diff --git a/kstars/ekos/scheduler/scheduler.h b/kstars/ekos/scheduler/scheduler.h --- a/kstars/ekos/scheduler/scheduler.h +++ b/kstars/ekos/scheduler/scheduler.h @@ -92,10 +92,12 @@ SCHEDCOL_NAME = 0, SCHEDCOL_STATUS, SCHEDCOL_CAPTURES, + SCHEDCOL_ALTITUDE, SCHEDCOL_SCORE, SCHEDCOL_STARTTIME, SCHEDCOL_ENDTIME, SCHEDCOL_DURATION, + SCHEDCOL_LEADTIME, SCHEDCOL_COUNT } SchedulerColumns; @@ -169,9 +171,12 @@ * @brief findAltitude Find altitude given a specific time * @param target Target * @param when date time to find altitude + * @param is_setting whether target is setting at the argument time (optional). + * @param debug outputs calculation to log file (optional). * @return Altitude of the target at the specific date and time given. + * @warning This function uses the current KStars geolocation. */ - static double findAltitude(const SkyPoint &target, const QDateTime &when); + static double findAltitude(const SkyPoint &target, const QDateTime &when, bool *is_setting = nullptr, bool debug = false); /** @defgroup SchedulerDBusInterface Ekos DBus Interface - Scheduler Module * Ekos::Align interface provides primary functions to run and stop the scheduler. @@ -201,6 +206,11 @@ */ Q_SCRIPTABLE void resetAllJobs(); + /** DBUS interface function. + * @brief Resets all jobs to IDLE + */ + Q_SCRIPTABLE void sortJobsPerAltitude(); + Ekos::SchedulerState status() { return state; } void setProfile(const QString &profile) {schedulerProfileCombo->setCurrentText(profile);} @@ -318,6 +328,11 @@ */ void clickQueueTable(QModelIndex index); + /** + * @brief reorderJobs Change the order of jobs in the UI based on a subset of its jobs. + */ + void reorderJobs(QList reordered_sublist); + /** * @brief moveJobUp Move the selected job up in the job list. */ @@ -435,53 +450,61 @@ void executeScript(const QString &filename); - int16_t getDarkSkyScore(const QDateTime &observationDateTime); + /** + * @brief getDarkSkyScore Get the dark sky score of a date and time. The further from dawn the better. + * @param when date and time to check the dark sky score, now if omitted + * @return Dark sky score. Daylight get bad score, as well as pre-dawn to dawn. + */ + int16_t getDarkSkyScore(QDateTime const &when = QDateTime()) const; /** * @brief getAltitudeScore Get the altitude score of an object. The higher the better - * @param job Active job - * @param when At what time to check the target altitude - * @return Altitude score. Altitude below minimum default of 15 degrees but above horizon get -20 score. Bad altitude below minimum required altitude or below horizon get -1000 score. + * @param job Target job + * @param when date and time to check the target altitude, now if omitted. + * @return Altitude score. Target altitude below minimum altitude required by job or setting target under 3 degrees below minimum altitude get bad score. */ - int16_t getAltitudeScore(SchedulerJob *job, QDateTime when); + int16_t getAltitudeScore(SchedulerJob const *job, QDateTime const &when = QDateTime()) const; /** * @brief getMoonSeparationScore Get moon separation score. The further apart, the better, up a maximum score of 20. * @param job Target job - * @param when What time to check the moon separation? + * @param when date and time to check the moon separation, now if omitted. * @return Moon separation score */ - int16_t getMoonSeparationScore(SchedulerJob *job, QDateTime when); + int16_t getMoonSeparationScore(SchedulerJob const *job, QDateTime const &when = QDateTime()) const; /** * @brief calculateJobScore Calculate job dark sky score, altitude score, and moon separation scores and returns the sum. - * @param job job to evaluate - * @param when time to evaluate constraints + * @param job Target + * @param when date and time to evaluate constraints, now if omitted. * @return Total score */ - int16_t calculateJobScore(SchedulerJob *job, QDateTime when); + int16_t calculateJobScore(SchedulerJob const *job, QDateTime const &when = QDateTime()) const; /** - * @brief getWeatherScore Get weather condition score. - * @return If weather condition OK, return 0, if warning return -500, if alert return -1000 + * @brief getWeatherScore Get current weather condition score. + * @return If weather condition OK, return score 0, else bad score. */ - int16_t getWeatherScore(); + int16_t getWeatherScore() const; /** * @brief calculateAltitudeTime calculate the altitude time given the minimum altitude given. * @param job active target * @param minAltitude minimum altitude required * @param minMoonAngle minimum separation from the moon. -1 to ignore. - * @return True if found a time in the night where the object is at or above the minimum altitude, false otherise. + * @param when date and time to start searching from, now if omitted. + * @return The date and time the target is at or above the argument altitude, valid if found, invalid if not achievable (always under altitude). */ - bool calculateAltitudeTime(SchedulerJob *job, double minAltitude, double minMoonAngle = -1); + QDateTime calculateAltitudeTime(SchedulerJob const *job, double minAltitude, double minMoonAngle = -1, QDateTime const &when = QDateTime()) const; /** * @brief calculateCulmination find culmination time adjust for the job offset - * @param job Active job - * @return True if culmination time adjust for offset is a valid time in the night + * @param job active target + * @param offset_minutes offset in minutes before culmination to search for. + * @param when date and time to start searching from, now if omitted + * @return The date and time the target is in entering the culmination interval, valid if found, invalid if not achievable (currently always valid). */ - bool calculateCulmination(SchedulerJob *job); + QDateTime calculateCulmination(SchedulerJob const *job, int offset_minutes, QDateTime const &when = QDateTime()) const; /** * @brief calculateDawnDusk Get dawn and dusk times for today @@ -598,7 +621,7 @@ * @param job scheduler job * @return Separation in degrees */ - double getCurrentMoonSeparation(SchedulerJob *job); + double getCurrentMoonSeparation(SchedulerJob const *job) const; /** * @brief updatePreDawn Update predawn time depending on current time and user offset diff --git a/kstars/ekos/scheduler/scheduler.cpp b/kstars/ekos/scheduler/scheduler.cpp --- a/kstars/ekos/scheduler/scheduler.cpp +++ b/kstars/ekos/scheduler/scheduler.cpp @@ -119,6 +119,12 @@ evaluateOnlyB->setIcon(QIcon::fromTheme("system-reboot")); evaluateOnlyB->setToolTip(i18n("Reset state and force reevaluation of all observation jobs.")); evaluateOnlyB->setAttribute(Qt::WA_LayoutUsesWidgetRect); + sortJobsB->setIcon(QIcon::fromTheme("transform-move-vertical")); + sortJobsB->setToolTip(i18n("Reset state and sort observation jobs per altitude and movement in sky, using the start time of the first job.\n" + "This action sorts setting targets before rising targets, and may help scheduling when starting your observation.\n" + "Option \"Sort Jobs by Altitude and Priority\" keeps the job list sorted this way, but with current time as reference.\n" + "Note the algorithm firsts calculate all altitudes using the same time, then evaluates jobs.")); + sortJobsB->setAttribute(Qt::WA_LayoutUsesWidgetRect); mosaicB->setIcon(QIcon::fromTheme("zoom-draw")); mosaicB->setAttribute(Qt::WA_LayoutUsesWidgetRect); @@ -162,6 +168,7 @@ connect(queueUpB, &QPushButton::clicked, this, &Scheduler::moveJobUp); connect(queueDownB, &QPushButton::clicked, this, &Scheduler::moveJobDown); connect(evaluateOnlyB, &QPushButton::clicked, this, &Scheduler::startJobEvaluation); + connect(sortJobsB, &QPushButton::clicked, this, &Scheduler::sortJobsPerAltitude); connect(queueTable, &QAbstractItemView::clicked, this, &Scheduler::clickQueueTable); connect(queueTable, &QAbstractItemView::doubleClicked, this, &Scheduler::loadJob); @@ -469,12 +476,6 @@ watchJobChanges(false); - /* Warn if appending a job after infinite repeat */ - /* FIXME: alter looping job priorities so that they are rescheduled later */ - foreach(SchedulerJob * job, jobs) - if(SchedulerJob::FINISH_LOOP == job->getCompletionCondition()) - appendLogText(i18n("Warning: Job '%1' has completion condition set to infinite repeat, other jobs may not execute.",job->getName())); - /* Create or Update a scheduler job */ int currentRow = queueTable->currentRow(); SchedulerJob * job = nullptr; @@ -545,7 +546,7 @@ if (altConstraintCheck->isChecked()) job->setMinAltitude(minAltitude->value()); else - job->setMinAltitude(-1); + job->setMinAltitude(-90); // Do we have minimum moon separation constraint? if (moonSeparationCheck->isChecked()) job->setMinMoonSeparation(minMoonSeparation->value()); @@ -566,10 +567,6 @@ appendLogText(i18n("Warning: job '%1' has startup time %2 resulting in a negative score, and will be marked invalid when processed.", job->getName(), job->getStartupTime().toString(job->getDateTimeDisplayFormat()))); - /* Warn if appending a job with a startup time that is in the past */ - if (job->getStartupTime() < KStarsData::Instance()->lt()) - appendLogText(i18n("Warning: job '%1' has fixed startup time %2 set in the past, and will be marked invalid when evaluated.", - job->getName(), job->getStartupTime().toString(job->getDateTimeDisplayFormat()))); } // #3 Completion conditions @@ -640,49 +637,55 @@ } } - /* FIXME: Move part of the new job cell-wiring to setJobStatusCells */ - - QTableWidgetItem *nameCell = (jobUnderEdit >= 0) ? queueTable->item(currentRow, static_cast(SCHEDCOL_NAME)) : new QTableWidgetItem(); - if (jobUnderEdit == -1) queueTable->setItem(currentRow, static_cast(SCHEDCOL_NAME), nameCell); - nameCell->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter); - nameCell->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); - job->setNameCell(nameCell); - - QTableWidgetItem *statusCell = (jobUnderEdit >= 0) ? queueTable->item(currentRow, static_cast(SCHEDCOL_STATUS)) : new QTableWidgetItem(); - if (jobUnderEdit == -1) queueTable->setItem(currentRow, static_cast(SCHEDCOL_STATUS), statusCell); - statusCell->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter); - statusCell->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); - job->setStatusCell(statusCell); - - QTableWidgetItem *captureCount = (jobUnderEdit >= 0) ? queueTable->item(currentRow, static_cast(SCHEDCOL_CAPTURES)) : new QTableWidgetItem(); - if (jobUnderEdit == -1) queueTable->setItem(currentRow, static_cast(SCHEDCOL_CAPTURES), captureCount); - captureCount->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter); - captureCount->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); - job->setCaptureCountCell(captureCount); - - QTableWidgetItem *scoreValue = (jobUnderEdit >= 0) ? queueTable->item(currentRow, static_cast(SCHEDCOL_SCORE)) : new QTableWidgetItem(); - if (jobUnderEdit == -1) queueTable->setItem(currentRow, static_cast(SCHEDCOL_SCORE), scoreValue); - scoreValue->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter); - scoreValue->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); - job->setScoreCell(scoreValue); - - QTableWidgetItem *startupCell = (jobUnderEdit >= 0) ? queueTable->item(currentRow, static_cast(SCHEDCOL_STARTTIME)) : new QTableWidgetItem(); - if (jobUnderEdit == -1) queueTable->setItem(currentRow, static_cast(SCHEDCOL_STARTTIME), startupCell); - startupCell->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter); - startupCell->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); - job->setStartupCell(startupCell); - - QTableWidgetItem *completionCell = (jobUnderEdit >= 0) ? queueTable->item(currentRow, static_cast(SCHEDCOL_ENDTIME)) : new QTableWidgetItem(); - if (jobUnderEdit == -1) queueTable->setItem(currentRow, static_cast(SCHEDCOL_ENDTIME), completionCell); - completionCell->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter); - completionCell->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); - job->setCompletionCell(completionCell); - - QTableWidgetItem *estimatedTimeCell = (jobUnderEdit >= 0) ? queueTable->item(currentRow, static_cast(SCHEDCOL_DURATION)) : new QTableWidgetItem(); - if (jobUnderEdit == -1) queueTable->setItem(currentRow, static_cast(SCHEDCOL_DURATION), estimatedTimeCell); - estimatedTimeCell->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter); - estimatedTimeCell->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); - job->setEstimatedTimeCell(estimatedTimeCell); + if (-1 == jobUnderEdit) + { + QTableWidgetItem *nameCell = new QTableWidgetItem(); + queueTable->setItem(currentRow, static_cast(SCHEDCOL_NAME), nameCell); + nameCell->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter); + nameCell->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); + + QTableWidgetItem *statusCell = new QTableWidgetItem(); + queueTable->setItem(currentRow, static_cast(SCHEDCOL_STATUS), statusCell); + statusCell->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter); + statusCell->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); + + QTableWidgetItem *captureCount = new QTableWidgetItem(); + queueTable->setItem(currentRow, static_cast(SCHEDCOL_CAPTURES), captureCount); + captureCount->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter); + captureCount->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); + + QTableWidgetItem *scoreValue = new QTableWidgetItem(); + queueTable->setItem(currentRow, static_cast(SCHEDCOL_SCORE), scoreValue); + scoreValue->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter); + scoreValue->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); + + QTableWidgetItem *startupCell = new QTableWidgetItem(); + queueTable->setItem(currentRow, static_cast(SCHEDCOL_STARTTIME), startupCell); + startupCell->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter); + startupCell->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); + + QTableWidgetItem *altitudeCell = new QTableWidgetItem(); + queueTable->setItem(currentRow, static_cast(SCHEDCOL_ALTITUDE), altitudeCell); + altitudeCell->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter); + altitudeCell->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); + + QTableWidgetItem *completionCell = new QTableWidgetItem(); + queueTable->setItem(currentRow, static_cast(SCHEDCOL_ENDTIME), completionCell); + completionCell->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter); + completionCell->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); + + QTableWidgetItem *estimatedTimeCell = new QTableWidgetItem(); + queueTable->setItem(currentRow, static_cast(SCHEDCOL_DURATION), estimatedTimeCell); + estimatedTimeCell->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter); + estimatedTimeCell->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); + + QTableWidgetItem *leadTimeCell = new QTableWidgetItem(); + queueTable->setItem(currentRow, static_cast(SCHEDCOL_LEADTIME), leadTimeCell); + leadTimeCell->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter); + leadTimeCell->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); + } + + setJobStatusCells(currentRow); /* We just added or saved a job, so we have a job in the list - enable relevant buttons */ queueSaveAsB->setEnabled(true); @@ -768,7 +771,7 @@ break; } - if (job->getMinAltitude() >= 0) + if (-90 < job->getMinAltitude()) { altConstraintCheck->setChecked(true); minAltitude->setValue(job->getMinAltitude()); @@ -869,11 +872,55 @@ queueUpB->setEnabled(false); queueDownB->setEnabled(false); } + sortJobsB->setEnabled(can_reorder); removeFromQueueB->setEnabled(can_delete); } +void Scheduler::reorderJobs(QList reordered_sublist) +{ + int destinationRow = 0; + foreach (SchedulerJob* job, reordered_sublist) + { + /* Find the reordered job's index in the list */ + int const currentRow = jobs.indexOf(job); + + /* Move job to expected location if needed, disregarding jobs that are not in the list - safeguard */ + /* Move jobs instead of swapping them, this will preserve the order of the remaining jobs */ + if (-1 != currentRow) + if (destinationRow != currentRow) + jobs.move(currentRow, destinationRow); + + /* Eventually move selection with moved job */ + if (queueTable->currentRow() == currentRow) + queueTable->selectRow(destinationRow); + + destinationRow++; + } + + /* Post checks */ + foreach (SchedulerJob* job, reordered_sublist) + { + Q_ASSERT_X(jobs.indexOf(job) == reordered_sublist.indexOf(job), __FUNCTION__, "Reordered job is at expected location"); + Q_ASSERT_X(1 == jobs.count(job), __FUNCTION__, "Reordering jobs does not create additional jobs"); + } + + /* Reassign status cells for all jobs, and reset them */ + for (int row = 0; row < jobs.size(); row++) + setJobStatusCells(row); + + /* Restore job manipulation settings */ + setJobManipulation(!Options::sortSchedulerJobs(), true); + + /* Reordering occurs while evaluating, not from user action, so don't re-evaluate, only make the list dirty */ + mDirty = true; +} + void Scheduler::moveJobUp() { + /* No move if jobs are sorted automatically */ + if (Options::sortSchedulerJobs()) + return; + int const rowCount = queueTable->rowCount(); int const currentRow = queueTable->currentRow(); int const destinationRow = currentRow - 1; @@ -893,20 +940,22 @@ queueTable->selectRow(destinationRow); setJobManipulation(!Options::sortSchedulerJobs(), true); - /* Make list modified */ - setDirty(); - - /* Reset all jobs starting from the one moved */ - for (int i = currentRow; i < jobs.size(); i++) - jobs.at(i)->reset(); + /* Jobs are now sorted, so reset all later jobs */ + for (int row = destinationRow; row < jobs.size(); row++) + jobs.at(row)->reset(); - /* Run evaluation as jobs that can run now changed order - saveJob will only evaluate if a job is edited */ + /* Make list modified and evaluate jobs */ + mDirty = true; jobEvaluationOnly = true; evaluateJobs(); } void Scheduler::moveJobDown() { + /* No move if jobs are sorted automatically */ + if (Options::sortSchedulerJobs()) + return; + int const rowCount = queueTable->rowCount(); int const currentRow = queueTable->currentRow(); int const destinationRow = currentRow + 1; @@ -926,14 +975,12 @@ queueTable->selectRow(destinationRow); setJobManipulation(!Options::sortSchedulerJobs(), true); - /* Make list modified */ - setDirty(); - - /* Reset all jobs starting from the one moved */ - for (int i = currentRow; i < jobs.size(); i++) - jobs.at(i)->reset(); + /* Jobs are now sorted, so reset all later jobs */ + for (int row = currentRow; row < jobs.size(); row++) + jobs.at(row)->reset(); - /* Run evaluation as jobs that can run now changed order - saveJob will only evaluate if a job is edited */ + /* Make list modified and evaluate jobs */ + mDirty = true; jobEvaluationOnly = true; evaluateJobs(); } @@ -949,9 +996,12 @@ job->setStatusCell(queueTable->item(row, static_cast(SCHEDCOL_STATUS))); job->setCaptureCountCell(queueTable->item(row, static_cast(SCHEDCOL_CAPTURES))); job->setScoreCell(queueTable->item(row, static_cast(SCHEDCOL_SCORE))); + job->setAltitudeCell(queueTable->item(row, static_cast(SCHEDCOL_ALTITUDE))); job->setStartupCell(queueTable->item(row, static_cast(SCHEDCOL_STARTTIME))); job->setCompletionCell(queueTable->item(row, static_cast(SCHEDCOL_ENDTIME))); job->setEstimatedTimeCell(queueTable->item(row, static_cast(SCHEDCOL_DURATION))); + job->setLeadTimeCell(queueTable->item(row, static_cast(SCHEDCOL_LEADTIME))); + job->updateJobCells(); } void Scheduler::resetJobEdit() @@ -1252,6 +1302,10 @@ void Scheduler::evaluateJobs() { + /* Don't evaluate if list is empty */ + if (jobs.isEmpty()) + return; + /* FIXME: it is possible to evaluate jobs while KStars has a time offset, so warn the user about this */ QDateTime const now = KStarsData::Instance()->lt(); @@ -1268,17 +1322,10 @@ sortedJobs.erase(std::remove_if(sortedJobs.begin(), sortedJobs.end(),[](SchedulerJob* job) { return SchedulerJob::JOB_ABORTED < job->getState(); }), sortedJobs.end()); - /* Then reorder jobs by priority */ - /* FIXME: refactor so all sorts are using the same predicates */ - /* FIXME: use std::stable_sort as qStableSort is deprecated */ - if (Options::sortSchedulerJobs()) - qStableSort(sortedJobs.begin(), sortedJobs.end(), SchedulerJob::increasingPriorityOrder); - - /* Then enumerate SchedulerJobs, scheduling only what is required */ + /* Then enumerate SchedulerJobs to consolidate imaging time */ foreach (SchedulerJob *job, sortedJobs) { /* Let aborted jobs be rescheduled later instead of forgetting them */ - /* FIXME: minimum altitude and altitude cutoff may cause loops here */ switch (job->getState()) { /* If job is idle, set it for evaluation */ @@ -1352,345 +1399,471 @@ job->setState(SchedulerJob::JOB_COMPLETE); continue; } + } + + /* + * At this step, we prepare scheduling of jobs. + * We filter out jobs that won't run now, and make sure jobs are not all starting at the same time. + */ + + updatePreDawn(); - // #1 Check startup conditions - switch (job->getStartupCondition()) + /* Remove complete and invalid jobs that could have appeared during the last evaluation */ + sortedJobs.erase(std::remove_if(sortedJobs.begin(), sortedJobs.end(),[](SchedulerJob* job) + { return SchedulerJob::JOB_ABORTED < job->getState(); }), sortedJobs.end()); + + /* If there are no jobs left to run in the filtered list, stop evaluation */ + if (sortedJobs.isEmpty()) + { + appendLogText(i18n("No jobs left in the scheduler queue.")); + setCurrentJob(nullptr); + jobEvaluationOnly = false; + return; + } + + /* If option says so, reorder by altitude and priority before sequencing */ + /* FIXME: refactor so all sorts are using the same predicates */ + /* FIXME: use std::stable_sort as qStableSort is deprecated */ + /* FIXME: dissociate altitude and priority, it's difficult to choose which predicate to use first */ + qCInfo(KSTARS_EKOS_SCHEDULER) << "Option to sort jobs based on priority and altitude is" << Options::sortSchedulerJobs(); + if (Options::sortSchedulerJobs()) + { + using namespace std::placeholders; + std::stable_sort(sortedJobs.begin(), sortedJobs.end(), + std::bind(SchedulerJob::decreasingAltitudeOrder, _1, _2, KStarsData::Instance()->lt())); + qStableSort(sortedJobs.begin(), sortedJobs.end(), SchedulerJob::increasingPriorityOrder); + } + + /* The first reordered job has no lead time - this could also be the delay from now to startup */ + sortedJobs.first()->setLeadTime(0); + + /* The objective of the following block is to make sure jobs are sequential in the list filtered previously. + * + * The algorithm manages overlap between jobs by stating that scheduled jobs that start sooner are non-movable. + * If the completion time of the previous job overlaps the current job, we offset the startup of the current job. + * Jobs that have no valid startup time when evaluated (ASAP jobs) are assigned an immediate startup time. + * The lead time from the Options registry is used as a buffer between jobs. + * + * Note about the situation where the current job overlaps the next job, and the next job is not movable: + * - If we mark the current job invalid, it will not be processed at all. Dropping is not satisfactory. + * - If we move the current job after the fixed job, we need to restart evaluation with a new list, and risk an + * infinite loop eventually. This means swapping schedules, and is incompatible with altitude/priority sort. + * - If we mark the current job aborted, it will be re-evaluated each time a job is complete to see if it can fit. + * Although puzzling for the end-user, this solution is dynamic: the aborted job might or might not be scheduled + * at the planned time slot. But as the end-user did not enforce the start time, this is acceptable. Moreover, the + * schedule will be altered by external events during the execution. + * + * Here are the constraints that have an effect on the job being examined, and indirectly on all subsequent jobs: + * - Twilight constraint moves jobs to the next dark sky interval. + * - Altitude constraint, currently linked with Moon separation, moves jobs to the next acceptable altitude time. + * - Culmination constraint moves jobs to the next transit time, with arbitrary offset. + * - Fixed startup time moves jobs to a fixed time, essentially making them non-movable, or invalid if in the past. + * + * Here are the constraints that have an effect on jobs following the job being examined: + * - Repeats requirement increases the duration of the current job, pushing subsequent jobs. + * - Looping requirement causes subsequent jobs to become invalid (until dynamic priority is implemented). + * - Fixed completion makes subsequent jobs start after that boundary time. + * + * However, we need a way to inform the end-user about failed schedules clearly in the UI. + * The message to get through is that if jobs are not sorted by altitude/priority, the aborted or invalid jobs + * should be modified or manually moved to a better position. If jobs are sorted automatically, aborted jobs will + * be processed when possible, probably not at the expected moment. + */ + + // Make sure no two jobs have the same scheduled time or overlap with other jobs + for (int index = 0; index < sortedJobs.size(); index++) + { + SchedulerJob * const currentJob = sortedJobs.at(index); + + // At this point, a job with no valid start date is a problem, so consider invalid startup time is now + if (!currentJob->getStartupTime().isValid()) + currentJob->setStartupTime(now); + + // Locate the previous scheduled job, so that a full schedule plan may be actually consolidated + SchedulerJob const * previousJob = nullptr; + for (int i = index - 1; 0 <= i; i--) { - // #1.1 ASAP? - case SchedulerJob::START_ASAP: + SchedulerJob const * const a_job = sortedJobs.at(i); + + if (SchedulerJob::JOB_SCHEDULED == a_job->getState()) { - /* Job is to be started as soon as possible, so check its current score */ - int16_t const score = calculateJobScore(job, now); + previousJob = a_job; + break; + } + } + + Q_ASSERT_X(nullptr == previousJob || previousJob != currentJob, __FUNCTION__, "Previous job considered for schedule is either undefined or not equal to current."); + + // Locate the next job - nothing special required except end of list check + SchedulerJob const * const nextJob = index + 1 < sortedJobs.size() ? sortedJobs.at(index + 1) : nullptr; - /* If it's not possible to run the job now, find proper altitude time */ - if (score < 0) + Q_ASSERT_X(nullptr == nextJob || nextJob != currentJob, __FUNCTION__, "Next job considered for schedule is either undefined or not equal to current."); + + // We're attempting to schedule the job 10 times before making it invalid + for (int attempt = 1; attempt < 11; attempt++) + { + qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Schedule attempt #%1 for %2-second job '%3' on row #%4 starting at %5, completing at %6.") + .arg(attempt) + .arg(static_cast(currentJob->getEstimatedTime())) + .arg(currentJob->getName()) + .arg(index + 1) + .arg(currentJob->getStartupTime().toString(currentJob->getDateTimeDisplayFormat())) + .arg(currentJob->getCompletionTime().toString(currentJob->getDateTimeDisplayFormat())); + + + // ----- #1 Should we reject the current job because of its fixed startup time? + // + // A job with fixed startup time must be processed at the time of startup, and may be late up to leadTime. + // When such a job repeats, its startup time is reinitialized to prevent abort - see completion algorithm. + // If such a job requires night time, minimum altitude or Moon separation, the consolidated startup time is checked for errors. + // If all restrictions are complied with, we bypass the rest of the verifications as the job cannot be moved. + + if (SchedulerJob::START_AT == currentJob->getFileStartupCondition()) + { + // Check whether the current job is too far in the past to be processed - if job is repeating, its startup time is already now + if (currentJob->getStartupTime().addSecs(static_cast (ceil(Options::leadTime()*60))) < now) { - // If Altitude or Dark score are negative, we try to schedule a better time for altitude and dark sky period. - if (calculateAltitudeTime(job, job->getMinAltitude() > 0 ? job->getMinAltitude() : 0, - job->getMinMoonSeparation())) - { - job->setState(SchedulerJob::JOB_SCHEDULED); - } - else - { - job->setState(SchedulerJob::JOB_INVALID); - } + currentJob->setState(SchedulerJob::JOB_INVALID); + - /* Keep the job score for current time, score will refresh as scheduler progresses */ - /* score = calculateJobScore(job, job->getStartupTime()); */ - job->setScore(score); + appendLogText(i18n("Warning: job '%1' has fixed startup time %2 set in the past, marking invalid.", + currentJob->getName(), currentJob->getStartupTime().toString(currentJob->getDateTimeDisplayFormat()))); + + break; } - /* If it's possible to run the job now, check weather */ - else if (isWeatherOK(job) == false) + // Check whether the current job has a positive dark sky score at the time of startup + else if (true == currentJob->getEnforceTwilight() && getDarkSkyScore(currentJob->getStartupTime()) < 0) { - appendLogText(i18n("Job '%1' cannot run now because of bad weather.", job->getName())); - job->setState(SchedulerJob::JOB_ABORTED); - job->setScore(BAD_SCORE); + currentJob->setState(SchedulerJob::JOB_INVALID); + + appendLogText(i18n("Warning: job '%1' has a fixed start time incompatible with its twilight restriction, marking invalid.", + currentJob->getName())); + + break; } - /* If weather is ok, schedule the job to run now */ - else + // Check whether the current job has a positive altitude score at the time of startup + else if (-90 < currentJob->getMinAltitude() && getAltitudeScore(currentJob, currentJob->getStartupTime()) < 0) { - appendLogText(i18n("Job '%1' is due to run as soon as possible.", job->getName())); - /* Give a proper start time, so that job can be rescheduled if others also start asap */ - job->setStartupTime(now); - job->setState(SchedulerJob::JOB_SCHEDULED); - job->setScore(score); - } - } - break; + currentJob->setState(SchedulerJob::JOB_INVALID); - // #1.2 Culmination? - case SchedulerJob::START_CULMINATION: - { - if (calculateCulmination(job)) - { - appendLogText(i18n("Job '%1' is scheduled at %2 for culmination.", job->getName(), - job->getStartupTime().toString(job->getDateTimeDisplayFormat()))); - job->setState(SchedulerJob::JOB_SCHEDULED); + appendLogText(i18n("Warning: job '%1' has a fixed start time incompatible with its altitude restriction, marking invalid.", + currentJob->getName())); + + break; } - else + // Check whether the current job has a positive Moon separation score at the time of startup + else if (0 < currentJob->getMinMoonSeparation() && getMoonSeparationScore(currentJob, currentJob->getStartupTime()) < 0) { - appendLogText(i18n("Job '%1' culmination cannot be scheduled, marking invalid.", job->getName())); - job->setState(SchedulerJob::JOB_INVALID); + currentJob->setState(SchedulerJob::JOB_INVALID); + + appendLogText(i18n("Warning: job '%1' has a fixed start time incompatible with its Moon separation restriction, marking invalid.", + currentJob->getName())); + + break; } + + // This job is non-movable, we're done + currentJob->setState(SchedulerJob::JOB_SCHEDULED); + qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' is scheduled to start at %2, in compliance with fixed startup time requirement.") + .arg(currentJob->getName()) + .arg(currentJob->getStartupTime().toString(currentJob->getDateTimeDisplayFormat())); + + break; } - break; - // #1.3 Start at? - case SchedulerJob::START_AT: + // ----- #2 Should we delay the current job because it overlaps the previous job? + // + // The previous job is considered non-movable, and its completion, plus lead time, is the origin for the current job. + // If no previous job exists, or if all prior jobs in the list are rejected, there is no overlap. + // If there is a previous job, the current job is simply delayed to avoid an eventual overlap. + // IF there is a previous job but it never finishes, the current job is rejected. + // This scheduling obviously relies on imaging time estimation: because errors stack up, future startup times are less and less reliable. + + if (nullptr != previousJob) { - if (job->getCompletionCondition() == SchedulerJob::FINISH_AT) + if (previousJob->getCompletionTime().isValid()) { - if (job->getCompletionTime() <= job->getStartupTime()) + // Calculate time we should be at after finishing the previous job + QDateTime const previousCompletionTime = previousJob->getCompletionTime().addSecs(static_cast (ceil(Options::leadTime()*60.0))); + + // Delay the current job to completion of its previous sibling if needed - this updates the completion time automatically + if (currentJob->getStartupTime() < previousCompletionTime) { - appendLogText(i18n("Job '%1' completion time (%2) could not be achieved before start up time (%3), marking invalid", job->getName(), - job->getCompletionTime().toString(job->getDateTimeDisplayFormat()), - job->getStartupTime().toString(job->getDateTimeDisplayFormat()))); - job->setState(SchedulerJob::JOB_INVALID); + currentJob->setStartupTime(previousCompletionTime); + + qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' is scheduled to start at %2, %3 seconds after %4, in compliance with previous job completion requirement.") + .arg(currentJob->getName()) + .arg(currentJob->getStartupTime().toString(currentJob->getDateTimeDisplayFormat())) + .arg(previousJob->getCompletionTime().secsTo(currentJob->getStartupTime())) + .arg(previousJob->getCompletionTime().toString(previousJob->getDateTimeDisplayFormat())); + + // If the job is repeating or looping, re-estimate imaging duration - error case may be a bug + if (SchedulerJob::FINISH_SEQUENCE != currentJob->getCompletionCondition()) + if (false == estimateJobTime(currentJob)) + currentJob->setState(SchedulerJob::JOB_INVALID); + continue; } } - - int const timeUntil = now.secsTo(job->getStartupTime()); - - // If starting time already passed by 5 minutes (default), we mark the job as invalid or aborted - if (timeUntil < (-1 * Options::leadTime() * 60)) + else { - dms const passedUp(-timeUntil * 15.0 / 3600.0); + currentJob->setState(SchedulerJob::JOB_INVALID); - /* Mark the job invalid only if its startup time was a user request, else just abort it for later reschedule */ - if (job->getFileStartupCondition() == SchedulerJob::START_AT) - { - appendLogText(i18n("Job '%1' startup time was fixed at %2, and is already passed by %3, marking invalid.", - job->getName(), job->getStartupTime().toString(job->getDateTimeDisplayFormat()), passedUp.toHMSString())); - job->setState(SchedulerJob::JOB_INVALID); - } - /* Don't abort a job that is repeating because it started long ago, that delay is expected */ - else if (job->getRepeatsRequired() <= 1) - { - appendLogText(i18n("Job '%1' startup time was %2, and is already passed by %3, marking aborted.", - job->getName(), job->getStartupTime().toString(job->getDateTimeDisplayFormat()), passedUp.toHMSString())); - job->setState(SchedulerJob::JOB_ABORTED); - } + appendLogText(i18n("Warning: Job '%1' cannot start because its previous sibling has no completion time, marking invalid.", + currentJob->getName())); + + break; } - // Start scoring once we reach startup time - else if (timeUntil <= 0) + + currentJob->setLeadTime(previousJob->getCompletionTime().secsTo(currentJob->getStartupTime())); + + Q_ASSERT_X(previousJob->getCompletionTime() < currentJob->getStartupTime(), __FUNCTION__, "Previous and current jobs do not overlap."); + } + + + // ----- #3 Should we delay the current job because it overlaps daylight? + // + // Pre-dawn time rules whether a job may be started before dawn, or delayed to next night. + // Note that the case of START_AT jobs is considered earlier in the algorithm, thus may be omitted here. + // In addition to be hardcoded currently, the imaging duration is not reliable enough to start a short job during pre-dawn. + // However, completion time during daylight only causes a warning, as this case will be processed as the job runs. + + if (currentJob->getEnforceTwilight()) + { + // During that check, we don't verify the current job can actually complete before dawn. + // If the job is interrupted while running, it will be aborted and rescheduled at a later time. + + // We wouldn't start observation 30 mins (default) before dawn. + // FIXME: Refactor duplicated dawn/dusk calculations + double const earlyDawn = Dawn - Options::preDawnTime() / (60.0 * 24.0); + + // Compute dawn time for the startup date of the job + // FIXME: Use KAlmanac to find the real dawn/dusk time for the day the job is supposed to be processed + QDateTime const dawnDateTime(currentJob->getStartupTime().date(), QTime(0,0).addSecs(earlyDawn * 24 * 3600)); + + // Check if the job starts after dawn + if (dawnDateTime < currentJob->getStartupTime()) { - /* Consolidate altitude, moon separation and sky darkness scores */ - int16_t const score = calculateJobScore(job, now); + // Compute dusk time for the startup date of the job - no lead time on dusk + QDateTime const duskDateTime(currentJob->getStartupTime().date(), QTime(0,0).addSecs(Dusk * 24 * 3600)); - if (score < 0) + // Check if the job starts before dusk + if (currentJob->getStartupTime() < duskDateTime) { - /* If job score is already negative, silently abort the job to avoid spamming the user */ - if (0 < job->getScore()) - { - if (job->getState() == SchedulerJob::JOB_EVALUATION) - appendLogText(i18n("Job '%1' evaluation failed with a score of %2, marking aborted.", - job->getName(), score)); - else if (timeUntil == 0) - appendLogText(i18n("Job '%1' updated score is %2 at startup time, marking aborted.", - job->getName(), score)); - else - appendLogText(i18n("Job '%1' updated score is %2 %3 seconds after startup time, marking aborted.", - job->getName(), score, abs(timeUntil))); - } + // Delay job to next dusk - we will check other requirements later on + currentJob->setStartupTime(duskDateTime); - job->setState(SchedulerJob::JOB_ABORTED); - job->setScore(score); - } - /* Positive score means job is already scheduled, so we check the weather, and if it is not OK, we set bad score until weather improves. */ - else if (isWeatherOK(job) == false) - { - appendLogText(i18n("Job '%1' cannot run now because of bad weather.", job->getName())); - job->setState(SchedulerJob::JOB_ABORTED); - job->setScore(BAD_SCORE); - } - /* Else record current score */ - else - { - appendLogText(i18n("Job '%1' will be run at %2.", job->getName(), job->getStartupTime().toString(job->getDateTimeDisplayFormat()))); - job->setState(SchedulerJob::JOB_SCHEDULED); - job->setScore(score); + qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' is scheduled to start at %2, in compliance with night time requirement.") + .arg(currentJob->getName()) + .arg(currentJob->getStartupTime().toString(currentJob->getDateTimeDisplayFormat())); + + continue; } } -#if 0 - // If it is in the future and originally was designated as ASAP job - // Job must be less than 12 hours away to be considered for re-evaluation - else if (timeUntil > (Options::leadTime() * 60) && (timeUntil < 12 * 3600) && - job->getFileStartupCondition() == SchedulerJob::START_ASAP) + + // Compute dawn time for the day following the startup time, but disregard the pre-dawn offset as we'll consider completion + // FIXME: Use KAlmanac to find the real dawn/dusk time for the day next to the day the job is supposed to be processed + QDateTime const nextDawnDateTime(currentJob->getStartupTime().date().addDays(1), QTime(0,0).addSecs(Dawn * 24 * 3600)); + + // Check if the completion date overlaps the next dawn, and issue a warning if so + if (nextDawnDateTime < currentJob->getCompletionTime()) { - QDateTime nextJobTime = now.addSecs(Options::leadTime() * 60); - if (job->getEnforceTwilight() == false || (now > duskDateTime && now < preDawnDateTime)) - { - appendLogText(i18n("Job '%1' can be scheduled under 12 hours, but will be re-evaluated at %2.", - job->getName(), nextJobTime.toString(job->getDateTimeDisplayFormat()))); - job->setStartupTime(nextJobTime); - } - job->setScore(BAD_SCORE); + appendLogText(i18n("Warning: job '%1' execution overlaps daylight, it will be interrupted at dawn and rescheduled on next night time.", + currentJob->getName())); } - // If time is far in the future, we make the score negative - else + + + Q_ASSERT_X(0 <= getDarkSkyScore(currentJob->getStartupTime()), __FUNCTION__, "Consolidated startup time results in a positive dark sky score."); + } + + + // ----- #4 Should we delay the current job because of its target culmination? + // + // Culmination uses the transit time, and fixes the startup time of the job to a particular offset around this transit time. + // This restriction may be used to start a job at the least air mass, or after a meridian flip. + // Culmination is scheduled before altitude restriction because it is normally more restrictive for the resulting startup time. + // It may happen that a target cannot rise enough to comply with the altitude restriction, but a culmination time is always valid. + + if (SchedulerJob::START_CULMINATION == currentJob->getFileStartupCondition()) + { + // Consolidate the culmination time, with offset, of the current job + QDateTime const nextCulminationTime = calculateCulmination(currentJob, currentJob->getCulminationOffset(), currentJob->getStartupTime()); + + if (nextCulminationTime.isValid()) // Guaranteed { - if (job->getState() == SchedulerJob::JOB_EVALUATION && - calculateJobScore(job, job->getStartupTime()) < 0) + if (currentJob->getStartupTime() < nextCulminationTime) { - appendLogText(i18n("Job '%1' can only be scheduled in more than 12 hours, marking aborted.", - job->getName())); - job->setState(SchedulerJob::JOB_ABORTED); + currentJob->setStartupTime(nextCulminationTime); + + qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' is scheduled to start at %2, in compliance with culmination requirements.") + .arg(currentJob->getName()) + .arg(currentJob->getStartupTime().toString(currentJob->getDateTimeDisplayFormat())); + continue; } - - /*score += BAD_SCORE;*/ } -#endif - /* Else simply refresh job score */ else { - qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' unmodified, will be run at %2.") - .arg(job->getName()) - .arg(job->getStartupTime().toString(job->getDateTimeDisplayFormat())); - job->setState(SchedulerJob::JOB_SCHEDULED); - job->setScore(calculateJobScore(job, now)); + currentJob->setState(SchedulerJob::JOB_INVALID); + + appendLogText(i18n("Warning: job '%1' requires culmination offset of %2 minutes, not achievable, marking invalid.", + currentJob->getName(), + QString("%L1").arg(currentJob->getCulminationOffset()))); + + break; } + // Don't test altitude here, because we will push the job during the next check step + // Q_ASSERT_X(0 <= getAltitudeScore(currentJob, currentJob->getStartupTime()), __FUNCTION__, "Consolidated altitude time results in a positive altitude score."); } - break; - } - if (job->getState() == SchedulerJob::JOB_EVALUATION) - { - qCDebug(KSTARS_EKOS_SCHEDULER) << "BUGBUG! Job '" << job->getName() << "' was unexpectedly not scheduled by evaluation."; - } - } - /* - * At this step, we scheduled all jobs that had to be scheduled because they could not start as soon as possible. - * Now we check the amount of jobs we have to run. - */ + // ----- #5 Should we delay the current job because its altitude is incorrect? + // + // Altitude time ensures the job is assigned a startup time when its target is high enough. + // As other restrictions, the altitude is only considered for startup time, completion time is managed while the job is running. + // Because a target setting down is a problem for the schedule, a cutoff altitude is added in the case the job target is past the meridian at startup time. + // FIXME: though arguable, Moon separation is also considered in that restriction check - move it to a separate case. - int invalidJobs = 0, completedJobs = 0, abortedJobs = 0, upcomingJobs = 0; + if (-90 < currentJob->getMinAltitude()) + { + // Consolidate a new altitude time from the startup time of the current job + QDateTime const nextAltitudeTime = calculateAltitudeTime(currentJob, currentJob->getMinAltitude(), currentJob->getMinMoonSeparation(), currentJob->getStartupTime()); - /* Partition jobs into invalid/aborted/completed/upcoming jobs */ - foreach (SchedulerJob *job, jobs) - { - switch (job->getState()) - { - case SchedulerJob::JOB_INVALID: - invalidJobs++; - break; + if (nextAltitudeTime.isValid()) + { + if (currentJob->getStartupTime() < nextAltitudeTime) + { + currentJob->setStartupTime(nextAltitudeTime); - case SchedulerJob::JOB_ERROR: - case SchedulerJob::JOB_ABORTED: - abortedJobs++; - break; + qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' is scheduled to start at %2, in compliance with altitude and Moon separation requirements.") + .arg(currentJob->getName()) + .arg(currentJob->getStartupTime().toString(currentJob->getDateTimeDisplayFormat())); - case SchedulerJob::JOB_COMPLETE: - completedJobs++; - break; + continue; + } + } + else + { + currentJob->setState(SchedulerJob::JOB_INVALID); - case SchedulerJob::JOB_SCHEDULED: - case SchedulerJob::JOB_BUSY: - upcomingJobs++; - break; + appendLogText(i18n("Warning: job '%1' requires minimum altitude %2 and Moon separation %3, not achievable, marking invalid.", + currentJob->getName(), + QString("%L1").arg(static_cast(currentJob->getMinAltitude()), 0, 'f', minAltitude->decimals()), + 0.0 < currentJob->getMinMoonSeparation() ? + QString("%L1").arg(static_cast(currentJob->getMinMoonSeparation()), 0, 'f', minMoonSeparation->decimals()) : + QString("-"))); - default: - break; - } - } + break; + } - /* And render some statistics */ - if (upcomingJobs == 0 && jobEvaluationOnly == false) - { - if (invalidJobs > 0) - qCDebug(KSTARS_EKOS_SCHEDULER) << QString("%L1 job(s) invalid.").arg(invalidJobs); + Q_ASSERT_X(0 <= getAltitudeScore(currentJob, currentJob->getStartupTime()), __FUNCTION__, "Consolidated altitude time results in a positive altitude score."); + } - if (abortedJobs > 0) - qCDebug(KSTARS_EKOS_SCHEDULER) << QString("%L1 job(s) aborted.").arg(abortedJobs); - if (completedJobs > 0) - qCDebug(KSTARS_EKOS_SCHEDULER) << QString("%L1 job(s) completed.").arg(completedJobs); - } + // ----- #6 Should we reject the current job because it overlaps the next job and that next job is not movable? + // + // If we have a blocker next to the current job, we compare the completion time of the current job and the startup time of this next job, taking lead time into account. + // This verification obviously relies on the imaging time to be reliable, but there's not much we can do at this stage of the implementation. - /* - * At this step, we still have jobs to run. - * We filter out jobs that won't run now, and make sure jobs are not all starting at the same time. - */ + if (nullptr != nextJob && SchedulerJob::START_AT == nextJob->getFileStartupCondition()) + { + // In the current implementation, it is not possible to abort a running job when the next job is supposed to start. + // Movable jobs after this one will be delayed, but non-movable jobs are considered blockers. - updatePreDawn(); + // Calculate time we have between the end of the current job and the next job + double const timeToNext = static_cast (currentJob->getCompletionTime().secsTo(nextJob->getStartupTime())); - /* Remove complete and invalid jobs that could have appeared during the last evaluation */ - sortedJobs.erase(std::remove_if(sortedJobs.begin(), sortedJobs.end(),[](SchedulerJob* job) - { return SchedulerJob::JOB_ABORTED < job->getState(); }), sortedJobs.end()); + // If that time is overlapping the next job, abort the current job + if (timeToNext < Options::leadTime()*60) + { + currentJob->setState(SchedulerJob::JOB_ABORTED); - /* If there are no jobs left to run in the filtered list, stop evaluation */ - if (sortedJobs.isEmpty()) - { - appendLogText(i18n("No jobs left in the scheduler queue.")); - setCurrentJob(nullptr); - jobEvaluationOnly = false; - return; - } + appendLogText(i18n("Warning: job '%1' is constrained by the start time of the next job, and cannot finish in time, marking aborted.", + currentJob->getName())); - /* Now that jobs are scheduled, possibly at the same time, reorder by altitude and priority again */ - if (Options::sortSchedulerJobs()) - { - qStableSort(sortedJobs.begin(), sortedJobs.end(), SchedulerJob::decreasingAltitudeOrder); - qStableSort(sortedJobs.begin(), sortedJobs.end(), SchedulerJob::increasingPriorityOrder); - } + break; + } - /* Reorder jobs by schedule time */ - qStableSort(sortedJobs.begin(), sortedJobs.end(), SchedulerJob::increasingStartupTimeOrder); + Q_ASSERT_X(currentJob->getCompletionTime().addSecs(Options::leadTime()*60) < nextJob->getStartupTime(), __FUNCTION__, "No overlap "); + } - // Our first job now takes priority over ALL others. - // So if any other jobs conflicts with ours, we re-schedule that job to another time. - SchedulerJob *firstJob = sortedJobs.first(); - QDateTime firstStartTime = firstJob->getStartupTime(); - QDateTime lastStartTime = firstJob->getStartupTime(); - double lastJobEstimatedTime = firstJob->getEstimatedTime(); - int daysCount = 0; - qCInfo(KSTARS_EKOS_SCHEDULER) << "Option to sort jobs based on priority and altitude is" << Options::sortSchedulerJobs(); - qCDebug(KSTARS_EKOS_SCHEDULER) << "First job after sort is" << firstJob->getName() << "starting at" << firstJob->getStartupTime().toString(firstJob->getDateTimeDisplayFormat()); + // ----- #7 Should we reject the current job because it exceeded its fixed completion time? + // + // This verification simply checks that because of previous jobs, the startup time of the current job doesn't exceed its fixed completion time. + // Its main objective is to catch wrong dates in the FINISH_AT configuration. - // Make sure no two jobs have the same scheduled time or overlap with other jobs - // FIXME: the rescheduling algorithm is incorrect when mixing asap and fixed startup times. - foreach (SchedulerJob *job, sortedJobs) - { - // First job is our time origin - if (job == firstJob) - continue; + if (SchedulerJob::FINISH_AT == currentJob->getCompletionCondition()) + { + if (currentJob->getCompletionTime() < currentJob->getStartupTime()) + { + currentJob->setState(SchedulerJob::JOB_INVALID); - // Bypass non-scheduled jobs - if (SchedulerJob::JOB_SCHEDULED != job->getState() || SchedulerJob::START_AT != job->getStartupCondition()) - continue; + appendLogText(i18n("Job '%1' completion time (%2) could not be achieved before start up time (%3)", + currentJob->getName(), + currentJob->getCompletionTime().toString(currentJob->getDateTimeDisplayFormat()), + currentJob->getStartupTime().toString(currentJob->getDateTimeDisplayFormat()))); - qCDebug(KSTARS_EKOS_SCHEDULER) << "Examining job" << job->getName() << "starting at" << job->getStartupTime().toString(job->getDateTimeDisplayFormat()); + break; + } + } - // At this point, a job with no valid start date is a problem - Q_ASSERT_X(job->getStartupTime().isValid(), __FUNCTION__, "Jobs in the schedule list have a valid startup time"); - double timeBetweenJobs = static_cast(std::abs(firstStartTime.secsTo(job->getStartupTime()))); + // ----- #8 Should we reject the current job because of weather? + // + // That verification is left for runtime + // + // if (false == isWeatherOK(currentJob)) + //{ + // currentJob->setState(SchedulerJob::JOB_ABORTED); + // + // appendLogText(i18n("Job '%1' cannot run now because of bad weather, marking aborted.", currentJob->getName())); + //} - qCDebug(KSTARS_EKOS_SCHEDULER) << "Job starts in" << timeBetweenJobs << "seconds (lead time" << Options::leadTime()*60 << ")"; - // If there are within 5 minutes of each other, delay scheduling time of the lower altitude one - if (timeBetweenJobs < (Options::leadTime()) * 60) - { - double delayJob = timeBetweenJobs + lastJobEstimatedTime; - - if (delayJob < (Options::leadTime() * 60)) - delayJob = Options::leadTime() * 60; - - QDateTime otherjob_time = lastStartTime.addSecs(delayJob); - QDateTime nextPreDawnTime = preDawnDateTime.addDays(daysCount); - // If other jobs starts after pre-dawn limit, then we schedule it to the next day. - // But we only take this action IF the job we are checking against starts _before_ dawn and our - // job therefore carry us after down, then there is an actual need to schedule it next day. - // FIXME: After changing time we are not evaluating job again when we should. - if (job->getEnforceTwilight() && lastStartTime < nextPreDawnTime && otherjob_time >= nextPreDawnTime) - { - QDateTime date; + // ----- #9 Update score for current time and mark evaluating jobs as scheduled - daysCount++; + currentJob->setScore(calculateJobScore(currentJob, now)); + currentJob->setState(SchedulerJob::JOB_SCHEDULED); - lastStartTime = job->getStartupTime().addDays(daysCount); - job->setStartupTime(lastStartTime); - date = lastStartTime.addSecs(delayJob); - } - else - { - lastStartTime = lastStartTime.addSecs(delayJob); - job->setStartupTime(lastStartTime); - } + qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' on row #%2 passed all checks after %3 attempts, will proceed at %4 for approximately %5 seconds, marking scheduled") + .arg(currentJob->getName()) + .arg(index + 1) + .arg(attempt) + .arg(currentJob->getStartupTime().toString(currentJob->getDateTimeDisplayFormat())) + .arg(currentJob->getEstimatedTime()); - /* Kept the informative log now that aborted jobs are rescheduled */ - appendLogText(i18n("Jobs '%1' and '%2' have close start up times, job '%2' is rescheduled to %3.", - firstJob->getName(), job->getName(), job->getStartupTime().toString(job->getDateTimeDisplayFormat()))); + break; } - lastJobEstimatedTime = job->getEstimatedTime(); + // Check if job was successfully scheduled, else reject it + if (SchedulerJob::JOB_EVALUATION == currentJob->getState()) + { + currentJob->setState(SchedulerJob::JOB_INVALID); + + //appendLogText(i18n("Warning: job '%1' on row #%2 could not be scheduled during evaluation and is marked invalid, please review your plan.", + // currentJob->getName(), + // index + 1)); + +#if 0 + // Advices + if (-90 < currentJob->getMinAltitude()) + appendLogText(i18n("Job '%1' may require relaxing the current altitude requirement of %2 degrees.", + currentJob->getName(), + QString("%L1").arg(static_cast(currentJob->getMinAltitude()), 0, 'f', minAltitude->decimals))); + + if (SchedulerJob::START_CULMINATION == currentJob->getFileStartupCondition() && Options::leadTime() < 5) + appendLogText(i18n("Job '%1' may require increasing the current lead time of %2 minutes to make transit time calculation stable.", + currentJob->getName(), + Options::leadTime())); +#endif + } } + /* Apply sorting to queue table */ + reorderJobs(sortedJobs); + if (jobEvaluationOnly || state != SCHEDULER_RUNNIG) { qCInfo(KSTARS_EKOS_SCHEDULER) << "Ekos finished evaluating jobs, no job selection required."; @@ -1723,7 +1896,7 @@ /* Check if job can be processed right now */ if (job_to_execute->getFileStartupCondition() == SchedulerJob::START_ASAP) - if( 0 < calculateJobScore(job_to_execute, now)) + if( 0 <= calculateJobScore(job_to_execute, now)) job_to_execute->setStartupTime(now); qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' is selected for next observation with priority #%2 and score %3.") @@ -1757,156 +1930,163 @@ } } -double Scheduler::findAltitude(const SkyPoint &target, const QDateTime &when) +double Scheduler::findAltitude(const SkyPoint &target, const QDateTime &when, bool * is_setting, bool debug) { - // Make a copy - /*SkyPoint p = target; - QDateTime lt(when.date(), QTime()); - KStarsDateTime ut = KStarsData::Instance()->geo()->LTtoUT(KStarsDateTime(lt)); + // FIXME: block calculating target coordinates at a particular time is duplicated in several places - KStarsDateTime myUT = ut.addSecs(when.time().msecsSinceStartOfDay() / 1000); + GeoLocation * const geo = KStarsData::Instance()->geo(); - CachingDms LST = KStarsData::Instance()->geo()->GSTtoLST(myUT.gst()); - p.EquatorialToHorizontal(&LST, KStarsData::Instance()->geo()->lat()); + // Retrieve the argument date/time, or fall back to current time - don't use QDateTime's timezone! + KStarsDateTime ltWhen(when.isValid() ? + Qt::UTC == when.timeSpec() ? geo->UTtoLT(KStarsDateTime(when)) : when : + KStarsData::Instance()->lt()); - return p.alt().Degrees();*/ - - SkyPoint p = target; - KStarsDateTime lt(when); - CachingDms LST = KStarsData::Instance()->geo()->GSTtoLST(lt.gst()); - p.EquatorialToHorizontal(&LST, KStarsData::Instance()->geo()->lat()); + // Create a sky object with the target catalog coordinates + SkyObject o; + o.setRA0(target.ra0()); + o.setDec0(target.dec0()); - return p.alt().Degrees(); + // Update RA/DEC of the target for the current fraction of the day + KSNumbers numbers(ltWhen.djd()); + o.updateCoordsNow(&numbers); + + // Calculate alt/az coordinates using KStars instance's geolocation + CachingDms const LST = geo->GSTtoLST(geo->LTtoUT(ltWhen).gst()); + o.EquatorialToHorizontal(&LST, geo->lat()); + + // Hours are reduced to [0,24[, meridian being at 0 + double offset = LST.Hours() - o.ra().Hours(); + if (24.0 <= offset) + offset -= 24.0; + else if (offset < 0.0) + offset += 24.0; + bool const passed_meridian = 0.0 <= offset && offset < 12.0; + + if (debug) + qCDebug(KSTARS_EKOS_SCHEDULER) << QString("When:%9 LST:%8 RA:%1 RA0:%2 DEC:%3 DEC0:%4 alt:%5 setting:%6 HA:%7") + .arg(o.ra().toHMSString()) + .arg(o.ra0().toHMSString()) + .arg(o.dec().toHMSString()) + .arg(o.dec0().toHMSString()) + .arg(o.alt().Degrees()) + .arg(passed_meridian ? "yes":"no") + .arg(o.ra().Hours()) + .arg(LST.toHMSString()) + .arg(ltWhen.toString("HH:mm:ss")); + + if (is_setting) + *is_setting = passed_meridian; + + return o.alt().Degrees(); } -bool Scheduler::calculateAltitudeTime(SchedulerJob *job, double minAltitude, double minMoonAngle) +QDateTime Scheduler::calculateAltitudeTime(SchedulerJob const *job, double minAltitude, double minMoonAngle, QDateTime const &when) const { - // We wouldn't stat observation 30 mins (default) before dawn. - double const earlyDawn = Dawn - Options::preDawnTime() / (60.0 * 24.0); + // FIXME: block calculating target coordinates at a particular time is duplicated in several places - /* Compute UTC for beginning of today */ - QDateTime const lt(KStarsData::Instance()->lt().date(), QTime()); - KStarsDateTime const ut = geo->LTtoUT(KStarsDateTime(lt)); + // Retrieve the argument date/time, or fall back to current time - don't use QDateTime's timezone! + KStarsDateTime ltWhen(when.isValid() ? + Qt::UTC == when.timeSpec() ? geo->UTtoLT(KStarsDateTime(when)) : when : + KStarsData::Instance()->lt()); - /* Retrieve target coordinates to be converted to horizontal to determine altitude */ - SkyPoint target = job->getTargetCoords(); + // Create a sky object with the target catalog coordinates + SkyPoint const target = job->getTargetCoords(); + SkyObject o; + o.setRA0(target.ra0()); + o.setDec0(target.dec0()); - /* Retrieve the current fraction of the day */ - QTime const now = KStarsData::Instance()->lt().time(); - double const fraction = now.hour() + now.minute() / 60.0 + now.second() / 3600; + // Calculate the UT at the argument time + KStarsDateTime const ut = geo->LTtoUT(ltWhen); - /* This attempts to locate the first minute of the next 24 hours when the job target matches the altitude and moon constraints */ - for (double hour = fraction; hour < (fraction + 24); hour += 1.0 / 60.0) + // Within the next 24 hours, search when the job target matches the altitude and moon constraints + for (unsigned int minute = 0; minute < 24*60; minute++) { - double const rawFrac = (hour > 24 ? (hour - 24) : hour) / 24.0; + KStarsDateTime const ltOffset(ltWhen.addSecs(minute * 60)); - /* Test twilight enforcement, and if enforced, bail out if start time is during day */ - /* FIXME: rework day fraction loop to shift to dusk directly */ - if (job->getEnforceTwilight() && Dawn <= rawFrac && rawFrac <= Dusk) - continue; + // Update RA/DEC of the target for the current fraction of the day + KSNumbers numbers(ltOffset.djd()); + o.updateCoordsNow(&numbers); - /* Compute altitude of target for the current fraction of the day */ - KStarsDateTime const myUT = ut.addSecs(hour * 3600.0); - CachingDms const LST = geo->GSTtoLST(myUT.gst()); - target.EquatorialToHorizontal(&LST, geo->lat()); - double const altitude = target.alt().Degrees(); + // Compute local sidereal time for the current fraction of the day, calculate altitude + CachingDms const LST = geo->GSTtoLST(geo->LTtoUT(ltOffset).gst()); + o.EquatorialToHorizontal(&LST, geo->lat()); + double const altitude = o.alt().Degrees(); - if (altitude > minAltitude) + if (minAltitude <= altitude) { - QDateTime const startTime = geo->UTtoLT(myUT); + // Don't test proximity to dawn in this situation, we only cater for altitude here - /* Test twilight enforcement, and if enforced, bail out if start time is too close to dawn */ - if (job->getEnforceTwilight() && earlyDawn < rawFrac && rawFrac < Dawn) - { - appendLogText(i18n("Warning: job '%1' reaches an altitude of %2 degrees at %3 but will not be scheduled due to " - "close proximity to astronomical twilight rise.", - job->getName(), QString("%L1").arg(minAltitude, 0, 'f', 3), startTime.toString(job->getDateTimeDisplayFormat()))); - return false; - } - - /* Continue searching if Moon separation is not good enough */ - if (minMoonAngle > 0 && getMoonSeparationScore(job, startTime) < 0) + // Continue searching if Moon separation is not good enough + if (0 < minMoonAngle && getMoonSeparationScore(job, ltOffset) < 0) continue; - /* FIXME: the name of the function doesn't suggest the job can be modified */ - job->setStartupTime(startTime); - /* Kept the informative log because of the reschedule of aborted jobs */ - appendLogText(i18n("Job '%1' is scheduled to start at %2 where its altitude is %3 degrees.", job->getName(), - startTime.toString(job->getDateTimeDisplayFormat()), QString("%L1").arg(altitude, 0, 'f', 3))); - return true; + // Continue searching if target is setting and under the cutoff + double offset = LST.Hours() - o.ra().Hours(); + if (24.0 <= offset) + offset -= 24.0; + else if (offset < 0.0) + offset += 24.0; + if (0.0 <= offset && offset < 12.0) + if (altitude - SETTING_ALTITUDE_CUTOFF < minAltitude) + continue; + + return ltOffset; } } - /* FIXME: move this to the caller too to comment the decision to reject the job */ - if (minMoonAngle == -1) - { - if (job->getEnforceTwilight()) - { - appendLogText(i18n("Warning: job '%1' has no night time with an altitude above %2 degrees during the next 24 hours, marking invalid.", - job->getName(), QString("%L1").arg(minAltitude, 0, 'f', 3))); - } - else appendLogText(i18n("Warning: job '%1' cannot rise to an altitude above %2 degrees in the next 24 hours, marking invalid.", - job->getName(), QString("%L1").arg(minAltitude, 0, 'f', 3))); - } - else appendLogText(i18n("Warning: job '%1' cannot be scheduled with an altitude above %2 degrees with minimum moon " - "separation of %3 degrees in the next 24 hours, marking invalid.", - job->getName(), QString("%L1").arg(minAltitude, 0, 'f', 3), - QString("%L1").arg(minMoonAngle, 0, 'f', 3))); - return false; + return QDateTime(); } -bool Scheduler::calculateCulmination(SchedulerJob *job) +QDateTime Scheduler::calculateCulmination(SchedulerJob const *job, int offset_minutes, QDateTime const &when) const { - SkyPoint target = job->getTargetCoords(); + // FIXME: culmination calculation is a min altitude requirement, should be an interval altitude requirement + // FIXME: block calculating target coordinates at a particular time is duplicated in calculateCulmination - SkyObject o; + // Retrieve the argument date/time, or fall back to current time - don't use QDateTime's timezone! + KStarsDateTime ltWhen(when.isValid() ? + Qt::UTC == when.timeSpec() ? geo->UTtoLT(KStarsDateTime(when)) : when : + KStarsData::Instance()->lt()); + // Create a sky object with the target catalog coordinates + SkyPoint const target = job->getTargetCoords(); + SkyObject o; o.setRA0(target.ra0()); o.setDec0(target.dec0()); - o.EquatorialToHorizontal(KStarsData::Instance()->lst(), KStarsData::Instance()->geo()->lat()); + // Update RA/DEC for the argument date/time + KSNumbers numbers(ltWhen.djd()); + o.updateCoordsNow(&numbers); - QDateTime midnight(KStarsData::Instance()->lt().date(), QTime()); - KStarsDateTime dt = geo->LTtoUT(KStarsDateTime(midnight)); + // Calculate transit date/time at the argument date - transitTime requires UT and returns LocalTime + KStarsDateTime transitDateTime(ltWhen.date(), o.transitTime(geo->LTtoUT(ltWhen), geo), Qt::LocalTime); - QTime transitTime = o.transitTime(dt, geo); + // Shift transit date/time by the argument offset + KStarsDateTime observationDateTime = transitDateTime.addSecs(offset_minutes * 60); - qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' transit time is %2") - .arg(job->getName()) - .arg(transitTime.toString("hh:mm:ss")); - - int dayOffset = 0; - if (KStarsData::Instance()->lt().time() > transitTime) - dayOffset = 1; - - QDateTime observationDateTime(QDate::currentDate().addDays(dayOffset), - transitTime.addSecs(job->getCulminationOffset() * 60)); - - qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' observation time is %2 adjusted for %L3 min.") - .arg(job->getName()) - .arg(observationDateTime.toString(job->getDateTimeDisplayFormat())) - .arg(static_cast(job->getCulminationOffset()), 0, 'f', 3); + // Relax observation time, culmination calculation is stable at minute only + KStarsDateTime relaxedDateTime = observationDateTime.addSecs(Options::leadTime() * 60); - if (job->getEnforceTwilight() && getDarkSkyScore(observationDateTime) < 0) + // Verify resulting observation time is under lead time vs. argument time + // If sooner, delay by 8 hours to get to the next transit - perhaps in a third call + if (relaxedDateTime < ltWhen) { - appendLogText(i18n("Job '%1' target culminates during the day and cannot be scheduled for observation.", job->getName())); - return false; - } + qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' startup %2 is posterior to transit %3, shifting by 8 hours.") + .arg(job->getName()) + .arg(ltWhen.toString(job->getDateTimeDisplayFormat())) + .arg(relaxedDateTime.toString(job->getDateTimeDisplayFormat())); - if (observationDateTime < (static_cast(KStarsData::Instance()->lt()))) - { - appendLogText(i18n("Job '%1' observation time %2 is passed for today.", - job->getName(), job->getStartupTime().toString(job->getDateTimeDisplayFormat()))); - return false; + return calculateCulmination(job, offset_minutes, when.addSecs(8*60*60)); } + // Guarantees - culmination calculation is stable at minute level, so relax by lead time Q_ASSERT_X(observationDateTime.isValid(), __FUNCTION__, "Observation time for target culmination is valid."); + Q_ASSERT_X(ltWhen <= relaxedDateTime, __FUNCTION__, "Observation time for target culmination is at or after than argument time"); - job->setStartupTime(observationDateTime); - return true; + // Return consolidated culmination time + return Qt::UTC == observationDateTime.timeSpec() ? geo->UTtoLT(observationDateTime) : observationDateTime; } -int16_t Scheduler::getWeatherScore() +int16_t Scheduler::getWeatherScore() const { if (weatherCheck->isEnabled() == false || weatherCheck->isChecked() == false) return 0; @@ -1919,38 +2099,72 @@ return 0; } -int16_t Scheduler::getDarkSkyScore(const QDateTime &observationDateTime) +int16_t Scheduler::getDarkSkyScore(QDateTime const &when) const { - // if (job->getStartingCondition() == SchedulerJob::START_CULMINATION) - // return -1000; + double const secsPerDay = 24.0 * 3600.0; + double const minsPerDay = 24.0 * 60.0; + + // Dark sky score is calculated based on distance to today's dawn and next dusk. + // Option "Pre-dawn Time" avoids executing a job when dawn is approaching, and is a value in minutes. + // - If observation is between option "Pre-dawn Time" and dawn, score is BAD_SCORE/50. + // - If observation is before dawn today, score is fraction of the day from beginning of observation to dawn time, as percentage. + // - If observation is after dusk, score is fraction of the day from dusk to beginning of observation, as percentage. + // - If observation is between dawn and dusk, score is BAD_SCORE. + // + // If observation time is invalid, the score is calculated for the current day time. + // Note exact dusk time is considered valid in terms of night time, and will return a positive, albeit null, score. + + // FIXME: Dark sky score should consider the middle of the local night as best value. + // FIXME: Current algorithm uses the dawn and dusk of today, instead of the day of the observation. + + int const earlyDawnSecs = static_cast ((Dawn - static_cast (Options::preDawnTime()) / minsPerDay) * secsPerDay); + int const dawnSecs = static_cast (Dawn * secsPerDay); + int const duskSecs = static_cast (Dusk * secsPerDay); + int const obsSecs = (when.isValid() ? when : KStarsData::Instance()->lt()).time().msecsSinceStartOfDay()/1000; - int16_t score = 0; - double dayFraction = 0; + int16_t score = 0; - // Anything half an hour before dawn shouldn't be a good candidate - double earlyDawn = Dawn - Options::preDawnTime() / (60.0 * 24.0); + if (earlyDawnSecs <= obsSecs && obsSecs < dawnSecs) + { + score = BAD_SCORE / 50; - dayFraction = observationDateTime.time().msecsSinceStartOfDay() / (24.0 * 60.0 * 60.0 * 1000.0); + //qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Dark sky score at %1 is %2 (between pre-dawn and dawn).") + // .arg(observationDateTime.toString()) + // .arg(QString::asprintf("%+d", score)); + } + else if (obsSecs < dawnSecs) + { + score = static_cast ((dawnSecs - obsSecs) / secsPerDay) * 100; - // The farther the target from dawn, the better. - if (dayFraction > earlyDawn && dayFraction < Dawn) - score = BAD_SCORE / 50; - else if (dayFraction < Dawn) - score = (Dawn - dayFraction) * 100; - else if (dayFraction > Dusk) + //qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Dark sky score at %1 is %2 (before dawn).") + // .arg(observationDateTime.toString()) + // .arg(QString::asprintf("%+d", score)); + } + else if (duskSecs <= obsSecs) { - score = (dayFraction - Dusk) * 100; + score = static_cast ((obsSecs - duskSecs) / secsPerDay) * 100; + + //qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Dark sky score at %1 is %2 (after dusk).") + // .arg(observationDateTime.toString()) + // .arg(QString::asprintf("%+d", score)); } else + { score = BAD_SCORE; - qCDebug(KSTARS_EKOS_SCHEDULER) << "Dark sky score is" << score << "for time" << observationDateTime.toString(); + //qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Dark sky score at %1 is %2 (during daylight).") + // .arg(observationDateTime.toString()) + // .arg(QString::asprintf("%+d", score)); + } return score; } -int16_t Scheduler::calculateJobScore(SchedulerJob *job, QDateTime when) +int16_t Scheduler::calculateJobScore(SchedulerJob const *job, QDateTime const &when) const { + if (nullptr == job) + return BAD_SCORE; + /* Only consolidate the score if light frames are required, calibration frames can run whenever needed */ if (!job->getLightFramesRequired()) return 1000; @@ -1960,132 +2174,190 @@ /* As soon as one score is negative, it's a no-go and other scores are unneeded */ if (job->getEnforceTwilight()) - total += getDarkSkyScore(when); + { + int16_t const darkSkyScore = getDarkSkyScore(when); + + qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' dark sky score is %2 at %3") + .arg(job->getName()) + .arg(QString::asprintf("%+d", darkSkyScore)) + .arg(when.toString(job->getDateTimeDisplayFormat())); + + total += darkSkyScore; + } /* We still enforce altitude if the job is neither required to track nor guide, because this is too confusing for the end-user. * If we bypass calculation here, it must also be bypassed when checking job constraints in checkJobStage. */ if (0 <= total /*&& ((job->getStepPipeline() & SchedulerJob::USE_TRACK) || (job->getStepPipeline() & SchedulerJob::USE_GUIDE))*/) - total += getAltitudeScore(job, when); + { + int16_t const altitudeScore = getAltitudeScore(job, when); + + qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' altitude score is %2 at %3") + .arg(job->getName()) + .arg(QString::asprintf("%+d", altitudeScore)) + .arg(when.toString(job->getDateTimeDisplayFormat())); + + total += altitudeScore; + } if (0 <= total) - total += getMoonSeparationScore(job, when); + { + int16_t const moonSeparationScore = getMoonSeparationScore(job, when); + + qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' Moon separation score is %2 at %3") + .arg(job->getName()) + .arg(QString::asprintf("%+d", moonSeparationScore)) + .arg(when.toString(job->getDateTimeDisplayFormat())); + + total += moonSeparationScore; + } + + qCInfo(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' has a total score of %2 at %3.") + .arg(job->getName()) + .arg(QString::asprintf("%+d", total)) + .arg(when.toString(job->getDateTimeDisplayFormat())); - qCInfo(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' has a total score of %2").arg(job->getName()).arg(total); return total; } -int16_t Scheduler::getAltitudeScore(SchedulerJob *job, QDateTime when) +int16_t Scheduler::getAltitudeScore(SchedulerJob const *job, QDateTime const &when) const { - int16_t score = 0; - double currentAlt = findAltitude(job->getTargetCoords(), when); + // FIXME: block calculating target coordinates at a particular time is duplicated in several places + + // Retrieve the argument date/time, or fall back to current time - don't use QDateTime's timezone! + KStarsDateTime ltWhen(when.isValid() ? + Qt::UTC == when.timeSpec() ? geo->UTtoLT(KStarsDateTime(when)) : when : + KStarsData::Instance()->lt()); + + // Create a sky object with the target catalog coordinates + SkyPoint const target = job->getTargetCoords(); + SkyObject o; + o.setRA0(target.ra0()); + o.setDec0(target.dec0()); - if (currentAlt < 0) + // Update RA/DEC of the target for the current fraction of the day + KSNumbers numbers(ltWhen.djd()); + o.updateCoordsNow(&numbers); + + // Compute local sidereal time for the current fraction of the day, calculate altitude + CachingDms const LST = geo->GSTtoLST(geo->LTtoUT(ltWhen).gst()); + o.EquatorialToHorizontal(&LST, geo->lat()); + double const altitude = o.alt().Degrees(); + + int16_t score = BAD_SCORE - 1; + + // If altitude is negative, bad score + // FIXME: some locations may allow negative altitudes + if (altitude < 0) { score = BAD_SCORE; } - // If minimum altitude is specified - else if (job->getMinAltitude() > 0) + else if (-90 < job->getMinAltitude()) { - // if current altitude is lower that's not good - if (currentAlt < job->getMinAltitude()) + // If under altitude constraint, bad score + if (altitude < job->getMinAltitude()) score = BAD_SCORE; + // Else if setting and under altitude cutoff, job would end soon after starting, bad score + // FIXME: half bad score when under altitude cutoff risk getting positive again else { - // Get HA of actual object, and not of the mount as was done below - double HA = KStars::Instance()->data()->lst()->Hours() - job->getTargetCoords().ra().Hours(); - -#if 0 - if (indiState == INDI_READY) - { - QDBusReply haReply = mountInterface->call(QDBus::AutoDetect, "getHourAngle"); - if (haReply.error().type() == QDBusError::NoError) - HA = haReply.value(); - } -#endif - - // If already passed the merdian and setting we check if it is within setting alttidue cut off value (3 degrees default) - // If it is within that value then it is useless to start the job which will end very soon so we better look for a better job. - /* FIXME: don't use BAD_SCORE/2, a negative result implies the job has to be aborted - we'd be annoyed if that score became positive again */ - /* FIXME: bug here, raising target will get a negative score if under cutoff, issue mitigated by aborted jobs getting rescheduled */ - if (HA > 0 && (currentAlt - SETTING_ALTITUDE_CUTOFF) < job->getMinAltitude()) - score = BAD_SCORE / 2.0; - else - // Otherwise, adjust score and add current altitude to score weight - score = (1.5 * pow(1.06, currentAlt)) - (minAltitude->minimum() / 10.0); + double offset = LST.Hours() - o.ra().Hours(); + if (24.0 <= offset) + offset -= 24.0; + else if (offset < 0.0) + offset += 24.0; + if (0.0 <= offset && offset < 12.0) + if (altitude - SETTING_ALTITUDE_CUTOFF < job->getMinAltitude()) + score = BAD_SCORE / 2; } } - // If it's below minimum hard altitude (15 degrees now), set score to 10% of altitude value - else if (currentAlt < minAltitude->minimum()) - { - score = currentAlt / 10.0; - } - // If no minimum altitude, then adjust altitude score to account for current target altitude - else + // If not constrained but below minimum hard altitude, set score to 10% of altitude value + else if (altitude < minAltitude->minimum()) { - score = (1.5 * pow(1.06, currentAlt)) - (minAltitude->minimum() / 10.0); + score = static_cast (altitude / 10.0); } - qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' target altitude is %L3 degrees at %2 (score %4).") - .arg(job->getName()) - .arg(currentAlt, 0, 'f', 3) - .arg(when.toString(job->getDateTimeDisplayFormat())) - .arg(QString::asprintf("%+d", score)); + // Else default score calculation without altitude constraint + if (score < BAD_SCORE) + score = static_cast ((1.5 * pow(1.06, altitude)) - (minAltitude->minimum() / 10.0)); + + //qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' target altitude is %3 degrees at %2 (score %4).") + // .arg(job->getName()) + // .arg(when.toString(job->getDateTimeDisplayFormat())) + // .arg(currentAlt, 0, 'f', minAltitude->decimals()) + // .arg(QString::asprintf("%+d", score)); return score; } -double Scheduler::getCurrentMoonSeparation(SchedulerJob *job) +double Scheduler::getCurrentMoonSeparation(SchedulerJob const *job) const { - // Get target altitude given the time - SkyPoint p = job->getTargetCoords(); - QDateTime midnight(KStarsData::Instance()->lt().date(), QTime()); - KStarsDateTime ut = geo->LTtoUT(KStarsDateTime(midnight)); - KStarsDateTime myUT = ut.addSecs(KStarsData::Instance()->lt().time().msecsSinceStartOfDay() / 1000); - CachingDms LST = geo->GSTtoLST(myUT.gst()); - p.EquatorialToHorizontal(&LST, geo->lat()); + // FIXME: block calculating target coordinates at a particular time is duplicated in several places + + // Retrieve the argument date/time, or fall back to current time - don't use QDateTime's timezone! + KStarsDateTime ltWhen(KStarsData::Instance()->lt()); + + // Create a sky object with the target catalog coordinates + SkyPoint const target = job->getTargetCoords(); + SkyObject o; + o.setRA0(target.ra0()); + o.setDec0(target.dec0()); + + // Update RA/DEC of the target for the current fraction of the day + KSNumbers numbers(ltWhen.djd()); + o.updateCoordsNow(&numbers); // Update moon - ut = geo->LTtoUT(KStarsData::Instance()->lt()); - KSNumbers ksnum(ut.djd()); - LST = geo->GSTtoLST(ut.gst()); - moon->updateCoords(&ksnum, true, geo->lat(), &LST, true); + //ut = geo->LTtoUT(ltWhen); + //KSNumbers ksnum(ut.djd()); // BUG: possibly LT.djd() != UT.djd() because of translation + //LST = geo->GSTtoLST(ut.gst()); + CachingDms LST = geo->GSTtoLST(geo->LTtoUT(ltWhen).gst()); + moon->updateCoords(&numbers, true, geo->lat(), &LST, true); // Moon/Sky separation p - return moon->angularDistanceTo(&p).Degrees(); + return moon->angularDistanceTo(&o).Degrees(); } -int16_t Scheduler::getMoonSeparationScore(SchedulerJob *job, QDateTime when) +int16_t Scheduler::getMoonSeparationScore(SchedulerJob const *job, QDateTime const &when) const { - int16_t score = 0; + // FIXME: block calculating target coordinates at a particular time is duplicated in several places + + // Retrieve the argument date/time, or fall back to current time - don't use QDateTime's timezone! + KStarsDateTime ltWhen(when.isValid() ? + Qt::UTC == when.timeSpec() ? geo->UTtoLT(KStarsDateTime(when)) : when : + KStarsData::Instance()->lt()); + + // Create a sky object with the target catalog coordinates + SkyPoint const target = job->getTargetCoords(); + SkyObject o; + o.setRA0(target.ra0()); + o.setDec0(target.dec0()); - // Get target altitude given the time - SkyPoint p = job->getTargetCoords(); - QDateTime midnight(when.date(), QTime()); - KStarsDateTime ut = geo->LTtoUT(KStarsDateTime(midnight)); - KStarsDateTime myUT = ut.addSecs(when.time().msecsSinceStartOfDay() / 1000); - CachingDms LST = geo->GSTtoLST(myUT.gst()); - p.EquatorialToHorizontal(&LST, geo->lat()); - double currentAlt = p.alt().Degrees(); + // Update RA/DEC of the target for the current fraction of the day + KSNumbers numbers(ltWhen.djd()); + o.updateCoordsNow(&numbers); // Update moon - ut = geo->LTtoUT(KStarsDateTime(when)); - KSNumbers ksnum(ut.djd()); - LST = geo->GSTtoLST(ut.gst()); - moon->updateCoords(&ksnum, true, geo->lat(), &LST, true); + //ut = geo->LTtoUT(ltWhen); + //KSNumbers ksnum(ut.djd()); // BUG: possibly LT.djd() != UT.djd() because of translation + //LST = geo->GSTtoLST(ut.gst()); + CachingDms LST = geo->GSTtoLST(geo->LTtoUT(ltWhen).gst()); + moon->updateCoords(&numbers, true, geo->lat(), &LST, true); - double moonAltitude = moon->alt().Degrees(); + double const moonAltitude = moon->alt().Degrees(); // Lunar illumination % - double illum = moon->illum() * 100.0; + double const illum = moon->illum() * 100.0; // Moon/Sky separation p - double separation = moon->angularDistanceTo(&p).Degrees(); + double const separation = moon->angularDistanceTo(&o).Degrees(); // Zenith distance of the moon - double zMoon = (90 - moonAltitude); + double const zMoon = (90 - moonAltitude); // Zenith distance of target - double zTarget = (90 - currentAlt); + double const zTarget = (90 - o.alt().Degrees()); + + int16_t score = 0; // If target = Moon, or no illuminiation, or moon below horizon, return static score. if (zMoon == zTarget || illum == 0 || zMoon >= 90) @@ -2112,10 +2384,10 @@ // Limit to 0 to 20 score /= 5.0; - qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' target is %L3 degrees from Moon (score %2).") - .arg(job->getName()) - .arg(separation, 0, 'f', 3) - .arg(QString::asprintf("%+d", score)); + //qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' target is %L3 degrees from Moon (score %2).") + // .arg(job->getName()) + // .arg(separation, 0, 'f', 3) + // .arg(QString::asprintf("%+d", score)); return score; } @@ -2134,8 +2406,8 @@ duskDateTime.setTime(dusk); // FIXME: reduce spam by moving twilight time to a text label - appendLogText(i18n("Astronomical twilight: dusk at %1, dawn at %2, and current time is %3", - dusk.toString(), dawn.toString(), now.toString())); + //appendLogText(i18n("Astronomical twilight: dusk at %1, dawn at %2, and current time is %3", + // dusk.toString(), dawn.toString(), now.toString())); } void Scheduler::executeJob(SchedulerJob *job) @@ -3031,7 +3303,7 @@ } // #2 Check if altitude restriction still holds true - if (currentJob->getMinAltitude() > 0) + if (-90 < currentJob->getMinAltitude()) { SkyPoint p = currentJob->getTargetCoords(); @@ -3044,8 +3316,9 @@ if (isMountParked() == false) { appendLogText(i18n("Job '%1' current altitude (%2 degrees) crossed minimum constraint altitude (%3 degrees), " - "marking aborted.", - currentJob->getName(), p.alt().Degrees(), currentJob->getMinAltitude())); + "marking aborted.", currentJob->getName(), + QString("%L1").arg(0, p.alt().Degrees(), minAltitude->decimals()), + QString("%L1").arg(0, currentJob->getMinAltitude(), minAltitude->decimals()))); currentJob->setState(SchedulerJob::JOB_ABORTED); stopCurrentJobAction(); @@ -4066,7 +4339,7 @@ outstream << "" << endl; outstream << "" << endl; - if (job->getMinAltitude() > 0) + if (-90 < job->getMinAltitude()) outstream << "MinimumAltitude" << endl; if (job->getMinMoonSeparation() > 0) outstream << "MoonSeparation" @@ -4316,8 +4589,12 @@ } else if (currentJob->getCompletionCondition() == SchedulerJob::FINISH_REPEAT) { + /* If the job is about to repeat, decrease its repeat count and reset its start time */ if (0 < currentJob->getRepeatsRemaining()) + { currentJob->setRepeatsRemaining(currentJob->getRepeatsRemaining() - 1); + currentJob->setStartupTime(QDateTime()); + } /* Mark the job idle as well as all its duplicates for re-evaluation */ foreach(SchedulerJob *a_job, jobs) @@ -4625,7 +4902,13 @@ return; if (0 <= jobUnderEdit && state != SCHEDULER_RUNNIG && !queueTable->selectedItems().isEmpty()) + { + // Now that jobs are sorted, reset jobs that are later than the edited one for re-evaluation + for (int row = jobUnderEdit; row < jobs.size(); row++) + jobs.at(row)->reset(); + saveJob(); + } // For object selection, all fields must be filled bool const nameSelectionOK = !raBox->isEmpty() && !decBox->isEmpty() && !nameEdit->text().isEmpty(); @@ -4908,36 +5191,49 @@ qDeleteAll(seqJobs); + // FIXME: Move those ifs away to the caller in order to avoid estimating in those situations! + // We can't estimate times that do not finish when sequence is done if (schedJob->getCompletionCondition() == SchedulerJob::FINISH_LOOP) { // We can't know estimated time if it is looping indefinitely - appendLogText(i18n("Warning: job '%1' will be looping until Scheduler is stopped manually.", schedJob->getName())); schedJob->setEstimatedTime(-2); + + qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' is configured to loop until Scheduler is stopped manually, has undefined imaging time.") + .arg(schedJob->getName()); } // If we know startup and finish times, we can estimate time right away else if (schedJob->getStartupCondition() == SchedulerJob::START_AT && schedJob->getCompletionCondition() == SchedulerJob::FINISH_AT) { + // FIXME: SchedulerJob is probably doing this already qint64 const diff = schedJob->getStartupTime().secsTo(schedJob->getCompletionTime()); - appendLogText(i18n("Job '%1' will run for %2.", schedJob->getName(), dms(diff * 15.0 / 3600.0f).toHMSString())); schedJob->setEstimatedTime(diff); + + qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' has a startup time and fixed completion time, will run for %2.") + .arg(schedJob->getName()) + .arg(dms(diff * 15.0 / 3600.0f).toHMSString()); } // If we know finish time only, we can roughly estimate the time considering the job starts now else if (schedJob->getStartupCondition() != SchedulerJob::START_AT && schedJob->getCompletionCondition() == SchedulerJob::FINISH_AT) { qint64 const diff = KStarsData::Instance()->lt().secsTo(schedJob->getCompletionTime()); - appendLogText(i18n("Job '%1' will run for %2 if started now.", schedJob->getName(), dms(diff * 15.0 / 3600.0f).toHMSString())); schedJob->setEstimatedTime(diff); + + qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' has no startup time but fixed completion time, will run for %2 if started now.") + .arg(schedJob->getName()) + .arg(dms(diff * 15.0 / 3600.0f).toHMSString()); } // Rely on the estimated imaging time to determine whether this job is complete or not - this makes the estimated time null else if (totalImagingTime <= 0) { + schedJob->setEstimatedTime(0); + qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' will not run, complete with %2/%3 captures.") .arg(schedJob->getName()).arg(totalCompletedCount).arg(totalSequenceCount); - schedJob->setEstimatedTime(0); } + // Else consolidate with step durations else { if (schedJob->getLightFramesRequired()) @@ -4959,9 +5255,9 @@ } dms const estimatedTime(totalImagingTime * 15.0 / 3600.0); - qCInfo(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' estimated to take %2 to complete.").arg(schedJob->getName(), estimatedTime.toHMSString()); - schedJob->setEstimatedTime(totalImagingTime); + + qCInfo(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' estimated to take %2 to complete.").arg(schedJob->getName(), estimatedTime.toHMSString()); } return true; @@ -5578,14 +5874,42 @@ // Reset current job setCurrentJob(nullptr); + // Reset ALL scheduler jobs to IDLE and force-reset their completed count - no effect when progress is kept + for (SchedulerJob * job: jobs) + { + job->reset(); + job->setCompletedCount(0); + } + // Unconditionally update the capture storage updateCompletedJobsCount(true); - // Reset ALL scheduler jobs to IDLE and re-evaluate them all again + // And evaluate all pending jobs per the conditions set in each + jobEvaluationOnly = true; + evaluateJobs(); +} + +void Scheduler::sortJobsPerAltitude() +{ + // We require a first job to sort, so bail out if list is empty + if (jobs.isEmpty()) + return; + + // Don't reset current job + setCurrentJob(nullptr); + + // Don't reset scheduler jobs startup times before sorting - we need the first job startup time + + // Sort by startup time, using the first job time as reference for altitude calculations + using namespace std::placeholders; + std::stable_sort(jobs.begin() + 1, jobs.end(), + std::bind(SchedulerJob::decreasingAltitudeOrder, _1, _2, jobs.first()->getStartupTime())); + + // Now reset job before re-evaluation for (SchedulerJob * job: jobs) job->reset(); - // And evaluate all pending jobs per the conditions set in each + // Re-evaluate jobEvaluationOnly = true; evaluateJobs(); } @@ -5830,6 +6154,10 @@ if (state == SCHEDULER_RUNNIG) return; + // Reset capture count of all jobs before re-evaluating + foreach (SchedulerJob *job, jobs) + job->setCompletedCount(0); + // Evaluate all jobs, this refreshes storage and resets job states startJobEvaluation(); } diff --git a/kstars/ekos/scheduler/scheduler.ui b/kstars/ekos/scheduler/scheduler.ui --- a/kstars/ekos/scheduler/scheduler.ui +++ b/kstars/ekos/scheduler/scheduler.ui @@ -521,6 +521,28 @@ + + + + false + + + + 0 + 0 + + + + + 32 + 32 + + + + + + + @@ -680,6 +702,11 @@ Captures + + + Altitude + + Score @@ -690,7 +717,7 @@ Start Time - + End Time @@ -700,6 +727,11 @@ Est. Duration + + + Lead time + + diff --git a/kstars/ekos/scheduler/schedulerjob.h b/kstars/ekos/scheduler/schedulerjob.h --- a/kstars/ekos/scheduler/schedulerjob.h +++ b/kstars/ekos/scheduler/schedulerjob.h @@ -250,6 +250,12 @@ void setStartupCell(QTableWidgetItem *value); /** @} */ + /** @brief Shortcut to widget cell for altitude in the job queue table. */ + /** @{ */ + QTableWidgetItem *getAltitudeCell() const { return altitudeCell; } + void setAltitudeCell(QTableWidgetItem *value); + /** @} */ + /** @brief Time after which the job is considered complete. */ /** @{ */ QDateTime getCompletionTime() const { return completionTime; } @@ -274,6 +280,18 @@ void setEstimatedTimeCell(QTableWidgetItem *value); /** @} */ + /** @brief Estimation of the lead time the job will have to process. */ + /** @{ */ + int64_t getLeadTime() const { return leadTime; } + void setLeadTime(const int64_t &value); + /** @} */ + + /** @brief Shortcut to widget cell for estimated time in the job queue table. */ + /** @{ */ + QTableWidgetItem *getLeadTimeCell() const { return leadTimeCell; } + void setLeadTimeCell(QTableWidgetItem *value); + /** @} */ + /** @brief Current score of the scheduler job. */ /** @{ */ int getScore() const { return score; } @@ -312,7 +330,7 @@ /** @} */ /** @brief Refresh all cells connected to this SchedulerJob. */ - void updateJobCell(); + void updateJobCells(); /** @brief Resetting a job to original values: * - idle state and stage @@ -329,28 +347,37 @@ */ bool isDuplicateOf(SchedulerJob const *a_job) const { return this != a_job && name == a_job->name && sequenceFile == a_job->sequenceFile; } - /** @brief Compare ::SchedulerJob instances based on score. This is a qSort predicate, deprecated in QT5. + /** @brief Compare ::SchedulerJob instances based on score. + * @fixme This is a qSort predicate, deprecated in QT5. * @arg a, b are ::SchedulerJob instances to compare. * @return true if the score of b is lower than the score of a. * @return false if the score of b is higher than or equal to the score of a. */ static bool decreasingScoreOrder(SchedulerJob const *a, SchedulerJob const *b); - /** @brief Compare ::SchedulerJob instances based on priority. This is a qSort predicate, deprecated in QT5. + /** @brief Compare ::SchedulerJob instances based on priority. + * @fixme This is a qSort predicate, deprecated in QT5. * @arg a, b are ::SchedulerJob instances to compare. * @return true if the priority of a is lower than the priority of b. * @return false if the priority of a is higher than or equal to the priority of b. */ static bool increasingPriorityOrder(SchedulerJob const *a, SchedulerJob const *b); - /** @brief Compare ::SchedulerJob instances based on altitude. This is a qSort predicate, deprecated in QT5. + /** @brief Compare ::SchedulerJob instances based on altitude and movement in sky at startup time. + * @fixme This is a qSort predicate, deprecated in QT5. * @arg a, b are ::SchedulerJob instances to compare. - * @return true if the altitude of b is lower than the altitude of a. - * @return false if the altitude of b is higher than or equal to the altitude of a. + * @arg when is the date/time to use to calculate the altitude to sort with, defaulting to a's startup time. + * @note To obtain proper sort between several SchedulerJobs, all should have the same startup time. + * @note Use std:bind to bind a specific date/time to this predicate for altitude calculation. + * @return true is a is setting but not b. + * @return false if b is setting but not a. + * @return true otherwise, if the altitude of b is lower than the altitude of a. + * @return false otherwise, if the altitude of b is higher than or equal to the altitude of a. */ - static bool decreasingAltitudeOrder(SchedulerJob const *a, SchedulerJob const *b); + static bool decreasingAltitudeOrder(SchedulerJob const *a, SchedulerJob const *b, QDateTime const &when = QDateTime()); - /** @brief Compare ::SchedulerJob instances based on startup time. This is a qSort predicate, deprecated in QT5. + /** @brief Compare ::SchedulerJob instances based on startup time. + * @fixme This is a qSort predicate, deprecated in QT5. * @arg a, b are ::SchedulerJob instances to compare. * @return true if the startup time of a is sooner than the priority of b. * @return false if the startup time of a is later than or equal to the priority of b. @@ -374,10 +401,18 @@ QDateTime startupTime; QDateTime completionTime; + /* @internal Caches to optimize cell rendering. */ + /* @{ */ + double altitudeAtStartup; + double altitudeAtCompletion; + bool isSettingAtStartup; + bool isSettingAtCompletion; + /* @} */ + QUrl sequenceFile; QUrl fitsFile; - double minAltitude { -1 }; + double minAltitude { -90 }; double minMoonSeparation { -1 }; bool enforceWeather { false }; @@ -392,17 +427,20 @@ QTableWidgetItem *statusCell { nullptr }; QTableWidgetItem *stageCell { nullptr }; QLabel *stageLabel { nullptr }; + QTableWidgetItem *altitudeCell { nullptr }; QTableWidgetItem *startupCell { nullptr }; QTableWidgetItem *completionCell { nullptr }; QTableWidgetItem *estimatedTimeCell { nullptr }; QTableWidgetItem *captureCountCell { nullptr }; QTableWidgetItem *scoreCell { nullptr }; + QTableWidgetItem *leadTimeCell { nullptr }; /** @} */ int score { 0 }; int16_t culminationOffset { 0 }; uint8_t priority { 10 }; int64_t estimatedTime { -1 }; + int64_t leadTime { 0 }; uint16_t repeatsRequired { 1 }; uint16_t repeatsRemaining { 1 }; bool inSequenceFocus { false }; diff --git a/kstars/ekos/scheduler/schedulerjob.cpp b/kstars/ekos/scheduler/schedulerjob.cpp --- a/kstars/ekos/scheduler/schedulerjob.cpp +++ b/kstars/ekos/scheduler/schedulerjob.cpp @@ -11,6 +11,7 @@ #include "dms.h" #include "kstarsdata.h" +#include "Options.h" #include "scheduler.h" #include @@ -20,23 +21,33 @@ void SchedulerJob::setName(const QString &value) { name = value; - updateJobCell(); + updateJobCells(); } void SchedulerJob::setStartupCondition(const StartupCondition &value) { startupCondition = value; + + /* Keep startup time and condition valid */ if (value == START_ASAP) startupTime = QDateTime(); - updateJobCell(); + + /* Refresh estimated time - which update job cells */ + setEstimatedTime(estimatedTime); } void SchedulerJob::setStartupTime(const QDateTime &value) { startupTime = value; + /* Keep startup time and condition valid */ if (value.isValid()) startupCondition = START_AT; + else + startupCondition = fileStartupCondition; + + // Refresh altitude - invalid date/time is taken care of when rendering + altitudeAtStartup = Ekos::Scheduler::findAltitude(targetCoords, startupTime, &isSettingAtStartup); /* Refresh estimated time - which update job cells */ setEstimatedTime(estimatedTime); @@ -69,20 +80,37 @@ void SchedulerJob::setCompletionTime(const QDateTime &value) { - /* If argument completion time is valid, automatically switch condition to FINISH_AT */ + /* If completion time is valid, automatically switch condition to FINISH_AT */ if (value.isValid()) { setCompletionCondition(FINISH_AT); completionTime = value; + altitudeAtCompletion = Ekos::Scheduler::findAltitude(targetCoords, completionTime, &isSettingAtCompletion); + setEstimatedTime(-1); + } + /* If completion time is invalid, and job is looping, keep completion time undefined */ + else if (FINISH_LOOP == completionCondition) + { + completionTime = QDateTime(); + altitudeAtCompletion = Ekos::Scheduler::findAltitude(targetCoords, completionTime, &isSettingAtCompletion); + setEstimatedTime(-1); } - /* If completion time is not valid, but startup time is, deduce completion from startup and duration */ + /* If completion time is invalid, deduce completion from startup and duration */ else if (startupTime.isValid()) { completionTime = startupTime.addSecs(estimatedTime); + altitudeAtCompletion = Ekos::Scheduler::findAltitude(targetCoords, completionTime, &isSettingAtCompletion); + updateJobCells(); } + /* Else just refresh estimated time - which update job cells */ + else setEstimatedTime(estimatedTime); - /* Refresh estimated time - which update job cells */ - setEstimatedTime(estimatedTime); + + /* Invariants */ + Q_ASSERT_X(completionTime.isValid() ? + (FINISH_AT == completionCondition || FINISH_REPEAT == completionCondition || FINISH_SEQUENCE == completionCondition) : + FINISH_LOOP == completionCondition, + __FUNCTION__, "Valid completion time implies job is FINISH_AT/REPEAT/SEQUENCE, else job is FINISH_LOOP."); } void SchedulerJob::setCompletionCondition(const CompletionCondition &value) @@ -92,8 +120,10 @@ // Update repeats requirement, looping jobs have none switch (completionCondition) { - case FINISH_AT: case FINISH_LOOP: + setCompletionTime(QDateTime()); + /* Fall through */ + case FINISH_AT: if (0 < getRepeatsRequired()) setRepeatsRequired(0); break; @@ -111,7 +141,7 @@ default: break; } - updateJobCell(); + updateJobCells(); } void SchedulerJob::setStepPipeline(const StepPipeline &value) @@ -142,13 +172,19 @@ /* setStartupTime(fileStartupTime); */ } - updateJobCell(); + updateJobCells(); +} + +void SchedulerJob::setLeadTime(const int64_t &value) +{ + leadTime = value; + updateJobCells(); } void SchedulerJob::setScore(int value) { score = value; - updateJobCell(); + updateJobCells(); } void SchedulerJob::setCulminationOffset(const int16_t &value) @@ -159,97 +195,109 @@ void SchedulerJob::setSequenceCount(const int count) { sequenceCount = count; - updateJobCell(); + updateJobCells(); } void SchedulerJob::setNameCell(QTableWidgetItem *value) { nameCell = value; - updateJobCell(); } void SchedulerJob::setCompletedCount(const int count) { completedCount = count; - updateJobCell(); + updateJobCells(); } void SchedulerJob::setStatusCell(QTableWidgetItem *value) { statusCell = value; - updateJobCell(); - if (statusCell) + if (nullptr != statusCell) statusCell->setToolTip(i18n("Current status of job '%1', managed by the Scheduler.\n" "If invalid, the Scheduler was not able to find a proper observation time for the target.\n" "If aborted, the Scheduler missed the scheduled time or encountered transitory issues and will reschedule the job.\n" "If complete, the Scheduler verified that all sequence captures requested were stored, including repeats.", name)); } +void SchedulerJob::setAltitudeCell(QTableWidgetItem *value) +{ + altitudeCell = value; + if (nullptr != altitudeCell) + altitudeCell->setToolTip(i18n("Current altitude of the target of job '%1'.\n" + "The altitude at startup, if available, is displayed between parentheses.\n" + "A rising target is indicated with an arrow going up.\n" + "A setting target is indicated with an arrow going down.", + name)); +} + void SchedulerJob::setStartupCell(QTableWidgetItem *value) { startupCell = value; - updateJobCell(); - if (startupCell) + if (nullptr != startupCell) startupCell->setToolTip(i18n("Startup time for job '%1', as estimated by the Scheduler.\n" "Fixed time from user or culmination time is marked with a chronometer symbol. ", name)); } void SchedulerJob::setCompletionCell(QTableWidgetItem *value) { completionCell = value; - updateJobCell(); - if (completionCell) + if (nullptr != completionCell) completionCell->setToolTip(i18n("Completion time for job '%1', as estimated by the Scheduler.\n" - "Can be specified by the user to limit duration of looping jobs.\n" - "Fixed time from user is marked with a chronometer symbol. ", + "You may specify a fixed time to limit duration of looping jobs." + "A warning symbol indicates the altitude at completion may cause the job to abort before completion.\n", name)); } void SchedulerJob::setCaptureCountCell(QTableWidgetItem *value) { captureCountCell = value; - updateJobCell(); - if (captureCountCell) + if (nullptr != captureCountCell) captureCountCell->setToolTip(i18n("Count of captures stored for job '%1', based on its sequence job.\n" "This is a summary, additional specific frame types may be required to complete the job.", name)); } void SchedulerJob::setScoreCell(QTableWidgetItem *value) { scoreCell = value; - updateJobCell(); - if (scoreCell) + if (nullptr != scoreCell) scoreCell->setToolTip(i18n("Current score for job '%1', from its altitude, moon separation and sky darkness.\n" "Negative if adequate altitude is not achieved yet or if there is no proper observation time today.\n" "The Scheduler will refresh scores when picking a new candidate job.", name)); } +void SchedulerJob::setLeadTimeCell(QTableWidgetItem *value) +{ + leadTimeCell = value; + if (nullptr != leadTimeCell) + scoreCell->setToolTip(i18n("Time interval from the job which precedes job '%1'.\n" + "Adjust the Lead Time in Ekos options to increase that duration and leave time for jobs to complete.\n" + "Rearrange jobs to minimize that duration and optimize your imaging time.", + name)); +} + void SchedulerJob::setDateTimeDisplayFormat(const QString &value) { dateTimeDisplayFormat = value; - updateJobCell(); + updateJobCells(); } void SchedulerJob::setStage(const JOBStage &value) { stage = value; - updateJobCell(); } void SchedulerJob::setStageCell(QTableWidgetItem *cell) { stageCell = cell; - updateJobCell(); } void SchedulerJob::setStageLabel(QLabel *label) { stageLabel = label; - updateJobCell(); } void SchedulerJob::setFileStartupCondition(const StartupCondition &value) @@ -264,28 +312,30 @@ void SchedulerJob::setEstimatedTime(const int64_t &value) { - /* If startup and completion times are fixed, estimated time cannot change */ - if (START_AT == startupCondition && FINISH_AT == completionCondition) + /* Estimated time is generally the difference between startup and completion times: + * - It is fixed when startup and completion times are fixed, that is, we disregard the argument + * - Else mostly it pushes completion time from startup time + * + * However it cannot advance startup time when completion time is fixed because of the way jobs are scheduled. + * This situation requires a warning in the user interface when there is not enough time for the job to process. + */ + + /* If startup and completion times are fixed, estimated time cannot change - disregard the argument */ + if (START_ASAP != fileStartupCondition && FINISH_AT == completionCondition) { estimatedTime = startupTime.secsTo(completionTime); } - /* If startup time is fixed but not completion time, estimated time adjusts completion time */ - else if (START_AT == startupCondition) + /* If completion time isn't fixed, estimated time adjusts completion time */ + else if (FINISH_AT != completionCondition && FINISH_LOOP != completionCondition) { estimatedTime = value; completionTime = startupTime.addSecs(value); + altitudeAtCompletion = Ekos::Scheduler::findAltitude(targetCoords, completionTime, &isSettingAtCompletion); } - /* If completion time is fixed but not startup time, estimated time adjusts startup time */ - /* FIXME: adjusting startup time will probably not work, because jobs are scheduled from first available altitude */ - else if (FINISH_AT == completionCondition) - { - estimatedTime = value; - startupTime = completionTime.addSecs(-value); - } - /* Else estimated time is simply stored as is */ + /* Else estimated time is simply stored as is - covers FINISH_LOOP from setCompletionTime */ else estimatedTime = value; - updateJobCell(); + updateJobCells(); } void SchedulerJob::setInSequenceFocus(bool value) @@ -306,7 +356,6 @@ void SchedulerJob::setEstimatedTimeCell(QTableWidgetItem *value) { estimatedTimeCell = value; - updateJobCell(); if (estimatedTimeCell) estimatedTimeCell->setToolTip(i18n("Duration job '%1' will take to complete when started, as estimated by the Scheduler.\n" "Depends on the actions to be run, and the sequence job to be processed.", @@ -339,13 +388,13 @@ setCompletionCondition(FINISH_LOOP); } - updateJobCell(); + updateJobCells(); } void SchedulerJob::setRepeatsRemaining(const uint16_t &value) { repeatsRemaining = value; - updateJobCell(); + updateJobCells(); } void SchedulerJob::setCapturedFramesMap(const CapturedFramesMap &value) @@ -361,20 +410,21 @@ targetCoords.apparentCoord(static_cast(J2000), KStarsData::Instance()->ut().djd()); } -void SchedulerJob::updateJobCell() +void SchedulerJob::updateJobCells() { - if (nameCell) + if (nullptr != nameCell) { nameCell->setText(name); - nameCell->tableWidget()->resizeColumnToContents(nameCell->column()); + if (nullptr != nameCell) + nameCell->tableWidget()->resizeColumnToContents(nameCell->column()); } - if (nameLabel) + if (nullptr != nameLabel) { nameLabel->setText(name + QString(":")); } - if (statusCell) + if (nullptr != statusCell) { static QMap stateStrings; static QString stateStringUnknown; @@ -391,10 +441,12 @@ stateStringUnknown = i18n("Unknown"); } statusCell->setText(stateStrings.value(state, stateStringUnknown)); - statusCell->tableWidget()->resizeColumnToContents(statusCell->column()); + + if (nullptr != statusCell->tableWidget()) + statusCell->tableWidget()->resizeColumnToContents(statusCell->column()); } - if (stageCell || stageLabel) + if (nullptr != stageCell || nullptr != stageLabel) { /* Translated string cache - overkill, probably, and doesn't warn about missing enums like switch/case should ; also, not thread-safe */ /* 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? */ @@ -419,112 +471,139 @@ stageStrings[STAGE_CAPTURING] = i18n("Capturing"); stageStringUnknown = i18n("Unknown"); } - if (stageCell) + if (nullptr != stageCell) { stageCell->setText(stageStrings.value(stage, stageStringUnknown)); - stageCell->tableWidget()->resizeColumnToContents(stageCell->column()); + if (nullptr != stageCell->tableWidget()) + stageCell->tableWidget()->resizeColumnToContents(stageCell->column()); } - if (stageLabel) + if (nullptr != stageLabel) { stageLabel->setText(QString("%1: %2").arg(name, stageStrings.value(stage, stageStringUnknown))); } } - if (startupCell && startupCell->tableWidget()) + if (nullptr != startupCell) { - /* Display a startup time if job is running, scheduled to run or about to be re-scheduled */ - if (JOB_SCHEDULED == state || JOB_BUSY == state || JOB_ABORTED == state) switch (fileStartupCondition) + /* Display startup time if it is valid */ + if (startupTime.isValid()) { - /* If the original condition is START_AT/START_CULMINATION, startup time is fixed */ - case START_AT: - case START_CULMINATION: - startupCell->setText(startupTime.toString(dateTimeDisplayFormat)); - startupCell->setIcon(QIcon::fromTheme("chronometer")); - break; - - /* If the original condition is START_ASAP, startup time is informational */ - case START_ASAP: - startupCell->setText(startupTime.toString(dateTimeDisplayFormat)); - startupCell->setIcon(QIcon()); - break; - - /* Else do not display any startup time */ - default: - startupCell->setText(QString()); - startupCell->setIcon(QIcon()); - break; - } - /* Display a missed startup time if job is invalid */ - else if (JOB_INVALID == state && START_AT == fileStartupCondition) - { - startupCell->setText(startupTime.toString(dateTimeDisplayFormat)); - startupCell->setIcon(QIcon::fromTheme("chronometer")); + startupCell->setText(QString("%1%2%L3° %4") + .arg(altitudeAtStartup < minAltitude ? QString(QChar(0x26A0)) : "") + .arg(QChar(isSettingAtStartup ? 0x2193 : 0x2191)) + .arg(altitudeAtStartup, 0, 'f', 1) + .arg(startupTime.toString(dateTimeDisplayFormat))); + + switch (fileStartupCondition) + { + /* If the original condition is START_AT/START_CULMINATION, startup time is fixed */ + case START_AT: + case START_CULMINATION: + startupCell->setIcon(QIcon::fromTheme("chronometer")); + break; + + /* If the original condition is START_ASAP, startup time is informational */ + case START_ASAP: + startupCell->setIcon(QIcon()); + break; + + default: break; + } } /* Else do not display any startup time */ else { - startupCell->setText(QString()); + startupCell->setText("-"); startupCell->setIcon(QIcon()); } - startupCell->tableWidget()->resizeColumnToContents(startupCell->column()); + if (nullptr != startupCell->tableWidget()) + startupCell->tableWidget()->resizeColumnToContents(startupCell->column()); } - if (completionCell && completionCell->tableWidget()) + if (nullptr != altitudeCell) { - /* Display a completion time if job is running, scheduled to run or about to be re-scheduled */ - if (JOB_SCHEDULED == state || JOB_BUSY == state || JOB_ABORTED == state) switch (completionCondition) - { - case FINISH_LOOP: - completionCell->setText(QString("-")); - completionCell->setIcon(QIcon()); - break; + // FIXME: Cache altitude calculations + bool is_setting = false; + double const alt = Ekos::Scheduler::findAltitude(targetCoords, QDateTime(), &is_setting); - case FINISH_AT: - completionCell->setText(completionTime.toString(dateTimeDisplayFormat)); - completionCell->setIcon(QIcon::fromTheme("chronometer")); - break; + altitudeCell->setText(QString("%1%L2°") + .arg(QChar(is_setting ? 0x2193 : 0x2191)) + .arg(alt, 0, 'f', 1)); - case FINISH_SEQUENCE: - case FINISH_REPEAT: - default: - completionCell->setText(completionTime.toString(dateTimeDisplayFormat)); - completionCell->setIcon(QIcon()); - break; + if (nullptr != altitudeCell->tableWidget()) + altitudeCell->tableWidget()->resizeColumnToContents(altitudeCell->column()); + } + + if (nullptr != completionCell) + { + /* Display completion time if it is valid and job is not looping */ + if (FINISH_LOOP != completionCondition && completionTime.isValid()) + { + completionCell->setText(QString("%1%2%L3° %4") + .arg(altitudeAtCompletion < minAltitude ? QString(QChar(0x26A0)) : "") + .arg(QChar(isSettingAtCompletion ? 0x2193 : 0x2191)) + .arg(altitudeAtCompletion, 0, 'f', 1) + .arg(completionTime.toString(dateTimeDisplayFormat))); + + switch (completionCondition) + { + case FINISH_AT: + completionCell->setIcon(QIcon::fromTheme("chronometer")); + break; + + case FINISH_SEQUENCE: + case FINISH_REPEAT: + default: + completionCell->setIcon(QIcon()); + break; + } } /* Else do not display any completion time */ else { - completionCell->setText(QString()); + completionCell->setText("-"); completionCell->setIcon(QIcon()); } - completionCell->tableWidget()->resizeColumnToContents(completionCell->column()); + if (nullptr != completionCell->tableWidget()) + completionCell->tableWidget()->resizeColumnToContents(completionCell->column()); } - if (estimatedTimeCell && estimatedTimeCell->tableWidget()) + if (nullptr != estimatedTimeCell) { if (0 < estimatedTime) /* Seconds to ms - this doesn't follow dateTimeDisplayFormat, which renders YMD too */ estimatedTimeCell->setText(QTime::fromMSecsSinceStartOfDay(estimatedTime*1000).toString("HH:mm:ss")); +#if 0 else if(0 == estimatedTime) /* FIXME: this special case could be merged with the previous, kept for future to indicate actual duration */ estimatedTimeCell->setText("00:00:00"); +#endif else /* Invalid marker */ estimatedTimeCell->setText("-"); - estimatedTimeCell->tableWidget()->resizeColumnToContents(estimatedTimeCell->column()); + /* Warn the end-user if estimated time doesn't fit in the startup/completion interval */ + if (estimatedTime < startupTime.secsTo(completionTime)) + estimatedTimeCell->setIcon(QIcon::fromTheme("document-find")); + else + estimatedTimeCell->setIcon(QIcon()); + + if (nullptr != estimatedTimeCell->tableWidget()) + estimatedTimeCell->tableWidget()->resizeColumnToContents(estimatedTimeCell->column()); } - if (captureCountCell && captureCountCell->tableWidget()) + if (nullptr != captureCountCell) { switch (completionCondition) { - case FINISH_LOOP: case FINISH_AT: + // FIXME: Attempt to calculate the number of frames until end - requires detailed imaging time + + case FINISH_LOOP: // If looping, display the count of completed frames - captureCountCell->setText(QString("%L1").arg(completedCount)); + captureCountCell->setText(QString("%L1/-").arg(completedCount)); break; case FINISH_SEQUENCE: @@ -534,30 +613,57 @@ captureCountCell->setText(QString("%L1/%L2").arg(completedCount).arg(sequenceCount)); break; } - captureCountCell->tableWidget()->resizeColumnToContents(captureCountCell->column()); + + if (nullptr != captureCountCell->tableWidget()) + captureCountCell->tableWidget()->resizeColumnToContents(captureCountCell->column()); } - if (scoreCell && scoreCell->tableWidget()) + if (nullptr != scoreCell) { if (0 <= score) scoreCell->setText(QString("%L1").arg(score)); else /* FIXME: negative scores are just weird for the end-user */ - scoreCell->setText(QString("<0")); + scoreCell->setText("<0"); - scoreCell->tableWidget()->resizeColumnToContents(scoreCell->column()); + if (nullptr != scoreCell->tableWidget()) + scoreCell->tableWidget()->resizeColumnToContents(scoreCell->column()); + } + + if (nullptr != leadTimeCell) + { + // Display lead time, plus a warning if lead time is more than twice the lead time of the Ekos options + switch (state) + { + case JOB_INVALID: + case JOB_ERROR: + case JOB_COMPLETE: + leadTimeCell->setText("-"); + break; + + default: + leadTimeCell->setText(QString("%1%2") + .arg(Options::leadTime() * 60 * 2 < leadTime ? QString(QChar(0x26A0)) : "") + .arg(QTime::fromMSecsSinceStartOfDay(leadTime*1000).toString("HH:mm:ss"))); + break; + } + + if (nullptr != leadTimeCell->tableWidget()) + leadTimeCell->tableWidget()->resizeColumnToContents(leadTimeCell->column()); } } void SchedulerJob::reset() { state = JOB_IDLE; stage = STAGE_IDLE; estimatedTime = -1; + leadTime = 0; startupCondition = fileStartupCondition; startupTime = fileStartupCondition == START_AT ? fileStartupTime : QDateTime(); /* No change to culmination offset */ repeatsRemaining = repeatsRequired; + updateJobCells(); } bool SchedulerJob::decreasingScoreOrder(SchedulerJob const *job1, SchedulerJob const *job2) @@ -570,10 +676,26 @@ return job1->getPriority() < job2->getPriority(); } -bool SchedulerJob::decreasingAltitudeOrder(SchedulerJob const *job1, SchedulerJob const *job2) +bool SchedulerJob::decreasingAltitudeOrder(SchedulerJob const *job1, SchedulerJob const *job2, QDateTime const &when) { - return Ekos::Scheduler::findAltitude(job1->getTargetCoords(), job1->getStartupTime()) > - Ekos::Scheduler::findAltitude(job2->getTargetCoords(), job2->getStartupTime()); + bool A_is_setting = job1->isSettingAtStartup; + double const altA = when.isValid() ? + Ekos::Scheduler::findAltitude(job1->getTargetCoords(), when, &A_is_setting) : + job1->altitudeAtStartup; + + bool B_is_setting = job2->isSettingAtStartup; + double const altB = when.isValid() ? + Ekos::Scheduler::findAltitude(job2->getTargetCoords(), when, &B_is_setting) : + job2->altitudeAtStartup; + + // Sort with the setting target first + if (A_is_setting && !B_is_setting) + return true; + else if (!A_is_setting && B_is_setting) + return false; + + // If both targets rise or set, sort by decreasing altitude, considering a setting target is prioritary + return (A_is_setting && B_is_setting) ? altA < altB : altB < altA; } bool SchedulerJob::increasingStartupTimeOrder(SchedulerJob const *job1, SchedulerJob const *job2)