File indexing completed on 2024-04-21 14:44:44

0001 /*
0002     SPDX-FileCopyrightText: 2020 Hy Murveit <hy@murveit.com>
0003 
0004     SPDX-License-Identifier: GPL-2.0-or-later
0005 */
0006 
0007 #include "analyze.h"
0008 
0009 #include <KNotifications/KNotification>
0010 #include <QDateTime>
0011 #include <QShortcut>
0012 #include <QtGlobal>
0013 #include <QColor>
0014 
0015 #include "auxiliary/kspaths.h"
0016 #include "dms.h"
0017 #include "ekos/manager.h"
0018 #include "ekos/focus/curvefit.h"
0019 #include "fitsviewer/fitsdata.h"
0020 #include "fitsviewer/fitsviewer.h"
0021 #include "ksmessagebox.h"
0022 #include "kstars.h"
0023 #include "kstarsdata.h"
0024 #include "Options.h"
0025 #include "qcustomplot.h"
0026 
0027 #include <ekos_analyze_debug.h>
0028 #include <KHelpClient>
0029 #include <version.h>
0030 
0031 // Subclass QCPAxisTickerDateTime, so that times are offset from the start
0032 // of the log, instead of being offset from the UNIX 0-seconds time.
0033 class OffsetDateTimeTicker : public QCPAxisTickerDateTime
0034 {
0035     public:
0036         void setOffset(double offset)
0037         {
0038             timeOffset = offset;
0039         }
0040         QString getTickLabel(double tick, const QLocale &locale, QChar formatChar, int precision) override
0041         {
0042             Q_UNUSED(precision);
0043             Q_UNUSED(formatChar);
0044             // Seconds are offset from the unix origin by
0045             return locale.toString(keyToDateTime(tick + timeOffset).toTimeSpec(mDateTimeSpec), mDateTimeFormat);
0046         }
0047     private:
0048         double timeOffset = 0;
0049 };
0050 
0051 namespace
0052 {
0053 
0054 // QDateTime is written to file with this format.
0055 QString timeFormat = "yyyy-MM-dd hh:mm:ss.zzz";
0056 
0057 // The resolution of the scroll bar.
0058 constexpr int MAX_SCROLL_VALUE = 10000;
0059 
0060 // Half the height of a timeline line.
0061 // That is timeline lines are horizontal bars along y=1 or y=2 ... and their
0062 // vertical widths are from y-halfTimelineHeight to y+halfTimelineHeight.
0063 constexpr double halfTimelineHeight = 0.35;
0064 
0065 // These are initialized in initStatsPlot when the graphs are added.
0066 // They index the graphs in statsPlot, e.g. statsPlot->graph(HFR_GRAPH)->addData(...)
0067 int HFR_GRAPH = -1;
0068 int TEMPERATURE_GRAPH = -1;
0069 int FOCUS_POSITION_GRAPH = -1;
0070 int NUM_CAPTURE_STARS_GRAPH = -1;
0071 int MEDIAN_GRAPH = -1;
0072 int ECCENTRICITY_GRAPH = -1;
0073 int NUMSTARS_GRAPH = -1;
0074 int SKYBG_GRAPH = -1;
0075 int SNR_GRAPH = -1;
0076 int RA_GRAPH = -1;
0077 int DEC_GRAPH = -1;
0078 int RA_PULSE_GRAPH = -1;
0079 int DEC_PULSE_GRAPH = -1;
0080 int DRIFT_GRAPH = -1;
0081 int RMS_GRAPH = -1;
0082 int CAPTURE_RMS_GRAPH = -1;
0083 int MOUNT_RA_GRAPH = -1;
0084 int MOUNT_DEC_GRAPH = -1;
0085 int MOUNT_HA_GRAPH = -1;
0086 int AZ_GRAPH = -1;
0087 int ALT_GRAPH = -1;
0088 int PIER_SIDE_GRAPH = -1;
0089 int TARGET_DISTANCE_GRAPH = -1;
0090 
0091 // This one is in timelinePlot.
0092 int ADAPTIVE_FOCUS_GRAPH = -1;
0093 
0094 // Initialized in initGraphicsPlot().
0095 int FOCUS_GRAPHICS = -1;
0096 int FOCUS_GRAPHICS_FINAL = -1;
0097 int FOCUS_GRAPHICS_CURVE = -1;
0098 int GUIDER_GRAPHICS = -1;
0099 
0100 // Brushes used in the timeline plot.
0101 const QBrush temporaryBrush(Qt::green, Qt::DiagCrossPattern);
0102 const QBrush timelineSelectionBrush(QColor(255, 100, 100, 150), Qt::SolidPattern);
0103 const QBrush successBrush(Qt::green, Qt::SolidPattern);
0104 const QBrush failureBrush(Qt::red, Qt::SolidPattern);
0105 const QBrush offBrush(Qt::gray, Qt::SolidPattern);
0106 const QBrush progressBrush(Qt::blue, Qt::SolidPattern);
0107 const QBrush progress2Brush(QColor(0, 165, 255), Qt::SolidPattern);
0108 const QBrush progress3Brush(Qt::cyan, Qt::SolidPattern);
0109 const QBrush progress4Brush(Qt::darkGreen, Qt::SolidPattern);
0110 const QBrush stoppedBrush(Qt::yellow, Qt::SolidPattern);
0111 const QBrush stopped2Brush(Qt::darkYellow, Qt::SolidPattern);
0112 
0113 // Utility to checks if a file exists and is not a directory.
0114 bool fileExists(const QString &path)
0115 {
0116     QFileInfo info(path);
0117     return info.exists() && info.isFile();
0118 }
0119 
0120 // Utilities to go between a mount status and a string.
0121 // Move to inditelescope.h/cpp?
0122 const QString mountStatusString(ISD::Mount::Status status)
0123 {
0124     switch (status)
0125     {
0126         case ISD::Mount::MOUNT_IDLE:
0127             return i18n("Idle");
0128         case ISD::Mount::MOUNT_PARKED:
0129             return i18n("Parked");
0130         case ISD::Mount::MOUNT_PARKING:
0131             return i18n("Parking");
0132         case ISD::Mount::MOUNT_SLEWING:
0133             return i18n("Slewing");
0134         case ISD::Mount::MOUNT_MOVING:
0135             return i18n("Moving");
0136         case ISD::Mount::MOUNT_TRACKING:
0137             return i18n("Tracking");
0138         case ISD::Mount::MOUNT_ERROR:
0139             return i18n("Error");
0140     }
0141     return i18n("Error");
0142 }
0143 
0144 ISD::Mount::Status toMountStatus(const QString &str)
0145 {
0146     if (str == i18n("Idle"))
0147         return ISD::Mount::MOUNT_IDLE;
0148     else if (str == i18n("Parked"))
0149         return ISD::Mount::MOUNT_PARKED;
0150     else if (str == i18n("Parking"))
0151         return ISD::Mount::MOUNT_PARKING;
0152     else if (str == i18n("Slewing"))
0153         return ISD::Mount::MOUNT_SLEWING;
0154     else if (str == i18n("Moving"))
0155         return ISD::Mount::MOUNT_MOVING;
0156     else if (str == i18n("Tracking"))
0157         return ISD::Mount::MOUNT_TRACKING;
0158     else
0159         return ISD::Mount::MOUNT_ERROR;
0160 }
0161 
0162 // Returns the stripe color used when drawing the capture timeline for various filters.
0163 // TODO: Not sure how to internationalize this.
0164 bool filterStripeBrush(const QString &filter, QBrush *brush)
0165 {
0166     const QRegularExpression::PatternOption c = QRegularExpression::CaseInsensitiveOption;
0167 
0168     const QString rPattern("^(red|r)$");
0169     if (QRegularExpression(rPattern, c).match(filter).hasMatch())
0170     {
0171         *brush = QBrush(Qt::red, Qt::SolidPattern);
0172         return true;
0173     }
0174     const QString gPattern("^(green|g)$");
0175     if (QRegularExpression(gPattern, c).match(filter).hasMatch())
0176     {
0177         *brush = QBrush(Qt::green, Qt::SolidPattern);
0178         return true;
0179     }
0180     const QString bPattern("^(blue|b)$");
0181     if (QRegularExpression(bPattern, c).match(filter).hasMatch())
0182     {
0183         *brush = QBrush(Qt::blue, Qt::SolidPattern);
0184         return true;
0185     }
0186     const QString hPattern("^(ha|h|h-a|h_a|h-alpha|hydrogen|hydrogen_alpha|hydrogen-alpha|h_alpha|halpha)$");
0187     if (QRegularExpression(hPattern, c).match(filter).hasMatch())
0188     {
0189         *brush = QBrush(Qt::darkRed, Qt::SolidPattern);
0190         return true;
0191     }
0192     const QString oPattern("^(oiii|oxygen|oxygen_3|oxygen-3|oxygen_iii|oxygen-iii|o_iii|o-iii|o_3|o-3|o3)$");
0193     if (QRegularExpression(oPattern, c).match(filter).hasMatch())
0194     {
0195         *brush = QBrush(Qt::cyan, Qt::SolidPattern);
0196         return true;
0197     }
0198     const QString
0199     sPattern("^(sii|sulphur|sulphur_2|sulphur-2|sulphur_ii|sulphur-ii|sulfur|sulfur_2|sulfur-2|sulfur_ii|sulfur-ii|s_ii|s-ii|s_2|s-2|s2)$");
0200     if (QRegularExpression(sPattern, c).match(filter).hasMatch())
0201     {
0202         // Pink.
0203         *brush = QBrush(QColor(255, 182, 193), Qt::SolidPattern);
0204         return true;
0205     }
0206     const QString lPattern("^(lpr|L|UV-IR cut|UV-IR|white|monochrome|broadband|clear|focus|luminance|lum|lps|cls)$");
0207     if (QRegularExpression(lPattern, c).match(filter).hasMatch())
0208     {
0209         *brush = QBrush(Qt::white, Qt::SolidPattern);
0210         return true;
0211     }
0212     return false;
0213 }
0214 
0215 // Used when searching for FITS files to display.
0216 // If filename isn't found as is, it tries alterateDirectory in several ways
0217 // e.g. if filename = /1/2/3/4/name is not found, then try alternateDirectory/name,
0218 // then alternateDirectory/4/name, then alternateDirectory/3/4/name,
0219 // then alternateDirectory/2/3/4/name, and so on.
0220 // If it cannot find the FITS file, it returns an empty string, otherwise it returns
0221 // the full path where the file was found.
0222 QString findFilename(const QString &filename, const QString &alternateDirectory)
0223 {
0224     // Try the origial full path.
0225     QFileInfo info(filename);
0226     if (info.exists() && info.isFile())
0227         return filename;
0228 
0229     // Try putting the filename at the end of the full path onto alternateDirectory.
0230     QString name = info.fileName();
0231     QString temp = QString("%1/%2").arg(alternateDirectory, name);
0232     if (fileExists(temp))
0233         return temp;
0234 
0235     // Try appending the filename plus the ending directories onto alternateDirectory.
0236     int size = filename.size();
0237     int searchBackFrom = size - name.size();
0238     int num = 0;
0239     while (searchBackFrom >= 0)
0240     {
0241         int index = filename.lastIndexOf('/', searchBackFrom);
0242         if (index < 0)
0243             break;
0244 
0245         QString temp2 = QString("%1%2").arg(alternateDirectory, filename.right(size - index));
0246         if (fileExists(temp2))
0247             return temp2;
0248 
0249         searchBackFrom = index - 1;
0250 
0251         // Paranoia
0252         if (++num > 20)
0253             break;
0254     }
0255     return "";
0256 }
0257 
0258 // This is an exhaustive search for now.
0259 // This is reasonable as the number of sessions should be limited.
0260 template <class T>
0261 class IntervalFinder
0262 {
0263     public:
0264         IntervalFinder() {}
0265         ~IntervalFinder() {}
0266         void add(T value)
0267         {
0268             intervals.append(value);
0269         }
0270         void clear()
0271         {
0272             intervals.clear();
0273         }
0274         QList<T> find(double t)
0275         {
0276             QList<T> result;
0277             for (const auto &interval : intervals)
0278             {
0279                 if (t >= interval.start && t <= interval.end)
0280                     result.push_back(interval);
0281             }
0282             return result;
0283         }
0284         // Finds the interval AFTER t, not including t
0285         T *findNext(double t)
0286         {
0287             double bestStart = 1e7;
0288             T *result = nullptr;
0289             for (auto &interval : intervals)
0290             {
0291                 if (interval.start > t && interval.start < bestStart)
0292                 {
0293                     bestStart = interval.start;
0294                     result = &interval;
0295                 }
0296             }
0297             return result;
0298         }
0299         // Finds the interval BEFORE t, not including t
0300         T *findPrevious(double t)
0301         {
0302             double bestStart = -1e7;
0303             T *result = nullptr;
0304             for (auto &interval : intervals)
0305             {
0306                 if (interval.start < t && interval.start > bestStart)
0307                 {
0308                     bestStart = interval.start;
0309                     result = &interval;
0310                 }
0311             }
0312             return result;
0313         }
0314     private:
0315         QList<T> intervals;
0316 };
0317 
0318 IntervalFinder<Ekos::Analyze::CaptureSession> captureSessions;
0319 IntervalFinder<Ekos::Analyze::FocusSession> focusSessions;
0320 IntervalFinder<Ekos::Analyze::GuideSession> guideSessions;
0321 IntervalFinder<Ekos::Analyze::MountSession> mountSessions;
0322 IntervalFinder<Ekos::Analyze::AlignSession> alignSessions;
0323 IntervalFinder<Ekos::Analyze::MountFlipSession> mountFlipSessions;
0324 IntervalFinder<Ekos::Analyze::SchedulerJobSession> schedulerJobSessions;
0325 
0326 }  // namespace
0327 
0328 namespace Ekos
0329 {
0330 
0331 // RmsFilter computes the RMS error of a 2-D sequence. Input the x error and y error
0332 // into newSample(). It returns the sqrt of an approximate moving average of the squared
0333 // errors roughly averaged over 40 samples--implemented by a simple digital low-pass filter.
0334 // It's used to compute RMS guider errors, where x and y would be RA and DEC errors.
0335 class RmsFilter
0336 {
0337     public:
0338         RmsFilter()
0339         {
0340             constexpr double timeConstant = 40.0;
0341             alpha = 1.0 / pow(timeConstant, 0.865);
0342         }
0343         void resetFilter()
0344         {
0345             filteredRMS = 0;
0346         }
0347         double newSample(double x, double y)
0348         {
0349             const double valueSquared = x * x + y * y;
0350             filteredRMS = alpha * valueSquared + (1.0 - alpha) * filteredRMS;
0351             return sqrt(filteredRMS);
0352         }
0353     private:
0354         double alpha { 0 };
0355         double filteredRMS { 0 };
0356 };
0357 
0358 bool Analyze::eventFilter(QObject *obj, QEvent *ev)
0359 {
0360     // Quit if click wasn't on a QLineEdit.
0361     if (qobject_cast<QLineEdit*>(obj) == nullptr)
0362         return false;
0363 
0364     // This filter only applies to single or double clicks.
0365     if (ev->type() != QEvent::MouseButtonDblClick && ev->type() != QEvent::MouseButtonPress)
0366         return false;
0367 
0368     auto axisEntry = yAxisMap.find(obj);
0369     if (axisEntry == yAxisMap.end())
0370         return false;
0371 
0372     const bool isRightClick = (ev->type() == QEvent::MouseButtonPress) &&
0373                               (static_cast<QMouseEvent*>(ev)->button() == Qt::RightButton);
0374     const bool isControlClick = (ev->type() == QEvent::MouseButtonPress) &&
0375                                 (static_cast<QMouseEvent*>(ev)->modifiers() &
0376                                  Qt::KeyboardModifier::ControlModifier);
0377     const bool isShiftClick = (ev->type() == QEvent::MouseButtonPress) &&
0378                               (static_cast<QMouseEvent*>(ev)->modifiers() &
0379                                Qt::KeyboardModifier::ShiftModifier);
0380 
0381     if (ev->type() == QEvent::MouseButtonDblClick || isRightClick || isControlClick || isShiftClick)
0382     {
0383         startYAxisTool(axisEntry->first, axisEntry->second);
0384         clickTimer.stop();
0385         return true;
0386     }
0387     else if (ev->type() == QEvent::MouseButtonPress)
0388     {
0389         clickTimer.setSingleShot(true);
0390         clickTimer.setInterval(250);
0391         clickTimer.start();
0392         m_ClickTimerInfo = axisEntry->second;
0393         // Wait 0.25 seconds to see if this is a double click or just a single click.
0394         connect(&clickTimer, &QTimer::timeout, this, [&]()
0395         {
0396             m_YAxisTool.reject();
0397             if (m_ClickTimerInfo.checkBox && !m_ClickTimerInfo.checkBox->isChecked())
0398             {
0399                 // Enable the graph.
0400                 m_ClickTimerInfo.checkBox->setChecked(true);
0401                 statsPlot->graph(m_ClickTimerInfo.graphIndex)->setVisible(true);
0402                 statsPlot->graph(m_ClickTimerInfo.graphIndex)->addToLegend();
0403             }
0404             userSetLeftAxis(m_ClickTimerInfo.axis);
0405         });
0406         return true;
0407     }
0408     return false;
0409 }
0410 
0411 Analyze::Analyze() : m_YAxisTool(this)
0412 {
0413     setupUi(this);
0414 
0415     captureRms.reset(new RmsFilter);
0416     guiderRms.reset(new RmsFilter);
0417 
0418     alternateFolder = QDir::homePath();
0419 
0420     initInputSelection();
0421     initTimelinePlot();
0422 
0423     initStatsPlot();
0424     connect(&m_YAxisTool, &YAxisTool::axisChanged, this, &Analyze::userChangedYAxis);
0425     connect(&m_YAxisTool, &YAxisTool::leftAxisChanged, this, &Analyze::userSetLeftAxis);
0426     connect(&m_YAxisTool, &YAxisTool::axisColorChanged, this, &Analyze::userSetAxisColor);
0427     qApp->installEventFilter(this);
0428 
0429     initGraphicsPlot();
0430     fullWidthCB->setChecked(true);
0431     keepCurrentCB->setChecked(true);
0432     runtimeDisplay = true;
0433     fullWidthCB->setVisible(true);
0434     fullWidthCB->setDisabled(false);
0435 
0436     // Initialize the checkboxes that allow the user to make (in)visible
0437     // each of the 4 main displays in Analyze.
0438     detailsCB->setChecked(true);
0439     statsCB->setChecked(true);
0440     graphsCB->setChecked(true);
0441     timelineCB->setChecked(true);
0442     setVisibility();
0443     connect(timelineCB, &QCheckBox::stateChanged, this, &Analyze::setVisibility);
0444     connect(graphsCB, &QCheckBox::stateChanged, this, &Analyze::setVisibility);
0445     connect(statsCB, &QCheckBox::stateChanged, this, &Analyze::setVisibility);
0446     connect(detailsCB, &QCheckBox::stateChanged, this, &Analyze::setVisibility);
0447 
0448     connect(fullWidthCB, &QCheckBox::toggled, [ = ](bool checked)
0449     {
0450         if (checked)
0451             this->replot();
0452     });
0453 
0454     initStatsCheckboxes();
0455 
0456     connect(zoomInB, &QPushButton::clicked, this, &Analyze::zoomIn);
0457     connect(zoomOutB, &QPushButton::clicked, this, &Analyze::zoomOut);
0458     connect(prevSessionB, &QPushButton::clicked, this, &Analyze::previousTimelineItem);
0459     connect(nextSessionB, &QPushButton::clicked, this, &Analyze::nextTimelineItem);
0460     connect(timelinePlot, &QCustomPlot::mousePress, this, &Analyze::timelineMousePress);
0461     connect(timelinePlot, &QCustomPlot::mouseDoubleClick, this, &Analyze::timelineMouseDoubleClick);
0462     connect(timelinePlot, &QCustomPlot::mouseWheel, this, &Analyze::timelineMouseWheel);
0463     connect(statsPlot, &QCustomPlot::mousePress, this, &Analyze::statsMousePress);
0464     connect(statsPlot, &QCustomPlot::mouseDoubleClick, this, &Analyze::statsMouseDoubleClick);
0465     connect(statsPlot, &QCustomPlot::mouseMove, this, &Analyze::statsMouseMove);
0466     connect(analyzeSB, &QScrollBar::valueChanged, this, &Analyze::scroll);
0467     analyzeSB->setRange(0, MAX_SCROLL_VALUE);
0468     connect(helpB, &QPushButton::clicked, this, &Analyze::helpMessage);
0469     connect(keepCurrentCB, &QCheckBox::stateChanged, this, &Analyze::keepCurrent);
0470 
0471     setupKeyboardShortcuts(this);
0472 
0473     reset();
0474     replot();
0475 }
0476 
0477 void Analyze::setVisibility()
0478 {
0479     detailsWidget->setVisible(detailsCB->isChecked());
0480     statsGridWidget->setVisible(statsCB->isChecked());
0481     timelinePlot->setVisible(timelineCB->isChecked());
0482     statsPlot->setVisible(graphsCB->isChecked());
0483     replot();
0484 }
0485 
0486 // Mouse wheel over the Timeline plot causes an x-axis zoom.
0487 void Analyze::timelineMouseWheel(QWheelEvent *event)
0488 {
0489     if (event->angleDelta().y() > 0)
0490         zoomIn();
0491     else if (event->angleDelta().y() < 0)
0492         zoomOut();
0493 }
0494 
0495 // This callback is used so that when keepCurrent is checked, we replot immediately.
0496 // The actual keepCurrent work is done in replot().
0497 void Analyze::keepCurrent(int state)
0498 {
0499     Q_UNUSED(state);
0500     if (keepCurrentCB->isChecked())
0501     {
0502         removeStatsCursor();
0503         replot();
0504     }
0505 }
0506 
0507 // Implements the input selection UI. User can either choose the current Ekos
0508 // session, or a file read from disk, or set the alternateDirectory variable.
0509 void Analyze::initInputSelection()
0510 {
0511     // Setup the input combo box.
0512     dirPath = QUrl::fromLocalFile(QDir(KSPaths::writableLocation(QStandardPaths::AppLocalDataLocation)).filePath("analyze"));
0513 
0514     inputCombo->addItem(i18n("Current Session"));
0515     inputCombo->addItem(i18n("Read from File"));
0516     inputCombo->addItem(i18n("Set alternative image-file base directory"));
0517     inputValue->setText("");
0518     connect(inputCombo, static_cast<void (QComboBox::*)(int)>(&QComboBox::activated), this, [&](int index)
0519     {
0520         if (index == 0)
0521         {
0522             // Input from current session
0523             if (!runtimeDisplay)
0524             {
0525                 reset();
0526                 inputValue->setText(i18n("Current Session"));
0527                 maxXValue = readDataFromFile(logFilename);
0528                 runtimeDisplay = true;
0529             }
0530             fullWidthCB->setChecked(true);
0531             fullWidthCB->setVisible(true);
0532             fullWidthCB->setDisabled(false);
0533             replot();
0534         }
0535         else if (index == 1)
0536         {
0537             // The i18n call below is broken up (and the word "analyze" is protected from it) because i18n
0538             // translates "analyze" to "analyse" for the English UK locale, but we need to keep it ".analyze"
0539             // because that's what how the files are named.
0540             QUrl inputURL = QFileDialog::getOpenFileUrl(this, i18nc("@title:window", "Select input file"), dirPath,
0541                             QString("Analyze %1 (*.analyze);;%2").arg(i18n("Log")).arg(i18n("All Files (*)")));
0542             if (inputURL.isEmpty())
0543                 return;
0544             dirPath = QUrl(inputURL.url(QUrl::RemoveFilename));
0545 
0546             reset();
0547             inputValue->setText(inputURL.fileName());
0548 
0549             // If we do this after the readData call below, it would animate the sequence.
0550             runtimeDisplay = false;
0551 
0552             maxXValue = readDataFromFile(inputURL.toLocalFile());
0553             checkForMissingSchedulerJobEnd(maxXValue);
0554             plotStart = 0;
0555             plotWidth = maxXValue + 5;
0556             replot();
0557         }
0558         else if (index == 2)
0559         {
0560             QString dir = QFileDialog::getExistingDirectory(
0561                               this, i18n("Set an alternate base directory for your captured images"),
0562                               QDir::homePath(),
0563                               QFileDialog::ShowDirsOnly);
0564             if (dir.size() > 0)
0565             {
0566                 // TODO: replace with an option.
0567                 alternateFolder = dir;
0568             }
0569             // This is not a destiation, reset to one of the above.
0570             if (runtimeDisplay)
0571                 inputCombo->setCurrentIndex(0);
0572             else
0573                 inputCombo->setCurrentIndex(1);
0574         }
0575     });
0576 }
0577 
0578 void Analyze::setupKeyboardShortcuts(QWidget *plot)
0579 {
0580     // Shortcuts defined: https://doc.qt.io/archives/qt-4.8/qkeysequence.html#standard-shortcuts
0581     QShortcut *s = new QShortcut(QKeySequence(QKeySequence::ZoomIn), plot);
0582     connect(s, &QShortcut::activated, this, &Analyze::zoomIn);
0583     s = new QShortcut(QKeySequence(QKeySequence::ZoomOut), plot);
0584     connect(s, &QShortcut::activated, this, &Analyze::zoomOut);
0585 
0586     s = new QShortcut(QKeySequence(QKeySequence::MoveToNextChar), plot);
0587     connect(s, &QShortcut::activated, this, &Analyze::scrollRight);
0588     s = new QShortcut(QKeySequence(QKeySequence::MoveToPreviousChar), plot);
0589     connect(s, &QShortcut::activated, this, &Analyze::scrollLeft);
0590 
0591     s = new QShortcut(QKeySequence(QKeySequence::MoveToNextWord), plot);
0592     connect(s, &QShortcut::activated, this, &Analyze::nextTimelineItem);
0593     s = new QShortcut(QKeySequence(QKeySequence::MoveToPreviousWord), plot);
0594     connect(s, &QShortcut::activated, this, &Analyze::previousTimelineItem);
0595 
0596     s = new QShortcut(QKeySequence(QKeySequence::SelectNextWord), plot);
0597     connect(s, &QShortcut::activated, this, &Analyze::nextTimelineItem);
0598     s = new QShortcut(QKeySequence(QKeySequence::SelectPreviousWord), plot);
0599     connect(s, &QShortcut::activated, this, &Analyze::previousTimelineItem);
0600 
0601     s = new QShortcut(QKeySequence(QKeySequence::MoveToNextLine), plot);
0602     connect(s, &QShortcut::activated, this, &Analyze::statsYZoomIn);
0603     s = new QShortcut(QKeySequence(QKeySequence::MoveToPreviousLine), plot);
0604     connect(s, &QShortcut::activated, this, &Analyze::statsYZoomOut);
0605     s = new QShortcut(QKeySequence("?"), plot);
0606     connect(s, &QShortcut::activated, this, &Analyze::helpMessage);
0607     s = new QShortcut(QKeySequence("h"), plot);
0608     connect(s, &QShortcut::activated, this, &Analyze::helpMessage);
0609     s = new QShortcut(QKeySequence(QKeySequence::HelpContents), plot);
0610     connect(s, &QShortcut::activated, this, &Analyze::helpMessage);
0611 }
0612 
0613 Analyze::~Analyze()
0614 {
0615     // TODO:
0616     // We should write out to disk any sessions that haven't terminated
0617     // (e.g. capture, focus, guide)
0618 }
0619 
0620 void Analyze::setSelectedSession(const Session &s)
0621 {
0622     m_selectedSession = s;
0623 }
0624 
0625 void Analyze::clearSelectedSession()
0626 {
0627     m_selectedSession = Session();
0628 }
0629 
0630 // When a user selects a timeline session, the previously selected one
0631 // is deselected.  Note: this does not replot().
0632 void Analyze::unhighlightTimelineItem()
0633 {
0634     clearSelectedSession();
0635     if (selectionHighlight != nullptr)
0636     {
0637         timelinePlot->removeItem(selectionHighlight);
0638         selectionHighlight = nullptr;
0639     }
0640     detailsTable->clear();
0641     prevSessionB->setDisabled(true);
0642     nextSessionB->setDisabled(true);
0643 }
0644 
0645 // Highlight the area between start and end of the session on row y in Timeline.
0646 // Note that this doesn't replot().
0647 void Analyze::highlightTimelineItem(const Session &session)
0648 {
0649     constexpr double halfHeight = 0.5;
0650     unhighlightTimelineItem();
0651 
0652     setSelectedSession(session);
0653     QCPItemRect *rect = new QCPItemRect(timelinePlot);
0654     rect->topLeft->setCoords(session.start, session.offset + halfHeight);
0655     rect->bottomRight->setCoords(session.end, session.offset - halfHeight);
0656     rect->setBrush(timelineSelectionBrush);
0657     selectionHighlight = rect;
0658     prevSessionB->setDisabled(false);
0659     nextSessionB->setDisabled(false);
0660 
0661 }
0662 
0663 // Creates a fat line-segment on the Timeline, optionally with a stripe in the middle.
0664 QCPItemRect * Analyze::addSession(double start, double end, double y,
0665                                   const QBrush &brush, const QBrush *stripeBrush)
0666 {
0667     QPen pen = QPen(Qt::black, 1, Qt::SolidLine);
0668     QCPItemRect *rect = new QCPItemRect(timelinePlot);
0669     rect->topLeft->setCoords(start, y + halfTimelineHeight);
0670     rect->bottomRight->setCoords(end, y - halfTimelineHeight);
0671     rect->setPen(pen);
0672     rect->setSelectedPen(pen);
0673     rect->setBrush(brush);
0674     rect->setSelectedBrush(brush);
0675 
0676     if (stripeBrush != nullptr)
0677     {
0678         QCPItemRect *stripe = new QCPItemRect(timelinePlot);
0679         stripe->topLeft->setCoords(start, y + halfTimelineHeight / 2.0);
0680         stripe->bottomRight->setCoords(end, y - halfTimelineHeight / 2.0);
0681         stripe->setPen(pen);
0682         stripe->setBrush(*stripeBrush);
0683     }
0684     return rect;
0685 }
0686 
0687 // Add the guide stats values to the Stats graphs.
0688 // We want to avoid drawing guide-stat values when not guiding.
0689 // That is, we have no input samples then, but the graph would connect
0690 // two points with a line. By adding NaN values into the graph,
0691 // those places are made invisible.
0692 void Analyze::addGuideStats(double raDrift, double decDrift, int raPulse, int decPulse, double snr,
0693                             int numStars, double skyBackground, double time)
0694 {
0695     double MAX_GUIDE_STATS_GAP = 30;
0696 
0697     if (time - lastGuideStatsTime > MAX_GUIDE_STATS_GAP &&
0698             lastGuideStatsTime >= 0)
0699     {
0700         addGuideStatsInternal(qQNaN(), qQNaN(), 0, 0, qQNaN(), qQNaN(), qQNaN(), qQNaN(), qQNaN(),
0701                               lastGuideStatsTime + .0001);
0702         addGuideStatsInternal(qQNaN(), qQNaN(), 0, 0, qQNaN(), qQNaN(), qQNaN(), qQNaN(), qQNaN(), time - .0001);
0703         guiderRms->resetFilter();
0704     }
0705 
0706     const double drift = std::hypot(raDrift, decDrift);
0707 
0708     // To compute the RMS error, which is sqrt(sum square error / N), filter the squared
0709     // error, which effectively returns sum squared error / N, and take the sqrt.
0710     // This is done by RmsFilter::newSample().
0711     const double rms = guiderRms->newSample(raDrift, decDrift);
0712     addGuideStatsInternal(raDrift, decDrift, double(raPulse), double(decPulse), snr, numStars, skyBackground, drift, rms, time);
0713 
0714     // If capture is active, plot the capture RMS.
0715     if (captureStartedTime >= 0)
0716     {
0717         // lastCaptureRmsTime is the last time we plotted a capture RMS value.
0718         // If we have plotted values previously, and there's a gap in guiding
0719         // we must place NaN values in the graph surrounding the gap.
0720         if ((lastCaptureRmsTime >= 0) &&
0721                 (time - lastCaptureRmsTime > MAX_GUIDE_STATS_GAP))
0722         {
0723             // this is the first sample in a series with a gap behind us.
0724             statsPlot->graph(CAPTURE_RMS_GRAPH)->addData(lastCaptureRmsTime + .0001, qQNaN());
0725             statsPlot->graph(CAPTURE_RMS_GRAPH)->addData(time - .0001, qQNaN());
0726             captureRms->resetFilter();
0727         }
0728         const double rmsC = captureRms->newSample(raDrift, decDrift);
0729         statsPlot->graph(CAPTURE_RMS_GRAPH)->addData(time, rmsC);
0730         lastCaptureRmsTime = time;
0731     }
0732 
0733     lastGuideStatsTime = time;
0734 }
0735 
0736 void Analyze::addGuideStatsInternal(double raDrift, double decDrift, double raPulse,
0737                                     double decPulse, double snr,
0738                                     double numStars, double skyBackground,
0739                                     double drift, double rms, double time)
0740 {
0741     statsPlot->graph(RA_GRAPH)->addData(time, raDrift);
0742     statsPlot->graph(DEC_GRAPH)->addData(time, decDrift);
0743     statsPlot->graph(RA_PULSE_GRAPH)->addData(time, raPulse);
0744     statsPlot->graph(DEC_PULSE_GRAPH)->addData(time, decPulse);
0745     statsPlot->graph(DRIFT_GRAPH)->addData(time, drift);
0746     statsPlot->graph(RMS_GRAPH)->addData(time, rms);
0747 
0748     // Set the SNR axis' maximum to 95% of the way up from the middle to the top.
0749     if (!qIsNaN(snr))
0750         snrMax = std::max(snr, snrMax);
0751     if (!qIsNaN(skyBackground))
0752         skyBgMax = std::max(skyBackground, skyBgMax);
0753     if (!qIsNaN(numStars))
0754         numStarsMax = std::max(numStars, static_cast<double>(numStarsMax));
0755 
0756     statsPlot->graph(SNR_GRAPH)->addData(time, snr);
0757     statsPlot->graph(NUMSTARS_GRAPH)->addData(time, numStars);
0758     statsPlot->graph(SKYBG_GRAPH)->addData(time, skyBackground);
0759 }
0760 
0761 void Analyze::addTemperature(double temperature, double time)
0762 {
0763     // The HFR corresponds to the last capture
0764     // If there is no temperature sensor, focus sends a large negative value.
0765     if (temperature > -200)
0766         statsPlot->graph(TEMPERATURE_GRAPH)->addData(time, temperature);
0767 }
0768 
0769 void Analyze::addFocusPosition(double focusPosition, double time)
0770 {
0771     statsPlot->graph(FOCUS_POSITION_GRAPH)->addData(time, focusPosition);
0772 }
0773 
0774 void Analyze::addTargetDistance(double targetDistance, double time)
0775 {
0776     // The target distance corresponds to the last capture
0777     if (previousCaptureStartedTime >= 0 && previousCaptureCompletedTime >= 0 &&
0778             previousCaptureStartedTime < previousCaptureCompletedTime &&
0779             previousCaptureCompletedTime <= time)
0780     {
0781         statsPlot->graph(TARGET_DISTANCE_GRAPH)->addData(previousCaptureStartedTime - .0001, qQNaN());
0782         statsPlot->graph(TARGET_DISTANCE_GRAPH)->addData(previousCaptureStartedTime, targetDistance);
0783         statsPlot->graph(TARGET_DISTANCE_GRAPH)->addData(previousCaptureCompletedTime, targetDistance);
0784         statsPlot->graph(TARGET_DISTANCE_GRAPH)->addData(previousCaptureCompletedTime + .0001, qQNaN());
0785     }
0786 }
0787 
0788 // Add the HFR values to the Stats graph, as a constant value between startTime and time.
0789 void Analyze::addHFR(double hfr, int numCaptureStars, int median, double eccentricity,
0790                      double time, double startTime)
0791 {
0792     // The HFR corresponds to the last capture
0793     statsPlot->graph(HFR_GRAPH)->addData(startTime - .0001, qQNaN());
0794     statsPlot->graph(HFR_GRAPH)->addData(startTime, hfr);
0795     statsPlot->graph(HFR_GRAPH)->addData(time, hfr);
0796     statsPlot->graph(HFR_GRAPH)->addData(time + .0001, qQNaN());
0797 
0798     statsPlot->graph(NUM_CAPTURE_STARS_GRAPH)->addData(startTime - .0001, qQNaN());
0799     statsPlot->graph(NUM_CAPTURE_STARS_GRAPH)->addData(startTime, numCaptureStars);
0800     statsPlot->graph(NUM_CAPTURE_STARS_GRAPH)->addData(time, numCaptureStars);
0801     statsPlot->graph(NUM_CAPTURE_STARS_GRAPH)->addData(time + .0001, qQNaN());
0802 
0803     statsPlot->graph(MEDIAN_GRAPH)->addData(startTime - .0001, qQNaN());
0804     statsPlot->graph(MEDIAN_GRAPH)->addData(startTime, median);
0805     statsPlot->graph(MEDIAN_GRAPH)->addData(time, median);
0806     statsPlot->graph(MEDIAN_GRAPH)->addData(time + .0001, qQNaN());
0807 
0808     statsPlot->graph(ECCENTRICITY_GRAPH)->addData(startTime - .0001, qQNaN());
0809     statsPlot->graph(ECCENTRICITY_GRAPH)->addData(startTime, eccentricity);
0810     statsPlot->graph(ECCENTRICITY_GRAPH)->addData(time, eccentricity);
0811     statsPlot->graph(ECCENTRICITY_GRAPH)->addData(time + .0001, qQNaN());
0812 
0813     medianMax = std::max(median, medianMax);
0814     numCaptureStarsMax = std::max(numCaptureStars, numCaptureStarsMax);
0815 }
0816 
0817 // Add the Mount Coordinates values to the Stats graph.
0818 // All but pierSide are in double degrees.
0819 void Analyze::addMountCoords(double ra, double dec, double az,
0820                              double alt, int pierSide, double ha, double time)
0821 {
0822     statsPlot->graph(MOUNT_RA_GRAPH)->addData(time, ra);
0823     statsPlot->graph(MOUNT_DEC_GRAPH)->addData(time, dec);
0824     statsPlot->graph(MOUNT_HA_GRAPH)->addData(time, ha);
0825     statsPlot->graph(AZ_GRAPH)->addData(time, az);
0826     statsPlot->graph(ALT_GRAPH)->addData(time, alt);
0827     statsPlot->graph(PIER_SIDE_GRAPH)->addData(time, double(pierSide));
0828 }
0829 
0830 // Read a .analyze file, and setup all the graphics.
0831 double Analyze::readDataFromFile(const QString &filename)
0832 {
0833     double lastTime = 10;
0834     QFile inputFile(filename);
0835     if (inputFile.open(QIODevice::ReadOnly))
0836     {
0837         QTextStream in(&inputFile);
0838         while (!in.atEnd())
0839         {
0840             QString line = in.readLine();
0841             double time = processInputLine(line);
0842             if (time > lastTime)
0843                 lastTime = time;
0844         }
0845         inputFile.close();
0846     }
0847     return lastTime;
0848 }
0849 
0850 // Process an input line read from a .analyze file.
0851 double Analyze::processInputLine(const QString &line)
0852 {
0853     bool ok;
0854     // Break the line into comma-separated components
0855     QStringList list = line.split(QLatin1Char(','));
0856     // We need at least a command and a timestamp
0857     if (list.size() < 2)
0858         return 0;
0859     if (list[0].at(0).toLatin1() == '#')
0860     {
0861         // Comment character # must be at start of line.
0862         return 0;
0863     }
0864 
0865     if ((list[0] == "AnalyzeStartTime") && list.size() == 3)
0866     {
0867         displayStartTime = QDateTime::fromString(list[1], timeFormat);
0868         startTimeInitialized = true;
0869         analyzeTimeZone = list[2];
0870         return 0;
0871     }
0872 
0873     // Except for comments and the above AnalyzeStartTime, the second item
0874     // in the csv line is a double which represents seconds since start of the log.
0875     const double time = QString(list[1]).toDouble(&ok);
0876     if (!ok)
0877         return 0;
0878     if (time < 0 || time > 3600 * 24 * 10)
0879         return 0;
0880 
0881     if ((list[0] == "CaptureStarting") && (list.size() == 4))
0882     {
0883         const double exposureSeconds = QString(list[2]).toDouble(&ok);
0884         if (!ok)
0885             return 0;
0886         const QString filter = list[3];
0887         processCaptureStarting(time, exposureSeconds, filter);
0888     }
0889     else if ((list[0] == "CaptureComplete") && (list.size() >= 6) && (list.size() <= 9))
0890     {
0891         const double exposureSeconds = QString(list[2]).toDouble(&ok);
0892         if (!ok)
0893             return 0;
0894         const QString filter = list[3];
0895         const double hfr = QString(list[4]).toDouble(&ok);
0896         if (!ok)
0897             return 0;
0898         const QString filename = list[5];
0899         const int numStars = (list.size() > 6) ? QString(list[6]).toInt(&ok) : 0;
0900         if (!ok)
0901             return 0;
0902         const int median = (list.size() > 7) ? QString(list[7]).toInt(&ok) : 0;
0903         if (!ok)
0904             return 0;
0905         const double eccentricity = (list.size() > 8) ? QString(list[8]).toDouble(&ok) : 0;
0906         if (!ok)
0907             return 0;
0908         processCaptureComplete(time, filename, exposureSeconds, filter, hfr, numStars, median, eccentricity, true);
0909     }
0910     else if ((list[0] == "CaptureAborted") && (list.size() == 3))
0911     {
0912         const double exposureSeconds = QString(list[2]).toDouble(&ok);
0913         if (!ok)
0914             return 0;
0915         processCaptureAborted(time, exposureSeconds, true);
0916     }
0917     else if ((list[0] == "AutofocusStarting") && (list.size() == 4))
0918     {
0919         QString filter = list[2];
0920         double temperature = QString(list[3]).toDouble(&ok);
0921         if (!ok)
0922             return 0;
0923         processAutofocusStarting(time, temperature, filter);
0924     }
0925     else if ((list[0] == "AutofocusComplete") && (list.size() >= 4))
0926     {
0927         const QString filter = list[2];
0928         const QString samples = list[3];
0929         const QString curve = list.size() > 4 ? list[4] : "";
0930         const QString title = list.size() > 5 ? list[5] : "";
0931         processAutofocusComplete(time, filter, samples, curve, title, true);
0932     }
0933     else if ((list[0] == "AdaptiveFocusComplete") && (list.size() == 12))
0934     {
0935         // This is the second version of the AdaptiveFocusComplete message
0936         const QString filter = list[2];
0937         double temperature = QString(list[3]).toDouble(&ok);
0938         const double tempTicks = QString(list[4]).toDouble(&ok);
0939         double altitude = QString(list[5]).toDouble(&ok);
0940         const double altTicks = QString(list[6]).toDouble(&ok);
0941         const int prevPosError = QString(list[7]).toInt(&ok);
0942         const int thisPosError = QString(list[8]).toInt(&ok);
0943         const int totalTicks = QString(list[9]).toInt(&ok);
0944         const int position = QString(list[10]).toInt(&ok);
0945         const bool focuserMoved = QString(list[11]).toInt(&ok) != 0;
0946         processAdaptiveFocusComplete(time, filter, temperature, tempTicks, altitude, altTicks, prevPosError,
0947                                      thisPosError, totalTicks, position, focuserMoved, true);
0948     }
0949     else if ((list[0] == "AdaptiveFocusComplete") && (list.size() >= 9))
0950     {
0951         // This is the first version of the AdaptiveFocusComplete message - retained os Analyze can process
0952         // historical messages correctly
0953         const QString filter = list[2];
0954         double temperature = QString(list[3]).toDouble(&ok);
0955         const int tempTicks = QString(list[4]).toInt(&ok);
0956         double altitude = QString(list[5]).toDouble(&ok);
0957         const int altTicks = QString(list[6]).toInt(&ok);
0958         const int totalTicks = QString(list[7]).toInt(&ok);
0959         const int position = QString(list[8]).toInt(&ok);
0960         const bool focuserMoved = list.size() < 10 || QString(list[9]).toInt(&ok) != 0;
0961         processAdaptiveFocusComplete(time, filter, temperature, tempTicks,
0962                                      altitude, altTicks, 0, 0, totalTicks, position, focuserMoved, true);
0963     }
0964     else if ((list[0] == "AutofocusAborted") && (list.size() == 4))
0965     {
0966         QString filter = list[2];
0967         QString samples = list[3];
0968         processAutofocusAborted(time, filter, samples, true);
0969     }
0970     else if ((list[0] == "GuideState") && list.size() == 3)
0971     {
0972         processGuideState(time, list[2], true);
0973     }
0974     else if ((list[0] == "GuideStats") && list.size() == 9)
0975     {
0976         const double ra = QString(list[2]).toDouble(&ok);
0977         if (!ok)
0978             return 0;
0979         const double dec = QString(list[3]).toDouble(&ok);
0980         if (!ok)
0981             return 0;
0982         const double raPulse = QString(list[4]).toInt(&ok);
0983         if (!ok)
0984             return 0;
0985         const double decPulse = QString(list[5]).toInt(&ok);
0986         if (!ok)
0987             return 0;
0988         const double snr = QString(list[6]).toDouble(&ok);
0989         if (!ok)
0990             return 0;
0991         const double skyBg = QString(list[7]).toDouble(&ok);
0992         if (!ok)
0993             return 0;
0994         const double numStars = QString(list[8]).toInt(&ok);
0995         if (!ok)
0996             return 0;
0997         processGuideStats(time, ra, dec, raPulse, decPulse, snr, skyBg, numStars, true);
0998     }
0999     else if ((list[0] == "Temperature") && list.size() == 3)
1000     {
1001         const double temperature = QString(list[2]).toDouble(&ok);
1002         if (!ok)
1003             return 0;
1004         processTemperature(time, temperature, true);
1005     }
1006     else if ((list[0] == "TargetDistance") && list.size() == 3)
1007     {
1008         const double targetDistance = QString(list[2]).toDouble(&ok);
1009         if (!ok)
1010             return 0;
1011         processTargetDistance(time, targetDistance, true);
1012     }
1013     else if ((list[0] == "MountState") && list.size() == 3)
1014     {
1015         processMountState(time, list[2], true);
1016     }
1017     else if ((list[0] == "MountCoords") && (list.size() == 7 || list.size() == 8))
1018     {
1019         const double ra = QString(list[2]).toDouble(&ok);
1020         if (!ok)
1021             return 0;
1022         const double dec = QString(list[3]).toDouble(&ok);
1023         if (!ok)
1024             return 0;
1025         const double az = QString(list[4]).toDouble(&ok);
1026         if (!ok)
1027             return 0;
1028         const double alt = QString(list[5]).toDouble(&ok);
1029         if (!ok)
1030             return 0;
1031         const int side = QString(list[6]).toInt(&ok);
1032         if (!ok)
1033             return 0;
1034         const double ha = (list.size() > 7) ? QString(list[7]).toDouble(&ok) : 0;
1035         if (!ok)
1036             return 0;
1037         processMountCoords(time, ra, dec, az, alt, side, ha, true);
1038     }
1039     else if ((list[0] == "AlignState") && list.size() == 3)
1040     {
1041         processAlignState(time, list[2], true);
1042     }
1043     else if ((list[0] == "MeridianFlipState") && list.size() == 3)
1044     {
1045         processMountFlipState(time, list[2], true);
1046     }
1047     else if ((list[0] == "SchedulerJobStart") && list.size() == 3)
1048     {
1049         QString jobName = list[2];
1050         processSchedulerJobStarted(time, jobName);
1051     }
1052     else if ((list[0] == "SchedulerJobEnd") && list.size() == 4)
1053     {
1054         QString jobName = list[2];
1055         QString reason = list[3];
1056         processSchedulerJobEnded(time, jobName, reason, true);
1057     }
1058     else
1059     {
1060         return 0;
1061     }
1062     return time;
1063 }
1064 
1065 namespace
1066 {
1067 void addDetailsRow(QTableWidget *table, const QString &col1, const QColor &color1,
1068                    const QString &col2, const QColor &color2,
1069                    const QString &col3 = "", const QColor &color3 = Qt::white)
1070 {
1071     int row = table->rowCount();
1072     table->setRowCount(row + 1);
1073 
1074     QTableWidgetItem *item = new QTableWidgetItem();
1075     if (col1 == "Filename")
1076     {
1077         // Special case filenames--they tend to be too long and get elided.
1078         QFont ft = item->font();
1079         ft.setPointSizeF(8.0);
1080         item->setFont(ft);
1081         item->setText(col2);
1082         item->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter);
1083         item->setForeground(color2);
1084         table->setItem(row, 0, item);
1085         table->setSpan(row, 0, 1, 3);
1086         return;
1087     }
1088 
1089     item->setText(col1);
1090     item->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter);
1091     item->setForeground(color1);
1092     table->setItem(row, 0, item);
1093 
1094     item = new QTableWidgetItem();
1095     item->setText(col2);
1096     item->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter);
1097     item->setForeground(color2);
1098     if (col1 == "Filename")
1099     {
1100         // Special Case long filenames.
1101         QFont ft = item->font();
1102         ft.setPointSizeF(8.0);
1103         item->setFont(ft);
1104     }
1105     table->setItem(row, 1, item);
1106 
1107     if (col3.size() > 0)
1108     {
1109         item = new QTableWidgetItem();
1110         item->setText(col3);
1111         item->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter);
1112         item->setForeground(color3);
1113         table->setItem(row, 2, item);
1114     }
1115     else
1116     {
1117         // Column 1 spans 2nd and 3rd columns
1118         table->setSpan(row, 1, 1, 2);
1119     }
1120 }
1121 }
1122 
1123 // Helper to create tables in the details display.
1124 // Start the table, displaying the heading and timing information, common to all sessions.
1125 void Analyze::Session::setupTable(const QString &name, const QString &status,
1126                                   const QDateTime &startClock, const QDateTime &endClock, QTableWidget *table)
1127 {
1128     details = table;
1129     details->clear();
1130     details->setRowCount(0);
1131     details->setEditTriggers(QAbstractItemView::NoEditTriggers);
1132     details->setColumnCount(3);
1133     details->verticalHeader()->setDefaultSectionSize(20);
1134     details->horizontalHeader()->setStretchLastSection(true);
1135     details->setColumnWidth(0, 100);
1136     details->setColumnWidth(1, 100);
1137     details->setShowGrid(false);
1138     details->setWordWrap(true);
1139     details->horizontalHeader()->hide();
1140     details->verticalHeader()->hide();
1141 
1142     QString startDateStr = startClock.toString("dd.MM.yyyy");
1143     QString startTimeStr = startClock.toString("hh:mm:ss");
1144     QString endTimeStr = isTemporary() ? "Ongoing"
1145                          : endClock.toString("hh:mm:ss");
1146 
1147     addDetailsRow(details, name, Qt::yellow, status, Qt::yellow);
1148     addDetailsRow(details, "Date", Qt::yellow, startDateStr, Qt::white);
1149     addDetailsRow(details, "Interval", Qt::yellow, QString::number(start, 'f', 3), Qt::white,
1150                   isTemporary() ? "Ongoing" : QString::number(end, 'f', 3), Qt::white);
1151     addDetailsRow(details, "Clock", Qt::yellow, startTimeStr, Qt::white, endTimeStr, Qt::white);
1152     addDetailsRow(details, "Duration", Qt::yellow, QString::number(end - start, 'f', 1), Qt::white);
1153 }
1154 
1155 // Add a new row to the table, which is specific to the particular Timeline line.
1156 void Analyze::Session::addRow(const QString &key, const QString &value)
1157 {
1158     addDetailsRow(details, key, Qt::yellow, value, Qt::white);
1159 }
1160 
1161 bool Analyze::Session::isTemporary() const
1162 {
1163     return rect != nullptr;
1164 }
1165 
1166 // The focus session parses the "pipe-separate-values" list of positions
1167 // and HFRs given it, eventually to be used to plot the focus v-curve.
1168 Analyze::FocusSession::FocusSession(double start_, double end_, QCPItemRect *rect, bool ok, double temperature_,
1169                                     const QString &filter_, const QString &points_, const QString &curve_, const QString &title_)
1170     : Session(start_, end_, FOCUS_Y, rect), success(ok),
1171       temperature(temperature_), filter(filter_), points(points_), curve(curve_), title(title_)
1172 {
1173     const QStringList list = points.split(QLatin1Char('|'));
1174     const int size = list.size();
1175     // Size can be 1 if points_ is an empty string.
1176     if (size < 2)
1177         return;
1178 
1179     for (int i = 0; i < size; )
1180     {
1181         bool parsed1, parsed2;
1182         int position = QString(list[i++]).toInt(&parsed1);
1183         if (i >= size)
1184             break;
1185         double hfr = QString(list[i++]).toDouble(&parsed2);
1186         if (!parsed1 || !parsed2)
1187         {
1188             positions.clear();
1189             hfrs.clear();
1190             return;
1191         }
1192         positions.push_back(position);
1193         hfrs.push_back(hfr);
1194     }
1195 }
1196 
1197 Analyze::FocusSession::FocusSession(double start_, double end_, QCPItemRect *rect,
1198                                     const QString &filter_, double temperature_, double tempTicks_, double altitude_,
1199                                     double altTicks_, int prevPosError_, int thisPosError_, int totalTicks_, int position_)
1200     : Session(start_, end_, FOCUS_Y, rect), temperature(temperature_), filter(filter_), tempTicks(tempTicks_),
1201       altitude(altitude_), altTicks(altTicks_), prevPosError(prevPosError_), thisPosError(thisPosError_),
1202       totalTicks(totalTicks_), adaptedPosition(position_)
1203 {
1204     standardSession = false;
1205 }
1206 
1207 double Analyze::FocusSession::focusPosition()
1208 {
1209     if (!standardSession)
1210         return adaptedPosition;
1211 
1212     if (positions.size() > 0)
1213         return positions.last();
1214     return 0;
1215 }
1216 
1217 namespace
1218 {
1219 bool isTemporaryFile(const QString &filename)
1220 {
1221     QString tempFileLocation = QStandardPaths::writableLocation(QStandardPaths::TempLocation);
1222     return filename.startsWith(tempFileLocation);
1223 }
1224 }
1225 
1226 // When the user clicks on a particular capture session in the timeline,
1227 // a table is rendered in the details section, and, if it was a double click,
1228 // the fits file is displayed, if it can be found.
1229 void Analyze::captureSessionClicked(CaptureSession &c, bool doubleClick)
1230 {
1231     highlightTimelineItem(c);
1232 
1233     if (c.isTemporary())
1234         c.setupTable("Capture", "in progress", clockTime(c.start), clockTime(c.start), detailsTable);
1235     else if (c.aborted)
1236         c.setupTable("Capture", "ABORTED", clockTime(c.start), clockTime(c.end), detailsTable);
1237     else
1238         c.setupTable("Capture", "successful", clockTime(c.start), clockTime(c.end), detailsTable);
1239 
1240     c.addRow("Filter", c.filter);
1241 
1242     double raRMS, decRMS, totalRMS;
1243     int numSamples;
1244     displayGuideGraphics(c.start, c.end, &raRMS, &decRMS, &totalRMS, &numSamples);
1245     if (numSamples > 0)
1246         c.addRow("GuideRMS", QString::number(totalRMS, 'f', 2));
1247 
1248     c.addRow("Exposure", QString::number(c.duration, 'f', 2));
1249     if (!c.isTemporary())
1250         c.addRow("Filename", c.filename);
1251 
1252 
1253     // Don't try to display images from temporary sessions (they aren't done yet).
1254     if (doubleClick && !c.isTemporary())
1255     {
1256         QString filename = findFilename(c.filename, alternateFolder);
1257         // Don't display temporary files from completed sessions either.
1258         bool tempImage = isTemporaryFile(c.filename);
1259         if (!tempImage && filename.size() == 0)
1260             appendLogText(i18n("Could not find image file: %1", c.filename));
1261         else if (!tempImage)
1262             displayFITS(filename);
1263         else appendLogText(i18n("Cannot display temporary image file: %1", c.filename));
1264     }
1265 }
1266 
1267 namespace
1268 {
1269 QString getSign(int val)
1270 {
1271     if (val == 0) return "";
1272     else if (val > 0) return "+";
1273     else return "-";
1274 }
1275 QString signedIntString(int val)
1276 {
1277     return QString("%1%2").arg(getSign(val)).arg(abs(val));
1278 }
1279 }
1280 
1281 
1282 // When the user clicks on a focus session in the timeline,
1283 // a table is rendered in the details section, and the HFR/position plot
1284 // is displayed in the graphics plot. If focus is ongoing
1285 // the information for the graphics is not plotted as it is not yet available.
1286 void Analyze::focusSessionClicked(FocusSession &c, bool doubleClick)
1287 {
1288     Q_UNUSED(doubleClick);
1289     highlightTimelineItem(c);
1290 
1291     if (!c.standardSession)
1292     {
1293         // This is an adaptive focus session
1294         c.setupTable("Focus", "Adaptive", clockTime(c.end), clockTime(c.end), detailsTable);
1295         c.addRow("Filter", c.filter);
1296         addDetailsRow(detailsTable, "Temperature", Qt::yellow, QString("%1°").arg(c.temperature, 0, 'f', 1),
1297                       Qt::white, QString("%1").arg(c.tempTicks, 0, 'f', 1));
1298         addDetailsRow(detailsTable, "Altitude", Qt::yellow, QString("%1°").arg(c.altitude, 0, 'f', 1),
1299                       Qt::white, QString("%1").arg(c.altTicks, 0, 'f', 1));
1300         addDetailsRow(detailsTable, "Pos Error", Qt::yellow, "Start / End", Qt::white,
1301                       QString("%1 / %2").arg(c.prevPosError).arg(c.thisPosError));
1302         addDetailsRow(detailsTable, "Position", Qt::yellow, QString::number(c.adaptedPosition),
1303                       Qt::white, signedIntString(c.totalTicks));
1304         return;
1305     }
1306 
1307     if (c.success)
1308         c.setupTable("Focus", "successful", clockTime(c.start), clockTime(c.end), detailsTable);
1309     else if (c.isTemporary())
1310         c.setupTable("Focus", "in progress", clockTime(c.start), clockTime(c.start), detailsTable);
1311     else
1312         c.setupTable("Focus", "FAILED", clockTime(c.start), clockTime(c.end), detailsTable);
1313 
1314     if (!c.isTemporary())
1315     {
1316         if (c.success)
1317         {
1318             if (c.hfrs.size() > 0)
1319                 c.addRow("HFR", QString::number(c.hfrs.last(), 'f', 2));
1320             if (c.positions.size() > 0)
1321                 c.addRow("Solution", QString::number(c.positions.last(), 'f', 0));
1322         }
1323         c.addRow("Iterations", QString::number(c.positions.size()));
1324     }
1325     c.addRow("Filter", c.filter);
1326     c.addRow("Temperature", QString::number(c.temperature, 'f', 1));
1327 
1328     if (c.isTemporary())
1329         resetGraphicsPlot();
1330     else
1331         displayFocusGraphics(c.positions, c.hfrs, c.curve, c.title, c.success);
1332 }
1333 
1334 // When the user clicks on a guide session in the timeline,
1335 // a table is rendered in the details section. If it has a G_GUIDING state
1336 // then a drift plot is generated and  RMS values are calculated
1337 // for the guiding session's time interval.
1338 void Analyze::guideSessionClicked(GuideSession &c, bool doubleClick)
1339 {
1340     Q_UNUSED(doubleClick);
1341     highlightTimelineItem(c);
1342 
1343     QString st;
1344     if (c.simpleState == G_IDLE)
1345         st = "Idle";
1346     else if (c.simpleState == G_GUIDING)
1347         st = "Guiding";
1348     else if (c.simpleState == G_CALIBRATING)
1349         st = "Calibrating";
1350     else if (c.simpleState == G_SUSPENDED)
1351         st = "Suspended";
1352     else if (c.simpleState == G_DITHERING)
1353         st = "Dithering";
1354 
1355     c.setupTable("Guide", st, clockTime(c.start), clockTime(c.end), detailsTable);
1356     resetGraphicsPlot();
1357     if (c.simpleState == G_GUIDING)
1358     {
1359         double raRMS, decRMS, totalRMS;
1360         int numSamples;
1361         displayGuideGraphics(c.start, c.end, &raRMS, &decRMS, &totalRMS, &numSamples);
1362         if (numSamples > 0)
1363         {
1364             c.addRow("total RMS", QString::number(totalRMS, 'f', 2));
1365             c.addRow("ra RMS", QString::number(raRMS, 'f', 2));
1366             c.addRow("dec RMS", QString::number(decRMS, 'f', 2));
1367         }
1368         c.addRow("Num Samples", QString::number(numSamples));
1369     }
1370 }
1371 
1372 void Analyze::displayGuideGraphics(double start, double end, double *raRMS,
1373                                    double *decRMS, double *totalRMS, int *numSamples)
1374 {
1375     resetGraphicsPlot();
1376     auto ra = statsPlot->graph(RA_GRAPH)->data()->findBegin(start);
1377     auto dec = statsPlot->graph(DEC_GRAPH)->data()->findBegin(start);
1378     auto raEnd = statsPlot->graph(RA_GRAPH)->data()->findEnd(end);
1379     auto decEnd = statsPlot->graph(DEC_GRAPH)->data()->findEnd(end);
1380     int num = 0;
1381     double raSquareErrorSum = 0, decSquareErrorSum = 0;
1382     while (ra != raEnd && dec != decEnd &&
1383             ra->mainKey() < end && dec->mainKey() < end &&
1384             ra != statsPlot->graph(RA_GRAPH)->data()->constEnd() &&
1385             dec != statsPlot->graph(DEC_GRAPH)->data()->constEnd() &&
1386             ra->mainKey() < end && dec->mainKey() < end)
1387     {
1388         const double raVal = ra->mainValue();
1389         const double decVal = dec->mainValue();
1390         graphicsPlot->graph(GUIDER_GRAPHICS)->addData(raVal, decVal);
1391         if (!qIsNaN(raVal) && !qIsNaN(decVal))
1392         {
1393             raSquareErrorSum += raVal * raVal;
1394             decSquareErrorSum += decVal * decVal;
1395             num++;
1396         }
1397         ra++;
1398         dec++;
1399     }
1400     if (numSamples != nullptr)
1401         *numSamples = num;
1402     if (num > 0)
1403     {
1404         if (raRMS != nullptr)
1405             *raRMS = sqrt(raSquareErrorSum / num);
1406         if (decRMS != nullptr)
1407             *decRMS = sqrt(decSquareErrorSum / num);
1408         if (totalRMS != nullptr)
1409             *totalRMS = sqrt((raSquareErrorSum + decSquareErrorSum) / num);
1410         if (numSamples != nullptr)
1411             *numSamples = num;
1412     }
1413     QCPItemEllipse *c1 = new QCPItemEllipse(graphicsPlot);
1414     c1->bottomRight->setCoords(1.0, -1.0);
1415     c1->topLeft->setCoords(-1.0, 1.0);
1416     QCPItemEllipse *c2 = new QCPItemEllipse(graphicsPlot);
1417     c2->bottomRight->setCoords(2.0, -2.0);
1418     c2->topLeft->setCoords(-2.0, 2.0);
1419     c1->setPen(QPen(Qt::green));
1420     c2->setPen(QPen(Qt::yellow));
1421 
1422     // Since the plot is wider than it is tall, these lines set the
1423     // vertical range to 2.5, and the horizontal range to whatever it
1424     // takes to keep the two axes' scales (number of pixels per value)
1425     // the same, so that circles stay circular (i.e. circles are not stretch
1426     // wide even though the graph area is not square).
1427     graphicsPlot->xAxis->setRange(-2.5, 2.5);
1428     graphicsPlot->yAxis->setRange(-2.5, 2.5);
1429     graphicsPlot->xAxis->setScaleRatio(graphicsPlot->yAxis);
1430 }
1431 
1432 // When the user clicks on a particular mount session in the timeline,
1433 // a table is rendered in the details section.
1434 void Analyze::mountSessionClicked(MountSession &c, bool doubleClick)
1435 {
1436     Q_UNUSED(doubleClick);
1437     highlightTimelineItem(c);
1438 
1439     c.setupTable("Mount", mountStatusString(c.state), clockTime(c.start),
1440                  clockTime(c.isTemporary() ? c.start : c.end), detailsTable);
1441 }
1442 
1443 // When the user clicks on a particular align session in the timeline,
1444 // a table is rendered in the details section.
1445 void Analyze::alignSessionClicked(AlignSession &c, bool doubleClick)
1446 {
1447     Q_UNUSED(doubleClick);
1448     highlightTimelineItem(c);
1449     c.setupTable("Align", getAlignStatusString(c.state), clockTime(c.start),
1450                  clockTime(c.isTemporary() ? c.start : c.end), detailsTable);
1451 }
1452 
1453 // When the user clicks on a particular meridian flip session in the timeline,
1454 // a table is rendered in the details section.
1455 void Analyze::mountFlipSessionClicked(MountFlipSession &c, bool doubleClick)
1456 {
1457     Q_UNUSED(doubleClick);
1458     highlightTimelineItem(c);
1459     c.setupTable("Meridian Flip", MeridianFlipState::meridianFlipStatusString(c.state),
1460                  clockTime(c.start), clockTime(c.isTemporary() ? c.start : c.end), detailsTable);
1461 }
1462 
1463 // When the user clicks on a particular scheduler session in the timeline,
1464 // a table is rendered in the details section.
1465 void Analyze::schedulerSessionClicked(SchedulerJobSession &c, bool doubleClick)
1466 {
1467     Q_UNUSED(doubleClick);
1468     highlightTimelineItem(c);
1469     c.setupTable("Scheduler Job", c.jobName,
1470                  clockTime(c.start), clockTime(c.isTemporary() ? c.start : c.end), detailsTable);
1471     c.addRow("End reason", c.reason);
1472 }
1473 
1474 // This method determines which timeline session (if any) was selected
1475 // when the user clicks in the Timeline plot. It also sets a cursor
1476 // in the stats plot.
1477 void Analyze::processTimelineClick(QMouseEvent *event, bool doubleClick)
1478 {
1479     unhighlightTimelineItem();
1480     double xval = timelinePlot->xAxis->pixelToCoord(event->x());
1481     double yval = timelinePlot->yAxis->pixelToCoord(event->y());
1482     if (yval >= CAPTURE_Y - 0.5 && yval <= CAPTURE_Y + 0.5)
1483     {
1484         QList<CaptureSession> candidates = captureSessions.find(xval);
1485         if (candidates.size() > 0)
1486             captureSessionClicked(candidates[0], doubleClick);
1487         else if ((temporaryCaptureSession.rect != nullptr) &&
1488                  (xval > temporaryCaptureSession.start))
1489             captureSessionClicked(temporaryCaptureSession, doubleClick);
1490     }
1491     else if (yval >= FOCUS_Y - 0.5 && yval <= FOCUS_Y + 0.5)
1492     {
1493         QList<FocusSession> candidates = focusSessions.find(xval);
1494         if (candidates.size() > 0)
1495             focusSessionClicked(candidates[0], doubleClick);
1496         else if ((temporaryFocusSession.rect != nullptr) &&
1497                  (xval > temporaryFocusSession.start))
1498             focusSessionClicked(temporaryFocusSession, doubleClick);
1499     }
1500     else if (yval >= GUIDE_Y - 0.5 && yval <= GUIDE_Y + 0.5)
1501     {
1502         QList<GuideSession> candidates = guideSessions.find(xval);
1503         if (candidates.size() > 0)
1504             guideSessionClicked(candidates[0], doubleClick);
1505         else if ((temporaryGuideSession.rect != nullptr) &&
1506                  (xval > temporaryGuideSession.start))
1507             guideSessionClicked(temporaryGuideSession, doubleClick);
1508     }
1509     else if (yval >= MOUNT_Y - 0.5 && yval <= MOUNT_Y + 0.5)
1510     {
1511         QList<MountSession> candidates = mountSessions.find(xval);
1512         if (candidates.size() > 0)
1513             mountSessionClicked(candidates[0], doubleClick);
1514         else if ((temporaryMountSession.rect != nullptr) &&
1515                  (xval > temporaryMountSession.start))
1516             mountSessionClicked(temporaryMountSession, doubleClick);
1517     }
1518     else if (yval >= ALIGN_Y - 0.5 && yval <= ALIGN_Y + 0.5)
1519     {
1520         QList<AlignSession> candidates = alignSessions.find(xval);
1521         if (candidates.size() > 0)
1522             alignSessionClicked(candidates[0], doubleClick);
1523         else if ((temporaryAlignSession.rect != nullptr) &&
1524                  (xval > temporaryAlignSession.start))
1525             alignSessionClicked(temporaryAlignSession, doubleClick);
1526     }
1527     else if (yval >= MERIDIAN_MOUNT_FLIP_Y - 0.5 && yval <= MERIDIAN_MOUNT_FLIP_Y + 0.5)
1528     {
1529         QList<MountFlipSession> candidates = mountFlipSessions.find(xval);
1530         if (candidates.size() > 0)
1531             mountFlipSessionClicked(candidates[0], doubleClick);
1532         else if ((temporaryMountFlipSession.rect != nullptr) &&
1533                  (xval > temporaryMountFlipSession.start))
1534             mountFlipSessionClicked(temporaryMountFlipSession, doubleClick);
1535     }
1536     else if (yval >= SCHEDULER_Y - 0.5 && yval <= SCHEDULER_Y + 0.5)
1537     {
1538         QList<SchedulerJobSession> candidates = schedulerJobSessions.find(xval);
1539         if (candidates.size() > 0)
1540             schedulerSessionClicked(candidates[0], doubleClick);
1541         else if ((temporarySchedulerJobSession.rect != nullptr) &&
1542                  (xval > temporarySchedulerJobSession.start))
1543             schedulerSessionClicked(temporarySchedulerJobSession, doubleClick);
1544     }
1545     setStatsCursor(xval);
1546     replot();
1547 }
1548 
1549 void Analyze::nextTimelineItem()
1550 {
1551     changeTimelineItem(true);
1552 }
1553 
1554 void Analyze::previousTimelineItem()
1555 {
1556     changeTimelineItem(false);
1557 }
1558 
1559 void Analyze::changeTimelineItem(bool next)
1560 {
1561     if (m_selectedSession.start == 0 && m_selectedSession.end == 0) return;
1562     switch(m_selectedSession.offset)
1563     {
1564         case CAPTURE_Y:
1565         {
1566             auto nextSession = next ? captureSessions.findNext(m_selectedSession.start)
1567                                : captureSessions.findPrevious(m_selectedSession.start);
1568 
1569             // Since we're displaying the images, don't want to stop at an aborted capture.
1570             // Continue searching until a good session (or no session) is found.
1571             while (nextSession && nextSession->aborted)
1572                 nextSession = next ? captureSessions.findNext(nextSession->start)
1573                               : captureSessions.findPrevious(nextSession->start);
1574 
1575             if (nextSession)
1576             {
1577                 // True because we want to display the image (so simulate a double-click on that session).
1578                 captureSessionClicked(*nextSession, true);
1579                 setStatsCursor((nextSession->end + nextSession->start) / 2);
1580             }
1581             break;
1582         }
1583         case FOCUS_Y:
1584         {
1585             auto nextSession = next ? focusSessions.findNext(m_selectedSession.start)
1586                                : focusSessions.findPrevious(m_selectedSession.start);
1587             if (nextSession)
1588             {
1589                 focusSessionClicked(*nextSession, true);
1590                 setStatsCursor((nextSession->end + nextSession->start) / 2);
1591             }
1592             break;
1593         }
1594         case ALIGN_Y:
1595         {
1596             auto nextSession = next ? alignSessions.findNext(m_selectedSession.start)
1597                                : alignSessions.findPrevious(m_selectedSession.start);
1598             if (nextSession)
1599             {
1600                 alignSessionClicked(*nextSession, true);
1601                 setStatsCursor((nextSession->end + nextSession->start) / 2);
1602             }
1603             break;
1604         }
1605         case GUIDE_Y:
1606         {
1607             auto nextSession = next ? guideSessions.findNext(m_selectedSession.start)
1608                                : guideSessions.findPrevious(m_selectedSession.start);
1609             if (nextSession)
1610             {
1611                 guideSessionClicked(*nextSession, true);
1612                 setStatsCursor((nextSession->end + nextSession->start) / 2);
1613             }
1614             break;
1615         }
1616         case MOUNT_Y:
1617         {
1618             auto nextSession = next ? mountSessions.findNext(m_selectedSession.start)
1619                                : mountSessions.findPrevious(m_selectedSession.start);
1620             if (nextSession)
1621             {
1622                 mountSessionClicked(*nextSession, true);
1623                 setStatsCursor((nextSession->end + nextSession->start) / 2);
1624             }
1625             break;
1626         }
1627         case SCHEDULER_Y:
1628         {
1629             auto nextSession = next ? schedulerJobSessions.findNext(m_selectedSession.start)
1630                                : schedulerJobSessions.findPrevious(m_selectedSession.start);
1631             if (nextSession)
1632             {
1633                 schedulerSessionClicked(*nextSession, true);
1634                 setStatsCursor((nextSession->end + nextSession->start) / 2);
1635             }
1636             break;
1637         }
1638             //case MERIDIAN_MOUNT_FLIP_Y:
1639     }
1640     if (!isVisible(m_selectedSession) && !isVisible(m_selectedSession))
1641         adjustView((m_selectedSession.start + m_selectedSession.end) / 2.0);
1642     replot();
1643 }
1644 
1645 bool Analyze::isVisible(const Session &s) const
1646 {
1647     if (fullWidthCB->isChecked())
1648         return true;
1649     return !((s.start < plotStart && s.end < plotStart) ||
1650              (s.start > (plotStart + plotWidth) && s.end > (plotStart + plotWidth)));
1651 }
1652 
1653 void Analyze::adjustView(double time)
1654 {
1655     if (!fullWidthCB->isChecked())
1656     {
1657         plotStart = time - plotWidth / 2;
1658     }
1659 }
1660 
1661 void Analyze::setStatsCursor(double time)
1662 {
1663     removeStatsCursor();
1664 
1665     // Cursor on the stats graph.
1666     QCPItemLine *line = new QCPItemLine(statsPlot);
1667     line->setPen(QPen(Qt::darkGray, 1, Qt::SolidLine));
1668     const double top = statsPlot->yAxis->range().upper;
1669     const double bottom = statsPlot->yAxis->range().lower;
1670     line->start->setCoords(time, bottom);
1671     line->end->setCoords(time, top);
1672     statsCursor = line;
1673 
1674     // Cursor on the timeline.
1675     QCPItemLine *line2 = new QCPItemLine(timelinePlot);
1676     line2->setPen(QPen(Qt::darkGray, 1, Qt::SolidLine));
1677     const double top2 = timelinePlot->yAxis->range().upper;
1678     const double bottom2 = timelinePlot->yAxis->range().lower;
1679     line2->start->setCoords(time, bottom2);
1680     line2->end->setCoords(time, top2);
1681     timelineCursor = line2;
1682 
1683     cursorTimeOut->setText(QString("%1s").arg(time));
1684     cursorClockTimeOut->setText(QString("%1")
1685                                 .arg(clockTime(time).toString("hh:mm:ss")));
1686     statsCursorTime = time;
1687     keepCurrentCB->setCheckState(Qt::Unchecked);
1688 }
1689 
1690 void Analyze::removeStatsCursor()
1691 {
1692     if (statsCursor != nullptr)
1693         statsPlot->removeItem(statsCursor);
1694     statsCursor = nullptr;
1695 
1696     if (timelineCursor != nullptr)
1697         timelinePlot->removeItem(timelineCursor);
1698     timelineCursor = nullptr;
1699 
1700     cursorTimeOut->setText("");
1701     cursorClockTimeOut->setText("");
1702     statsCursorTime = -1;
1703 }
1704 
1705 // When the users clicks in the stats plot, the cursor is set at the corresponding time.
1706 void Analyze::processStatsClick(QMouseEvent *event, bool doubleClick)
1707 {
1708     Q_UNUSED(doubleClick);
1709     double xval = statsPlot->xAxis->pixelToCoord(event->x());
1710     setStatsCursor(xval);
1711     replot();
1712 }
1713 
1714 void Analyze::timelineMousePress(QMouseEvent *event)
1715 {
1716     processTimelineClick(event, false);
1717 }
1718 
1719 void Analyze::timelineMouseDoubleClick(QMouseEvent *event)
1720 {
1721     processTimelineClick(event, true);
1722 }
1723 
1724 void Analyze::statsMousePress(QMouseEvent *event)
1725 {
1726     QCPAxis *yAxis = activeYAxis;
1727     if (!yAxis) return;
1728 
1729     // If we're on the legend, adjust the y-axis.
1730     if (statsPlot->xAxis->pixelToCoord(event->x()) < plotStart)
1731     {
1732         yAxisInitialPos = yAxis->pixelToCoord(event->y());
1733         return;
1734     }
1735     processStatsClick(event, false);
1736 }
1737 
1738 void Analyze::statsMouseDoubleClick(QMouseEvent *event)
1739 {
1740     processStatsClick(event, true);
1741 }
1742 
1743 // Allow the user to click and hold, causing the cursor to move in real-time.
1744 void Analyze::statsMouseMove(QMouseEvent *event)
1745 {
1746     QCPAxis *yAxis = activeYAxis;
1747     if (!yAxis) return;
1748 
1749     // If we're on the legend, adjust the y-axis.
1750     if (statsPlot->xAxis->pixelToCoord(event->x()) < plotStart)
1751     {
1752         auto range = yAxis->range();
1753         double yDiff = yAxisInitialPos - yAxis->pixelToCoord(event->y());
1754         yAxis->setRange(range.lower + yDiff, range.upper + yDiff);
1755         replot();
1756         if (m_YAxisTool.isVisible() && m_YAxisTool.getAxis() == yAxis)
1757             m_YAxisTool.replot(true);
1758         return;
1759     }
1760     processStatsClick(event, false);
1761 }
1762 
1763 // Called by the scrollbar, to move the current view.
1764 void Analyze::scroll(int value)
1765 {
1766     double pct = static_cast<double>(value) / MAX_SCROLL_VALUE;
1767     plotStart = std::max(0.0, maxXValue * pct - plotWidth / 2.0);
1768     // Normally replot adjusts the position of the slider.
1769     // If the user has done that, we don't want replot to re-do it.
1770     replot(false);
1771 
1772 }
1773 void Analyze::scrollRight()
1774 {
1775     plotStart = std::min(maxXValue - plotWidth / 5, plotStart + plotWidth / 5);
1776     fullWidthCB->setChecked(false);
1777     replot();
1778 
1779 }
1780 void Analyze::scrollLeft()
1781 {
1782     plotStart = std::max(0.0, plotStart - plotWidth / 5);
1783     fullWidthCB->setChecked(false);
1784     replot();
1785 
1786 }
1787 void Analyze::replot(bool adjustSlider)
1788 {
1789     adjustTemporarySessions();
1790     if (fullWidthCB->isChecked())
1791     {
1792         plotStart = 0;
1793         plotWidth = std::max(10.0, maxXValue);
1794     }
1795     else if (keepCurrentCB->isChecked())
1796     {
1797         plotStart = std::max(0.0, maxXValue - plotWidth);
1798     }
1799     // If we're keeping to the latest values,
1800     // set the time display to the latest time.
1801     if (keepCurrentCB->isChecked() && statsCursor == nullptr)
1802     {
1803         cursorTimeOut->setText(QString("%1s").arg(maxXValue));
1804         cursorClockTimeOut->setText(QString("%1")
1805                                     .arg(clockTime(maxXValue).toString("hh:mm:ss")));
1806     }
1807     analyzeSB->setPageStep(
1808         std::min(MAX_SCROLL_VALUE,
1809                  static_cast<int>(MAX_SCROLL_VALUE * plotWidth / maxXValue)));
1810     if (adjustSlider)
1811     {
1812         double sliderCenter = plotStart + plotWidth / 2.0;
1813         analyzeSB->setSliderPosition(MAX_SCROLL_VALUE * (sliderCenter / maxXValue));
1814     }
1815 
1816     timelinePlot->xAxis->setRange(plotStart, plotStart + plotWidth);
1817     timelinePlot->yAxis->setRange(0, LAST_Y);
1818 
1819     statsPlot->xAxis->setRange(plotStart, plotStart + plotWidth);
1820 
1821     // Rescale any automatic y-axes.
1822     if (statsPlot->isVisible())
1823     {
1824         for (auto &pairs : yAxisMap)
1825         {
1826             const YAxisInfo &info = pairs.second;
1827             if (statsPlot->graph(info.graphIndex)->visible() && info.rescale)
1828             {
1829                 QCPAxis *axis = info.axis;
1830                 axis->rescale();
1831                 axis->scaleRange(1.1, axis->range().center());
1832             }
1833         }
1834     }
1835 
1836     dateTicker->setOffset(displayStartTime.toMSecsSinceEpoch() / 1000.0);
1837 
1838     timelinePlot->replot();
1839     statsPlot->replot();
1840     graphicsPlot->replot();
1841 
1842     if (activeYAxis != nullptr)
1843     {
1844         // Adjust the statsPlot padding to align statsPlot and timelinePlot.
1845         const int widthDiff = statsPlot->axisRect()->width() - timelinePlot->axisRect()->width();
1846         const int paddingSize = activeYAxis->padding();
1847         constexpr int maxPadding = 100;
1848         // Don't quite following why a positive difference should INCREASE padding, but it works.
1849         const int newPad = std::min(maxPadding, std::max(0, paddingSize + widthDiff));
1850         if (newPad != paddingSize)
1851         {
1852             activeYAxis->setPadding(newPad);
1853             statsPlot->replot();
1854         }
1855     }
1856     updateStatsValues();
1857 }
1858 
1859 void Analyze::statsYZoom(double zoomAmount)
1860 {
1861     auto axis = activeYAxis;
1862     if (!axis) return;
1863     auto range = axis->range();
1864     const double halfDiff = (range.upper - range.lower) / 2.0;
1865     const double middle = (range.upper + range.lower) / 2.0;
1866     axis->setRange(QCPRange(middle - halfDiff * zoomAmount, middle + halfDiff * zoomAmount));
1867     if (m_YAxisTool.isVisible() && m_YAxisTool.getAxis() == axis)
1868         m_YAxisTool.replot(true);
1869 }
1870 void Analyze::statsYZoomIn()
1871 {
1872     statsYZoom(0.80);
1873     statsPlot->replot();
1874 }
1875 void Analyze::statsYZoomOut()
1876 {
1877     statsYZoom(1.25);
1878     statsPlot->replot();
1879 }
1880 
1881 namespace
1882 {
1883 // Pass in a function that converts the double graph value to a string
1884 // for the value box.
1885 template<typename Func>
1886 void updateStat(double time, QLineEdit *valueBox, QCPGraph *graph, Func func, bool useLastRealVal = false)
1887 {
1888     auto begin = graph->data()->findBegin(time);
1889     double timeDiffThreshold = 10000000.0;
1890     if ((begin != graph->data()->constEnd()) &&
1891             (fabs(begin->mainKey() - time) < timeDiffThreshold))
1892     {
1893         double foundVal = begin->mainValue();
1894         valueBox->setDisabled(false);
1895         if (qIsNaN(foundVal))
1896         {
1897             int index = graph->findBegin(time);
1898             const double MAX_TIME_DIFF = 600;
1899             while (useLastRealVal && index >= 0)
1900             {
1901                 const double val = graph->data()->at(index)->mainValue();
1902                 const double t = graph->data()->at(index)->mainKey();
1903                 if (time - t > MAX_TIME_DIFF)
1904                     break;
1905                 if (!qIsNaN(val))
1906                 {
1907                     valueBox->setText(func(val));
1908                     return;
1909                 }
1910                 index--;
1911             }
1912             valueBox->clear();
1913         }
1914         else
1915             valueBox->setText(func(foundVal));
1916     }
1917     else valueBox->setDisabled(true);
1918 }
1919 
1920 }  // namespace
1921 
1922 // This populates the output boxes below the stats plot with the correct statistics.
1923 void Analyze::updateStatsValues()
1924 {
1925     const double time = statsCursorTime < 0 ? maxXValue : statsCursorTime;
1926 
1927     auto d2Fcn = [](double d) -> QString { return QString::number(d, 'f', 2); };
1928     auto d1Fcn = [](double d) -> QString { return QString::number(d, 'f', 1); };
1929     // HFR, numCaptureStars, median & eccentricity are the only ones to use the last real value,
1930     // that is, it keeps those values from the last exposure.
1931     updateStat(time, hfrOut, statsPlot->graph(HFR_GRAPH), d2Fcn, true);
1932     updateStat(time, eccentricityOut, statsPlot->graph(ECCENTRICITY_GRAPH), d2Fcn, true);
1933     updateStat(time, skyBgOut, statsPlot->graph(SKYBG_GRAPH), d1Fcn);
1934     updateStat(time, snrOut, statsPlot->graph(SNR_GRAPH), d1Fcn);
1935     updateStat(time, raOut, statsPlot->graph(RA_GRAPH), d2Fcn);
1936     updateStat(time, decOut, statsPlot->graph(DEC_GRAPH), d2Fcn);
1937     updateStat(time, driftOut, statsPlot->graph(DRIFT_GRAPH), d2Fcn);
1938     updateStat(time, rmsOut, statsPlot->graph(RMS_GRAPH), d2Fcn);
1939     updateStat(time, rmsCOut, statsPlot->graph(CAPTURE_RMS_GRAPH), d2Fcn);
1940     updateStat(time, azOut, statsPlot->graph(AZ_GRAPH), d1Fcn);
1941     updateStat(time, altOut, statsPlot->graph(ALT_GRAPH), d2Fcn);
1942     updateStat(time, temperatureOut, statsPlot->graph(TEMPERATURE_GRAPH), d2Fcn);
1943 
1944     auto asFcn = [](double d) -> QString { return QString("%1\"").arg(d, 0, 'f', 0); };
1945     updateStat(time, targetDistanceOut, statsPlot->graph(TARGET_DISTANCE_GRAPH), asFcn, true);
1946 
1947     auto hmsFcn = [](double d) -> QString
1948     {
1949         dms ra;
1950         ra.setD(d);
1951         return QString("%1:%2:%3").arg(ra.hour()).arg(ra.minute()).arg(ra.second());
1952         //return ra.toHMSString();
1953     };
1954     updateStat(time, mountRaOut, statsPlot->graph(MOUNT_RA_GRAPH), hmsFcn);
1955     auto dmsFcn = [](double d) -> QString { dms dec; dec.setD(d); return dec.toDMSString(); };
1956     updateStat(time, mountDecOut, statsPlot->graph(MOUNT_DEC_GRAPH), dmsFcn);
1957     auto haFcn = [](double d) -> QString
1958     {
1959         dms ha;
1960         QChar z('0');
1961         QChar sgn('+');
1962         ha.setD(d);
1963         if (ha.Hours() > 12.0)
1964         {
1965             ha.setH(24.0 - ha.Hours());
1966             sgn = '-';
1967         }
1968         return QString("%1%2:%3").arg(sgn).arg(ha.hour(), 2, 10, z)
1969         .arg(ha.minute(), 2, 10, z);
1970     };
1971     updateStat(time, mountHaOut, statsPlot->graph(MOUNT_HA_GRAPH), haFcn);
1972 
1973     auto intFcn = [](double d) -> QString { return QString::number(d, 'f', 0); };
1974     updateStat(time, numStarsOut, statsPlot->graph(NUMSTARS_GRAPH), intFcn);
1975     updateStat(time, raPulseOut, statsPlot->graph(RA_PULSE_GRAPH), intFcn);
1976     updateStat(time, decPulseOut, statsPlot->graph(DEC_PULSE_GRAPH), intFcn);
1977     updateStat(time, numCaptureStarsOut, statsPlot->graph(NUM_CAPTURE_STARS_GRAPH), intFcn, true);
1978     updateStat(time, medianOut, statsPlot->graph(MEDIAN_GRAPH), intFcn, true);
1979     updateStat(time, focusPositionOut, statsPlot->graph(FOCUS_POSITION_GRAPH), intFcn);
1980 
1981     auto pierFcn = [](double d) -> QString
1982     {
1983         return d == 0.0 ? "W->E" : d == 1.0 ? "E->W" : "?";
1984     };
1985     updateStat(time, pierSideOut, statsPlot->graph(PIER_SIDE_GRAPH), pierFcn);
1986 }
1987 
1988 void Analyze::initStatsCheckboxes()
1989 {
1990     hfrCB->setChecked(Options::analyzeHFR());
1991     numCaptureStarsCB->setChecked(Options::analyzeNumCaptureStars());
1992     medianCB->setChecked(Options::analyzeMedian());
1993     eccentricityCB->setChecked(Options::analyzeEccentricity());
1994     numStarsCB->setChecked(Options::analyzeNumStars());
1995     skyBgCB->setChecked(Options::analyzeSkyBg());
1996     snrCB->setChecked(Options::analyzeSNR());
1997     temperatureCB->setChecked(Options::analyzeTemperature());
1998     focusPositionCB->setChecked(Options::focusPosition());
1999     targetDistanceCB->setChecked(Options::analyzeTargetDistance());
2000     raCB->setChecked(Options::analyzeRA());
2001     decCB->setChecked(Options::analyzeDEC());
2002     raPulseCB->setChecked(Options::analyzeRAp());
2003     decPulseCB->setChecked(Options::analyzeDECp());
2004     driftCB->setChecked(Options::analyzeDrift());
2005     rmsCB->setChecked(Options::analyzeRMS());
2006     rmsCCB->setChecked(Options::analyzeRMSC());
2007     mountRaCB->setChecked(Options::analyzeMountRA());
2008     mountDecCB->setChecked(Options::analyzeMountDEC());
2009     mountHaCB->setChecked(Options::analyzeMountHA());
2010     azCB->setChecked(Options::analyzeAz());
2011     altCB->setChecked(Options::analyzeAlt());
2012     pierSideCB->setChecked(Options::analyzePierSide());
2013 }
2014 
2015 void Analyze::zoomIn()
2016 {
2017     if (plotWidth > 0.5)
2018     {
2019         if (keepCurrentCB->isChecked())
2020             // If we're keeping to the end of the data, keep the end on the right.
2021             plotStart = std::max(0.0, maxXValue - plotWidth / 4.0);
2022         else if (statsCursorTime >= 0)
2023             // If there is a cursor, try to move it to the center.
2024             plotStart = std::max(0.0, statsCursorTime - plotWidth / 4.0);
2025         else
2026             // Keep the center the same.
2027             plotStart += plotWidth / 4.0;
2028         plotWidth = plotWidth / 2.0;
2029     }
2030     fullWidthCB->setChecked(false);
2031     replot();
2032 }
2033 
2034 void Analyze::zoomOut()
2035 {
2036     if (plotWidth < maxXValue)
2037     {
2038         plotStart = std::max(0.0, plotStart - plotWidth / 2.0);
2039         plotWidth = plotWidth * 2;
2040     }
2041     fullWidthCB->setChecked(false);
2042     replot();
2043 }
2044 
2045 namespace
2046 {
2047 
2048 void setupAxisDefaults(QCPAxis *axis)
2049 {
2050     axis->setBasePen(QPen(Qt::white, 1));
2051     axis->grid()->setPen(QPen(QColor(140, 140, 140, 140), 1, Qt::DotLine));
2052     axis->grid()->setSubGridPen(QPen(QColor(40, 40, 40), 1, Qt::DotLine));
2053     axis->grid()->setZeroLinePen(Qt::NoPen);
2054     axis->setBasePen(QPen(Qt::white, 1));
2055     axis->setTickPen(QPen(Qt::white, 1));
2056     axis->setSubTickPen(QPen(Qt::white, 1));
2057     axis->setTickLabelColor(Qt::white);
2058     axis->setLabelColor(Qt::white);
2059     axis->grid()->setVisible(true);
2060 }
2061 
2062 // Generic initialization of a plot, applied to all plots in this tab.
2063 void initQCP(QCustomPlot *plot)
2064 {
2065     plot->setBackground(QBrush(Qt::black));
2066     setupAxisDefaults(plot->yAxis);
2067     setupAxisDefaults(plot->xAxis);
2068     plot->xAxis->grid()->setZeroLinePen(Qt::NoPen);
2069 }
2070 }  // namespace
2071 
2072 void Analyze::initTimelinePlot()
2073 {
2074     initQCP(timelinePlot);
2075 
2076     // This places the labels on the left of the timeline.
2077     QSharedPointer<QCPAxisTickerText> textTicker(new QCPAxisTickerText);
2078     textTicker->addTick(CAPTURE_Y, i18n("Capture"));
2079     textTicker->addTick(FOCUS_Y, i18n("Focus"));
2080     textTicker->addTick(ALIGN_Y, i18n("Align"));
2081     textTicker->addTick(GUIDE_Y, i18n("Guide"));
2082     textTicker->addTick(MERIDIAN_MOUNT_FLIP_Y, i18n("Flip"));
2083     textTicker->addTick(MOUNT_Y, i18n("Mount"));
2084     textTicker->addTick(SCHEDULER_Y, i18n("Job"));
2085     timelinePlot->yAxis->setTicker(textTicker);
2086 
2087     ADAPTIVE_FOCUS_GRAPH = initGraph(timelinePlot, timelinePlot->yAxis, QCPGraph::lsNone, Qt::red, "adaptiveFocus");
2088     timelinePlot->graph(ADAPTIVE_FOCUS_GRAPH)->setPen(QPen(Qt::red, 2));
2089     timelinePlot->graph(ADAPTIVE_FOCUS_GRAPH)->setScatterStyle(QCPScatterStyle::ssDisc);
2090 }
2091 
2092 // Turn on and off the various statistics, adding/removing them from the legend.
2093 void Analyze::toggleGraph(int graph_id, bool show)
2094 {
2095     statsPlot->graph(graph_id)->setVisible(show);
2096     if (show)
2097         statsPlot->graph(graph_id)->addToLegend();
2098     else
2099         statsPlot->graph(graph_id)->removeFromLegend();
2100     replot();
2101 }
2102 
2103 int Analyze::initGraph(QCustomPlot * plot, QCPAxis * yAxis, QCPGraph::LineStyle lineStyle,
2104                        const QColor &color, const QString &name)
2105 {
2106     int num = plot->graphCount();
2107     plot->addGraph(plot->xAxis, yAxis);
2108     plot->graph(num)->setLineStyle(lineStyle);
2109     plot->graph(num)->setPen(QPen(color));
2110     plot->graph(num)->setName(name);
2111     return num;
2112 }
2113 
2114 void Analyze::updateYAxisMap(QObject * key, const YAxisInfo &axisInfo)
2115 {
2116     if (key == nullptr) return;
2117     auto axisEntry = yAxisMap.find(key);
2118     if (axisEntry == yAxisMap.end())
2119         yAxisMap.insert(std::make_pair(key, axisInfo));
2120     else
2121         axisEntry->second = axisInfo;
2122 }
2123 
2124 template <typename Func>
2125 int Analyze::initGraphAndCB(QCustomPlot * plot, QCPAxis * yAxis, QCPGraph::LineStyle lineStyle,
2126                             const QColor &color, const QString &name, const QString &shortName,
2127                             QCheckBox * cb, Func setCb, QLineEdit * out)
2128 {
2129     const int num = initGraph(plot, yAxis, lineStyle, color, shortName);
2130     if (out != nullptr)
2131     {
2132         const bool autoAxis = YAxisInfo::isRescale(yAxis->range());
2133         updateYAxisMap(out, YAxisInfo(yAxis, yAxis->range(), autoAxis, num, plot, cb, name, shortName, color));
2134     }
2135     if (cb != nullptr)
2136     {
2137         // Don't call toggleGraph() here, as it's too early for replot().
2138         bool show = cb->isChecked();
2139         plot->graph(num)->setVisible(show);
2140         if (show)
2141             plot->graph(num)->addToLegend();
2142         else
2143             plot->graph(num)->removeFromLegend();
2144 
2145         connect(cb, &QCheckBox::toggled,
2146                 [ = ](bool show)
2147         {
2148             this->toggleGraph(num, show);
2149             setCb(show);
2150         });
2151     }
2152     return num;
2153 }
2154 
2155 
2156 void Analyze::userSetAxisColor(QObject *key, const YAxisInfo &axisInfo, const QColor &color)
2157 {
2158     updateYAxisMap(key, axisInfo);
2159     statsPlot->graph(axisInfo.graphIndex)->setPen(QPen(color));
2160     Options::setAnalyzeStatsYAxis(serializeYAxes());
2161     replot();
2162 }
2163 
2164 void Analyze::userSetLeftAxis(QCPAxis *axis)
2165 {
2166     setLeftAxis(axis);
2167     Options::setAnalyzeStatsYAxis(serializeYAxes());
2168     replot();
2169 }
2170 
2171 void Analyze::userChangedYAxis(QObject *key, const YAxisInfo &axisInfo)
2172 {
2173     updateYAxisMap(key, axisInfo);
2174     Options::setAnalyzeStatsYAxis(serializeYAxes());
2175     replot();
2176 }
2177 
2178 // TODO: Doesn't seem like this is ever getting called. Not sure why not receiving the rangeChanged signal.
2179 void Analyze::yAxisRangeChanged(const QCPRange &newRange)
2180 {
2181     Q_UNUSED(newRange);
2182     if (m_YAxisTool.isVisible() && m_YAxisTool.getAxis() == activeYAxis)
2183         m_YAxisTool.replot(true);
2184 }
2185 
2186 void Analyze::setLeftAxis(QCPAxis *axis)
2187 {
2188     if (axis != nullptr && axis != activeYAxis)
2189     {
2190         for (const auto &pair : yAxisMap)
2191         {
2192             disconnect(pair.second.axis, QOverload<const QCPRange &>::of(&QCPAxis::rangeChanged), this,
2193                        QOverload<const QCPRange &>::of(&Analyze::yAxisRangeChanged));
2194             pair.second.axis->setVisible(false);
2195         }
2196         axis->setVisible(true);
2197         activeYAxis = axis;
2198         statsPlot->axisRect()->setRangeZoomAxes(0, axis);
2199         connect(axis, QOverload<const QCPRange &>::of(&QCPAxis::rangeChanged), this,
2200                 QOverload<const QCPRange &>::of(&Analyze::yAxisRangeChanged));
2201     }
2202 }
2203 
2204 void Analyze::startYAxisTool(QObject * key, const YAxisInfo &info)
2205 {
2206     if (info.checkBox && !info.checkBox->isChecked())
2207     {
2208         // Enable the graph.
2209         info.checkBox->setChecked(true);
2210         statsPlot->graph(info.graphIndex)->setVisible(true);
2211         statsPlot->graph(info.graphIndex)->addToLegend();
2212     }
2213 
2214     m_YAxisTool.reset(key, info, info.axis == activeYAxis);
2215     m_YAxisTool.show();
2216 }
2217 
2218 QCPAxis *Analyze::newStatsYAxis(const QString &label, double lower, double upper)
2219 {
2220     QCPAxis *axis = statsPlot->axisRect()->addAxis(QCPAxis::atLeft, 0); // 0 means QCP creates the axis.
2221     axis->setVisible(false);
2222     axis->setRange(lower, upper);
2223     axis->setLabel(label);
2224     setupAxisDefaults(axis);
2225     return axis;
2226 }
2227 
2228 bool Analyze::restoreYAxes(const QString &encoding)
2229 {
2230     constexpr int headerSize = 2;
2231     constexpr int itemSize = 5;
2232     QVector<QStringRef> items = encoding.splitRef(',');
2233     if (items.size() <= headerSize) return false;
2234     if ((items.size() - headerSize) % itemSize != 0) return false;
2235     if (items[0] != "AnalyzeStatsYAxis1.0") return false;
2236 
2237     // Restore the active Y axis
2238     const QString leftID = "left=";
2239     if (!items[1].startsWith(leftID)) return false;
2240     QStringRef left = items[1].mid(leftID.size());
2241     if (left.size() <= 0) return false;
2242     for (const auto &pair : yAxisMap)
2243     {
2244         if (pair.second.axis->label() == left)
2245         {
2246             setLeftAxis(pair.second.axis);
2247             break;
2248         }
2249     }
2250 
2251     // Restore the various upper/lower/rescale axis values.
2252     for (int i = headerSize; i < items.size(); i += itemSize)
2253     {
2254         const QString shortName = items[i].toString();
2255         const double lower = items[i + 1].toDouble();
2256         const double upper = items[i + 2].toDouble();
2257         const bool rescale = items[i + 3] == "T";
2258         const QColor color(items[i + 4]);
2259         for (auto &pair : yAxisMap)
2260         {
2261             auto &info = pair.second;
2262             if (info.axis->label() == shortName)
2263             {
2264                 info.color = color;
2265                 statsPlot->graph(info.graphIndex)->setPen(QPen(color));
2266                 info.rescale = rescale;
2267                 if (rescale)
2268                     info.axis->setRange(
2269                         QCPRange(YAxisInfo::LOWER_RESCALE,
2270                                  YAxisInfo::UPPER_RESCALE));
2271                 else
2272                     info.axis->setRange(QCPRange(lower, upper));
2273                 break;
2274             }
2275         }
2276     }
2277     return true;
2278 }
2279 
2280 // This would be sensitive to short names with commas in them, but we don't do that.
2281 QString Analyze::serializeYAxes()
2282 {
2283     QString encoding = QString("AnalyzeStatsYAxis1.0,left=%1").arg(activeYAxis->label());
2284     QList<QString> savedAxes;
2285     for (const auto &pair : yAxisMap)
2286     {
2287         const YAxisInfo &info = pair.second;
2288         const bool rescale = info.rescale;
2289 
2290         // Only save if something has changed.
2291         bool somethingChanged = (info.initialColor != info.color) ||
2292                                 (rescale != YAxisInfo::isRescale(info.initialRange)) ||
2293                                 (!rescale && info.axis->range() != info.initialRange);
2294 
2295         if (!somethingChanged) continue;
2296 
2297         // Don't save the same axis twice
2298         if (savedAxes.contains(info.axis->label())) continue;
2299 
2300         double lower = rescale ? YAxisInfo::LOWER_RESCALE : info.axis->range().lower;
2301         double upper = rescale ? YAxisInfo::UPPER_RESCALE : info.axis->range().upper;
2302         encoding.append(QString(",%1,%2,%3,%4,%5")
2303                         .arg(info.axis->label()).arg(lower).arg(upper)
2304                         .arg(info.rescale ? "T" : "F").arg(info.color.name()));
2305         savedAxes.append(info.axis->label());
2306     }
2307     return encoding;
2308 }
2309 
2310 void Analyze::initStatsPlot()
2311 {
2312     initQCP(statsPlot);
2313 
2314     // Setup the main y-axis
2315     statsPlot->yAxis->setVisible(true);
2316     statsPlot->yAxis->setLabel("RA/DEC");
2317     statsPlot->yAxis->setRange(-2, 5);
2318     setLeftAxis(statsPlot->yAxis);
2319 
2320     // Setup the legend
2321     statsPlot->legend->setVisible(true);
2322     statsPlot->legend->setFont(QFont("Helvetica", 6));
2323     statsPlot->legend->setTextColor(Qt::white);
2324     // Legend background is black and ~75% opaque.
2325     statsPlot->legend->setBrush(QBrush(QColor(0, 0, 0, 190)));
2326     // Legend stacks vertically.
2327     statsPlot->legend->setFillOrder(QCPLegend::foRowsFirst);
2328     // Rows pretty tightly packed.
2329     statsPlot->legend->setRowSpacing(-3);
2330     statsPlot->axisRect()->insetLayout()->setInsetAlignment(0, Qt::AlignLeft | Qt::AlignTop);
2331 
2332     // Add the graphs.
2333     QString shortName = "HFR";
2334     QCPAxis *hfrAxis = newStatsYAxis(shortName, -2, 6);
2335     HFR_GRAPH = initGraphAndCB(statsPlot, hfrAxis, QCPGraph::lsStepRight, Qt::cyan, "Capture Image HFR", shortName, hfrCB,
2336                                Options::setAnalyzeHFR, hfrOut);
2337     connect(hfrCB, &QCheckBox::clicked,
2338             [ = ](bool show)
2339     {
2340         if (show && !Options::autoHFR())
2341             KSNotification::info(
2342                 i18n("The \"Auto Compute HFR\" option in the KStars "
2343                      "FITS options menu is not set. You won't get HFR values "
2344                      "without it. Once you set it, newly captured images "
2345                      "will have their HFRs computed."));
2346     });
2347 
2348     shortName = "#SubStars";
2349     QCPAxis *numCaptureStarsAxis = newStatsYAxis(shortName);
2350     NUM_CAPTURE_STARS_GRAPH = initGraphAndCB(statsPlot, numCaptureStarsAxis, QCPGraph::lsStepRight, Qt::darkGreen,
2351                               "#Stars in Capture", shortName,
2352                               numCaptureStarsCB, Options::setAnalyzeNumCaptureStars, numCaptureStarsOut);
2353     connect(numCaptureStarsCB, &QCheckBox::clicked,
2354             [ = ](bool show)
2355     {
2356         if (show && !Options::autoHFR())
2357             KSNotification::info(
2358                 i18n("The \"Auto Compute HFR\" option in the KStars "
2359                      "FITS options menu is not set. You won't get # stars in capture image values "
2360                      "without it. Once you set it, newly captured images "
2361                      "will have their stars detected."));
2362     });
2363 
2364     shortName = "median";
2365     QCPAxis *medianAxis = newStatsYAxis(shortName);
2366     MEDIAN_GRAPH = initGraphAndCB(statsPlot, medianAxis, QCPGraph::lsStepRight, Qt::darkGray, "Median Pixel", shortName,
2367                                   medianCB, Options::setAnalyzeMedian, medianOut);
2368 
2369     shortName = "ecc";
2370     QCPAxis *eccAxis = newStatsYAxis(shortName, 0, 1.0);
2371     ECCENTRICITY_GRAPH = initGraphAndCB(statsPlot, eccAxis, QCPGraph::lsStepRight, Qt::darkMagenta, "Eccentricity",
2372                                         shortName, eccentricityCB, Options::setAnalyzeEccentricity, eccentricityOut);
2373     shortName = "#Stars";
2374     QCPAxis *numStarsAxis = newStatsYAxis(shortName);
2375     NUMSTARS_GRAPH = initGraphAndCB(statsPlot, numStarsAxis, QCPGraph::lsStepRight, Qt::magenta, "#Stars in Guide Image",
2376                                     shortName, numStarsCB, Options::setAnalyzeNumStars, numStarsOut);
2377     shortName = "SkyBG";
2378     QCPAxis *skyBgAxis = newStatsYAxis(shortName);
2379     SKYBG_GRAPH = initGraphAndCB(statsPlot, skyBgAxis, QCPGraph::lsStepRight, Qt::darkYellow, "Sky Background Brightness",
2380                                  shortName, skyBgCB, Options::setAnalyzeSkyBg, skyBgOut);
2381 
2382     shortName = "temp";
2383     QCPAxis *temperatureAxis = newStatsYAxis(shortName, -40, 40);
2384     TEMPERATURE_GRAPH = initGraphAndCB(statsPlot, temperatureAxis, QCPGraph::lsLine, Qt::yellow, "Temperature", shortName,
2385                                        temperatureCB, Options::setAnalyzeTemperature, temperatureOut);
2386     shortName = "focus";
2387     QCPAxis *focusPositionAxis = newStatsYAxis(shortName);
2388     FOCUS_POSITION_GRAPH = initGraphAndCB(statsPlot, focusPositionAxis, QCPGraph::lsStepLeft, Qt::lightGray, "Focus", shortName,
2389                                           focusPositionCB, Options::setFocusPosition, focusPositionOut);
2390     shortName = "tDist";
2391     QCPAxis *targetDistanceAxis = newStatsYAxis(shortName, 0, 60);
2392     TARGET_DISTANCE_GRAPH = initGraphAndCB(statsPlot, targetDistanceAxis, QCPGraph::lsLine,
2393                                            QColor(253, 185, 200),  // pink
2394                                            "Distance to Target (arcsec)", shortName, targetDistanceCB, Options::setAnalyzeTargetDistance, targetDistanceOut);
2395     shortName = "SNR";
2396     QCPAxis *snrAxis = newStatsYAxis(shortName, -100, 100);
2397     SNR_GRAPH = initGraphAndCB(statsPlot, snrAxis, QCPGraph::lsLine, Qt::yellow, "Guider SNR", shortName, snrCB,
2398                                Options::setAnalyzeSNR, snrOut);
2399     shortName = "RA";
2400     auto raColor = KStarsData::Instance()->colorScheme()->colorNamed("RAGuideError");
2401     RA_GRAPH = initGraphAndCB(statsPlot, statsPlot->yAxis, QCPGraph::lsLine, raColor, "Guider RA Drift", shortName, raCB,
2402                               Options::setAnalyzeRA, raOut);
2403     shortName = "DEC";
2404     auto decColor = KStarsData::Instance()->colorScheme()->colorNamed("DEGuideError");
2405     DEC_GRAPH = initGraphAndCB(statsPlot, statsPlot->yAxis, QCPGraph::lsLine, decColor, "Guider DEC Drift", shortName, decCB,
2406                                Options::setAnalyzeDEC, decOut);
2407     shortName = "RAp";
2408     auto raPulseColor = KStarsData::Instance()->colorScheme()->colorNamed("RAGuideError");
2409     raPulseColor.setAlpha(75);
2410     QCPAxis *pulseAxis = newStatsYAxis(shortName, -2 * 150, 5 * 150);
2411     RA_PULSE_GRAPH = initGraphAndCB(statsPlot, pulseAxis, QCPGraph::lsLine, raPulseColor, "RA Correction Pulse (ms)", shortName,
2412                                     raPulseCB, Options::setAnalyzeRAp, raPulseOut);
2413     statsPlot->graph(RA_PULSE_GRAPH)->setBrush(QBrush(raPulseColor, Qt::Dense4Pattern));
2414 
2415     shortName = "DECp";
2416     auto decPulseColor = KStarsData::Instance()->colorScheme()->colorNamed("DEGuideError");
2417     decPulseColor.setAlpha(75);
2418     DEC_PULSE_GRAPH = initGraphAndCB(statsPlot, pulseAxis, QCPGraph::lsLine, decPulseColor, "DEC Correction Pulse (ms)",
2419                                      shortName, decPulseCB, Options::setAnalyzeDECp, decPulseOut);
2420     statsPlot->graph(DEC_PULSE_GRAPH)->setBrush(QBrush(decPulseColor, Qt::Dense4Pattern));
2421 
2422     shortName = "Drift";
2423     DRIFT_GRAPH = initGraphAndCB(statsPlot, statsPlot->yAxis, QCPGraph::lsLine, Qt::lightGray, "Guider Instantaneous Drift",
2424                                  shortName, driftCB, Options::setAnalyzeDrift, driftOut);
2425     shortName = "RMS";
2426     RMS_GRAPH = initGraphAndCB(statsPlot, statsPlot->yAxis, QCPGraph::lsLine, Qt::red, "Guider RMS Drift", shortName, rmsCB,
2427                                Options::setAnalyzeRMS, rmsOut);
2428     shortName = "RMSc";
2429     CAPTURE_RMS_GRAPH = initGraphAndCB(statsPlot, statsPlot->yAxis, QCPGraph::lsLine, Qt::red,
2430                                        "Guider RMS Drift (during capture)", shortName, rmsCCB,
2431                                        Options::setAnalyzeRMSC, rmsCOut);
2432     shortName = "MOUNT_RA";
2433     QCPAxis *mountRaDecAxis = newStatsYAxis(shortName, -10, 370);
2434     // Colors of these two unimportant--not really plotted.
2435     MOUNT_RA_GRAPH = initGraphAndCB(statsPlot, mountRaDecAxis, QCPGraph::lsLine, Qt::red, "Mount RA Degrees", shortName,
2436                                     mountRaCB, Options::setAnalyzeMountRA, mountRaOut);
2437     shortName = "MOUNT_DEC";
2438     MOUNT_DEC_GRAPH = initGraphAndCB(statsPlot, mountRaDecAxis, QCPGraph::lsLine, Qt::red, "Mount DEC Degrees", shortName,
2439                                      mountDecCB, Options::setAnalyzeMountDEC, mountDecOut);
2440     shortName = "MOUNT_HA";
2441     MOUNT_HA_GRAPH = initGraphAndCB(statsPlot, mountRaDecAxis, QCPGraph::lsLine, Qt::red, "Mount Hour Angle", shortName,
2442                                     mountHaCB, Options::setAnalyzeMountHA, mountHaOut);
2443     shortName = "AZ";
2444     QCPAxis *azAxis = newStatsYAxis(shortName, -10, 370);
2445     AZ_GRAPH = initGraphAndCB(statsPlot, azAxis, QCPGraph::lsLine, Qt::darkGray, "Mount Azimuth", shortName, azCB,
2446                               Options::setAnalyzeAz, azOut);
2447     shortName = "ALT";
2448     QCPAxis *altAxis = newStatsYAxis(shortName, 0, 90);
2449     ALT_GRAPH = initGraphAndCB(statsPlot, altAxis, QCPGraph::lsLine, Qt::white, "Mount Altitude", shortName, altCB,
2450                                Options::setAnalyzeAlt, altOut);
2451     shortName = "PierSide";
2452     QCPAxis *pierSideAxis = newStatsYAxis(shortName, -2, 2);
2453     PIER_SIDE_GRAPH = initGraphAndCB(statsPlot, pierSideAxis, QCPGraph::lsLine, Qt::darkRed, "Mount Pier Side", shortName,
2454                                      pierSideCB, Options::setAnalyzePierSide, pierSideOut);
2455 
2456     // This makes mouseMove only get called when a button is pressed.
2457     statsPlot->setMouseTracking(false);
2458 
2459     // Setup the clock-time labels on the x-axis of the stats plot.
2460     dateTicker.reset(new OffsetDateTimeTicker);
2461     dateTicker->setDateTimeFormat("hh:mm:ss");
2462     statsPlot->xAxis->setTicker(dateTicker);
2463 
2464     // Didn't include QCP::iRangeDrag as it  interacts poorly with the curson logic.
2465     statsPlot->setInteractions(QCP::iRangeZoom);
2466 
2467     restoreYAxes(Options::analyzeStatsYAxis());
2468 }
2469 
2470 // Clear the graphics and state when changing input data.
2471 void Analyze::reset()
2472 {
2473     maxXValue = 10.0;
2474     plotStart = 0.0;
2475     plotWidth = 10.0;
2476 
2477     guiderRms->resetFilter();
2478     captureRms->resetFilter();
2479 
2480     unhighlightTimelineItem();
2481 
2482     for (int i = 0; i < statsPlot->graphCount(); ++i)
2483         statsPlot->graph(i)->data()->clear();
2484     statsPlot->clearItems();
2485 
2486     for (int i = 0; i < timelinePlot->graphCount(); ++i)
2487         timelinePlot->graph(i)->data()->clear();
2488     timelinePlot->clearItems();
2489 
2490     resetGraphicsPlot();
2491 
2492     detailsTable->clear();
2493     QPalette p = detailsTable->palette();
2494     p.setColor(QPalette::Base, Qt::black);
2495     p.setColor(QPalette::Text, Qt::white);
2496     detailsTable->setPalette(p);
2497 
2498     inputValue->clear();
2499 
2500     captureSessions.clear();
2501     focusSessions.clear();
2502     guideSessions.clear();
2503     mountSessions.clear();
2504     alignSessions.clear();
2505     mountFlipSessions.clear();
2506     schedulerJobSessions.clear();
2507 
2508     numStarsOut->setText("");
2509     skyBgOut->setText("");
2510     snrOut->setText("");
2511     temperatureOut->setText("");
2512     focusPositionOut->setText("");
2513     targetDistanceOut->setText("");
2514     eccentricityOut->setText("");
2515     medianOut->setText("");
2516     numCaptureStarsOut->setText("");
2517 
2518     raOut->setText("");
2519     decOut->setText("");
2520     driftOut->setText("");
2521     rmsOut->setText("");
2522     rmsCOut->setText("");
2523 
2524     removeStatsCursor();
2525     removeTemporarySessions();
2526 
2527     resetCaptureState();
2528     resetAutofocusState();
2529     resetGuideState();
2530     resetGuideStats();
2531     resetAlignState();
2532     resetMountState();
2533     resetMountCoords();
2534     resetMountFlipState();
2535     resetSchedulerJob();
2536 
2537     // Note: no replot().
2538 }
2539 
2540 void Analyze::initGraphicsPlot()
2541 {
2542     initQCP(graphicsPlot);
2543     FOCUS_GRAPHICS = initGraph(graphicsPlot, graphicsPlot->yAxis,
2544                                QCPGraph::lsNone, Qt::cyan, "Focus");
2545     graphicsPlot->graph(FOCUS_GRAPHICS)->setScatterStyle(
2546         QCPScatterStyle(QCPScatterStyle::ssCircle, Qt::white, Qt::white, 14));
2547     FOCUS_GRAPHICS_FINAL = initGraph(graphicsPlot, graphicsPlot->yAxis,
2548                                      QCPGraph::lsNone, Qt::cyan, "FocusBest");
2549     graphicsPlot->graph(FOCUS_GRAPHICS_FINAL)->setScatterStyle(
2550         QCPScatterStyle(QCPScatterStyle::ssCircle, Qt::yellow, Qt::yellow, 14));
2551     FOCUS_GRAPHICS_CURVE = initGraph(graphicsPlot, graphicsPlot->yAxis,
2552                                      QCPGraph::lsLine, Qt::white, "FocusCurve");
2553     graphicsPlot->setInteractions(QCP::iRangeZoom);
2554     graphicsPlot->setInteraction(QCP::iRangeDrag, true);
2555 
2556 
2557     GUIDER_GRAPHICS = initGraph(graphicsPlot, graphicsPlot->yAxis,
2558                                 QCPGraph::lsNone, Qt::cyan, "Guide Error");
2559     graphicsPlot->graph(GUIDER_GRAPHICS)->setScatterStyle(
2560         QCPScatterStyle(QCPScatterStyle::ssStar, Qt::gray, 5));
2561 }
2562 
2563 void Analyze::displayFocusGraphics(const QVector<double> &positions, const QVector<double> &hfrs,
2564                                    const QString &curve, const QString &title, bool success)
2565 {
2566     resetGraphicsPlot();
2567     auto graph = graphicsPlot->graph(FOCUS_GRAPHICS);
2568     auto finalGraph = graphicsPlot->graph(FOCUS_GRAPHICS_FINAL);
2569     double maxHfr = -1e8, maxPosition = -1e8, minHfr = 1e8, minPosition = 1e8;
2570     for (int i = 0; i < positions.size(); ++i)
2571     {
2572         // Yellow circle for the final point.
2573         if (success && i == positions.size() - 1)
2574             finalGraph->addData(positions[i], hfrs[i]);
2575         else
2576             graph->addData(positions[i], hfrs[i]);
2577         maxHfr = std::max(maxHfr, hfrs[i]);
2578         minHfr = std::min(minHfr, hfrs[i]);
2579         maxPosition = std::max(maxPosition, positions[i]);
2580         minPosition = std::min(minPosition, positions[i]);
2581     }
2582 
2583     for (int i = 0; i < positions.size(); ++i)
2584     {
2585         QCPItemText *textLabel = new QCPItemText(graphicsPlot);
2586         textLabel->setPositionAlignment(Qt::AlignCenter | Qt::AlignHCenter);
2587         textLabel->position->setType(QCPItemPosition::ptPlotCoords);
2588         textLabel->position->setCoords(positions[i], hfrs[i]);
2589         textLabel->setText(QString::number(i + 1));
2590         textLabel->setFont(QFont(font().family(), 12));
2591         textLabel->setPen(Qt::NoPen);
2592         textLabel->setColor(Qt::red);
2593     }
2594 
2595     const double xRange = maxPosition - minPosition;
2596 
2597     // Draw the curve, if given.
2598     if (curve.size() > 0)
2599     {
2600         CurveFitting curveFitting(curve);
2601         const double interval = xRange / 20.0;
2602         auto curveGraph = graphicsPlot->graph(FOCUS_GRAPHICS_CURVE);
2603         for (double x = minPosition ; x < maxPosition ; x += interval)
2604             curveGraph->addData(x, curveFitting.f(x));
2605     }
2606 
2607     auto plotTitle = new QCPItemText(graphicsPlot);
2608     plotTitle->setColor(QColor(255, 255, 255));
2609     plotTitle->setPositionAlignment(Qt::AlignTop | Qt::AlignHCenter);
2610     plotTitle->position->setType(QCPItemPosition::ptAxisRectRatio);
2611     plotTitle->position->setCoords(0.5, 0);
2612     plotTitle->setFont(QFont(font().family(), 10));
2613     plotTitle->setVisible(true);
2614     plotTitle->setText(title);
2615 
2616     // Set the same axes ranges as are used in focushfrvplot.cpp.
2617     const double upper = 1.5 * maxHfr;
2618     const double lower = minHfr - (0.25 * (upper - minHfr));
2619     const double xPadding = hfrs.size() > 0 ? xRange / hfrs.size() : 10;
2620     graphicsPlot->xAxis->setRange(minPosition - xPadding, maxPosition + xPadding);
2621     graphicsPlot->yAxis->setRange(lower, upper);
2622     graphicsPlot->replot();
2623 }
2624 
2625 void Analyze::resetGraphicsPlot()
2626 {
2627     for (int i = 0; i < graphicsPlot->graphCount(); ++i)
2628         graphicsPlot->graph(i)->data()->clear();
2629     graphicsPlot->clearItems();
2630 }
2631 
2632 void Analyze::displayFITS(const QString &filename)
2633 {
2634     QUrl url = QUrl::fromLocalFile(filename);
2635 
2636     if (fitsViewer.isNull())
2637     {
2638         fitsViewer = KStars::Instance()->createFITSViewer();
2639         fitsViewer->loadFile(url);
2640         connect(fitsViewer.get(), &FITSViewer::terminated, this, [this]()
2641         {
2642             fitsViewer.clear();
2643         });
2644     }
2645     else
2646         fitsViewer->updateFile(url, 0);
2647 
2648     fitsViewer->show();
2649 }
2650 
2651 void Analyze::helpMessage()
2652 {
2653 #ifdef Q_OS_OSX  // This is because KHelpClient doesn't seem to be working right on MacOS
2654     KStars::Instance()->appHelpActivated();
2655 #else
2656     KHelpClient::invokeHelp(QStringLiteral("tool-ekos.html#ekos-analyze"), QStringLiteral("kstars"));
2657 #endif
2658 }
2659 
2660 // This is intended for recording data to file.
2661 // Don't use this when displaying data read from file, as this is not using the
2662 // correct analyzeStartTime.
2663 double Analyze::logTime(const QDateTime &time)
2664 {
2665     if (!logInitialized)
2666         startLog();
2667     return (time.toMSecsSinceEpoch() - analyzeStartTime.toMSecsSinceEpoch()) / 1000.0;
2668 }
2669 
2670 // The logTime using clock = now.
2671 // This is intended for recording data to file.
2672 // Don't use this When displaying data read from file.
2673 double Analyze::logTime()
2674 {
2675     return logTime(QDateTime::currentDateTime());
2676 }
2677 
2678 // Goes back to clock time from seconds into the log.
2679 // Appropriate for both displaying data from files as well as when displaying live data.
2680 QDateTime Analyze::clockTime(double logSeconds)
2681 {
2682     return displayStartTime.addMSecs(logSeconds * 1000.0);
2683 }
2684 
2685 
2686 // Write the command name, a timestamp and the message with comma separation to a .analyze file.
2687 void Analyze::saveMessage(const QString &type, const QString &message)
2688 {
2689     QString line(QString("%1,%2%3%4\n")
2690                  .arg(type)
2691                  .arg(QString::number(logTime(), 'f', 3))
2692                  .arg(message.size() > 0 ? "," : "", message));
2693     appendToLog(line);
2694 }
2695 
2696 // Start writing a .analyze file.
2697 void Analyze::startLog()
2698 {
2699     analyzeStartTime = QDateTime::currentDateTime();
2700     startTimeInitialized = true;
2701     if (runtimeDisplay)
2702         displayStartTime = analyzeStartTime;
2703     if (logInitialized)
2704         return;
2705     QDir dir = QDir(KSPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + "/analyze");
2706     dir.mkpath(".");
2707 
2708     logFilename = dir.filePath("ekos-" + QDateTime::currentDateTime().toString("yyyy-MM-ddThh-mm-ss") + ".analyze");
2709     logFile.setFileName(logFilename);
2710     logFile.open(QIODevice::WriteOnly | QIODevice::Text);
2711 
2712     // This must happen before the below appendToLog() call.
2713     logInitialized = true;
2714 
2715     appendToLog(QString("#KStars version %1. Analyze log version 1.0.\n\n")
2716                 .arg(KSTARS_VERSION));
2717     appendToLog(QString("%1,%2,%3\n")
2718                 .arg("AnalyzeStartTime", analyzeStartTime.toString(timeFormat), analyzeStartTime.timeZoneAbbreviation()));
2719 }
2720 
2721 void Analyze::appendToLog(const QString &lines)
2722 {
2723     if (!logInitialized)
2724         startLog();
2725     QTextStream out(&logFile);
2726     out << lines;
2727     out.flush();
2728 }
2729 
2730 // maxXValue is the largest time value we have seen so far for this data.
2731 void Analyze::updateMaxX(double time)
2732 {
2733     maxXValue = std::max(time, maxXValue);
2734 }
2735 
2736 // Manage temporary sessions displayed on the Timeline.
2737 // Those are ongoing sessions that will ultimately be replaced when the session is complete.
2738 // This only happens with live data, not with data read from .analyze files.
2739 
2740 // Remove the graphic element.
2741 void Analyze::removeTemporarySession(Session * session)
2742 {
2743     if (session->rect != nullptr)
2744         timelinePlot->removeItem(session->rect);
2745     session->rect = nullptr;
2746     session->start = 0;
2747     session->end = 0;
2748 }
2749 
2750 // Remove all temporary sessions (i.e. from all lines in the Timeline).
2751 void Analyze::removeTemporarySessions()
2752 {
2753     removeTemporarySession(&temporaryCaptureSession);
2754     removeTemporarySession(&temporaryMountFlipSession);
2755     removeTemporarySession(&temporaryFocusSession);
2756     removeTemporarySession(&temporaryGuideSession);
2757     removeTemporarySession(&temporaryMountSession);
2758     removeTemporarySession(&temporaryAlignSession);
2759     removeTemporarySession(&temporarySchedulerJobSession);
2760 }
2761 
2762 // Add a new temporary session.
2763 void Analyze::addTemporarySession(Session * session, double time, double duration,
2764                                   int y_offset, const QBrush &brush)
2765 {
2766     removeTemporarySession(session);
2767     session->rect = addSession(time, time + duration, y_offset, brush);
2768     session->start = time;
2769     session->end = time + duration;
2770     session->offset = y_offset;
2771     session->temporaryBrush = brush;
2772     updateMaxX(time + duration);
2773 }
2774 
2775 // Extend a temporary session. That is, we don't know how long the session will last,
2776 // so when new data arrives (from any module, not necessarily the one with the temporary
2777 // session) we must extend that temporary session.
2778 void Analyze::adjustTemporarySession(Session * session)
2779 {
2780     if (session->rect != nullptr && session->end < maxXValue)
2781     {
2782         QBrush brush = session->temporaryBrush;
2783         double start = session->start;
2784         int offset = session->offset;
2785         addTemporarySession(session, start, maxXValue - start, offset, brush);
2786     }
2787 }
2788 
2789 // Extend all temporary sessions.
2790 void Analyze::adjustTemporarySessions()
2791 {
2792     adjustTemporarySession(&temporaryCaptureSession);
2793     adjustTemporarySession(&temporaryMountFlipSession);
2794     adjustTemporarySession(&temporaryFocusSession);
2795     adjustTemporarySession(&temporaryGuideSession);
2796     adjustTemporarySession(&temporaryMountSession);
2797     adjustTemporarySession(&temporaryAlignSession);
2798     adjustTemporarySession(&temporarySchedulerJobSession);
2799 }
2800 
2801 // Called when the captureStarting slot receives a signal.
2802 // Saves the message to disk, and calls processCaptureStarting.
2803 void Analyze::captureStarting(double exposureSeconds, const QString &filter)
2804 {
2805     saveMessage("CaptureStarting",
2806                 QString("%1,%2").arg(QString::number(exposureSeconds, 'f', 3), filter));
2807     processCaptureStarting(logTime(), exposureSeconds, filter);
2808 }
2809 
2810 // Called by either the above (when live data is received), or reading from file.
2811 // BatchMode would be true when reading from file.
2812 void Analyze::processCaptureStarting(double time, double exposureSeconds, const QString &filter)
2813 {
2814     captureStartedTime = time;
2815     captureStartedFilter = filter;
2816     updateMaxX(time);
2817 
2818     addTemporarySession(&temporaryCaptureSession, time, 1, CAPTURE_Y, temporaryBrush);
2819     temporaryCaptureSession.duration = exposureSeconds;
2820     temporaryCaptureSession.filter = filter;
2821 }
2822 
2823 // Called when the captureComplete slot receives a signal.
2824 void Analyze::captureComplete(const QVariantMap &metadata)
2825 {
2826     auto filename = metadata["filename"].toString();
2827     auto exposure = metadata["exposure"].toDouble();
2828     auto filter = metadata["filter"].toString();
2829     auto hfr = metadata["hfr"].toDouble();
2830     auto starCount = metadata["starCount"].toInt();
2831     auto median = metadata["median"].toDouble();
2832     auto eccentricity = metadata["eccentricity"].toDouble();
2833 
2834     saveMessage("CaptureComplete",
2835                 QString("%1,%2,%3,%4,%5,%6,%7")
2836                 .arg(QString::number(exposure, 'f', 3), filter, QString::number(hfr, 'f', 3), filename)
2837                 .arg(starCount)
2838                 .arg(median)
2839                 .arg(QString::number(eccentricity, 'f', 3)));
2840     if (runtimeDisplay && captureStartedTime >= 0)
2841         processCaptureComplete(logTime(), filename, exposure, filter, hfr, starCount, median, eccentricity);
2842 }
2843 
2844 void Analyze::processCaptureComplete(double time, const QString &filename,
2845                                      double exposureSeconds, const QString &filter, double hfr,
2846                                      int numStars, int median, double eccentricity, bool batchMode)
2847 {
2848     removeTemporarySession(&temporaryCaptureSession);
2849     QBrush stripe;
2850     if (filterStripeBrush(filter, &stripe))
2851         addSession(captureStartedTime, time, CAPTURE_Y, successBrush, &stripe);
2852     else
2853         addSession(captureStartedTime, time, CAPTURE_Y, successBrush, nullptr);
2854     auto session = CaptureSession(captureStartedTime, time, nullptr, false,
2855                                   filename, exposureSeconds, filter);
2856     captureSessions.add(session);
2857     addHFR(hfr, numStars, median, eccentricity, time, captureStartedTime);
2858     updateMaxX(time);
2859     if (!batchMode)
2860     {
2861         if (runtimeDisplay && keepCurrentCB->isChecked() && statsCursor == nullptr)
2862             captureSessionClicked(session, false);
2863         replot();
2864     }
2865     previousCaptureStartedTime = captureStartedTime;
2866     previousCaptureCompletedTime = time;
2867     captureStartedTime = -1;
2868 }
2869 
2870 void Analyze::captureAborted(double exposureSeconds)
2871 {
2872     saveMessage("CaptureAborted",
2873                 QString("%1").arg(QString::number(exposureSeconds, 'f', 3)));
2874     if (runtimeDisplay && captureStartedTime >= 0)
2875         processCaptureAborted(logTime(), exposureSeconds);
2876 }
2877 
2878 void Analyze::processCaptureAborted(double time, double exposureSeconds, bool batchMode)
2879 {
2880     removeTemporarySession(&temporaryCaptureSession);
2881     double duration = time - captureStartedTime;
2882     if (captureStartedTime >= 0 &&
2883             duration < (exposureSeconds + 30) &&
2884             duration < 3600)
2885     {
2886         // You can get a captureAborted without a captureStarting,
2887         // so make sure this associates with a real start.
2888         addSession(captureStartedTime, time, CAPTURE_Y, failureBrush);
2889         auto session = CaptureSession(captureStartedTime, time, nullptr, true, "",
2890                                       exposureSeconds, captureStartedFilter);
2891         captureSessions.add(session);
2892         updateMaxX(time);
2893         if (!batchMode)
2894         {
2895             if (runtimeDisplay && keepCurrentCB->isChecked() && statsCursor == nullptr)
2896                 captureSessionClicked(session, false);
2897             replot();
2898         }
2899         captureStartedTime = -1;
2900     }
2901     previousCaptureStartedTime = -1;
2902     previousCaptureCompletedTime = -1;
2903 }
2904 
2905 void Analyze::resetCaptureState()
2906 {
2907     captureStartedTime = -1;
2908     captureStartedFilter = "";
2909     medianMax = 1;
2910     numCaptureStarsMax = 1;
2911     previousCaptureStartedTime = -1;
2912     previousCaptureCompletedTime = -1;
2913 }
2914 
2915 void Analyze::autofocusStarting(double temperature, const QString &filter)
2916 {
2917     saveMessage("AutofocusStarting",
2918                 QString("%1,%2")
2919                 .arg(filter)
2920                 .arg(QString::number(temperature, 'f', 1)));
2921     processAutofocusStarting(logTime(), temperature, filter);
2922 }
2923 
2924 void Analyze::processAutofocusStarting(double time, double temperature, const QString &filter)
2925 {
2926     autofocusStartedTime = time;
2927     autofocusStartedFilter = filter;
2928     autofocusStartedTemperature = temperature;
2929     addTemperature(temperature, time);
2930     updateMaxX(time);
2931 
2932     addTemporarySession(&temporaryFocusSession, time, 1, FOCUS_Y, temporaryBrush);
2933     temporaryFocusSession.temperature = temperature;
2934     temporaryFocusSession.filter = filter;
2935 }
2936 
2937 void Analyze::adaptiveFocusComplete(const QString &filter, double temperature, double tempTicks,
2938                                     double altitude, double altTicks, int prevPosError, int thisPosError,
2939                                     int totalTicks, int position, bool focuserMoved)
2940 {
2941     saveMessage("AdaptiveFocusComplete", QString("%1,%2,%3,%4,%5,%6,%7,%8,%9,%10").arg(filter).arg(temperature, 0, 'f', 2)
2942                 .arg(tempTicks, 0, 'f', 2).arg(altitude, 0, 'f', 2).arg(altTicks, 0, 'f', 2).arg(prevPosError)
2943                 .arg(thisPosError).arg(totalTicks).arg(position).arg(focuserMoved ? 1 : 0));
2944 
2945     if (runtimeDisplay)
2946         processAdaptiveFocusComplete(logTime(), filter, temperature, tempTicks, altitude, altTicks, prevPosError, thisPosError,
2947                                      totalTicks, position, focuserMoved);
2948 }
2949 
2950 void Analyze::processAdaptiveFocusComplete(double time, const QString &filter, double temperature, double tempTicks,
2951         double altitude, double altTicks, int prevPosError, int thisPosError, int totalTicks, int position,
2952         bool focuserMoved, bool batchMode)
2953 {
2954     removeTemporarySession(&temporaryFocusSession);
2955 
2956     addFocusPosition(position, time);
2957     updateMaxX(time);
2958 
2959     // In general if nothing happened we won't plot a value. This means there won't be lots of points with zeros in them.
2960     // However, we need to cover the situation of offsetting movements that overall don't move the focuser but still have non-zero detail
2961     if (!focuserMoved || (abs(tempTicks) < 1.00 && abs(altTicks) < 1.0 && prevPosError == 0 && thisPosError == 0))
2962         return;
2963 
2964     // Add a dot on the timeline.
2965     timelinePlot->graph(ADAPTIVE_FOCUS_GRAPH)->addData(time, FOCUS_Y);
2966 
2967     // Add mouse sensitivity on the timeline.
2968     constexpr int artificialInterval = 10;
2969     auto session = FocusSession(time - artificialInterval, time + artificialInterval, nullptr,
2970                                 filter, temperature, tempTicks, altitude, altTicks, prevPosError, thisPosError, totalTicks, position);
2971     focusSessions.add(session);
2972 
2973     if (!batchMode)
2974         replot();
2975 
2976     autofocusStartedTime = -1;
2977 }
2978 
2979 void Analyze::autofocusComplete(const QString &filter, const QString &points, const QString &curve, const QString &rawTitle)
2980 {
2981     // Remove commas from the title as they're used as separators in the .analyze file.
2982     QString title = rawTitle;
2983     title.replace(",", " ");
2984 
2985     if (curve.size() == 0)
2986         saveMessage("AutofocusComplete", QString("%1,%2").arg(filter, points));
2987     else if (title.size() == 0)
2988         saveMessage("AutofocusComplete", QString("%1,%2,%3").arg(filter, points, curve));
2989     else
2990         saveMessage("AutofocusComplete", QString("%1,%2,%3,%4").arg(filter, points, curve, title));
2991 
2992     if (runtimeDisplay && autofocusStartedTime >= 0)
2993         processAutofocusComplete(logTime(), filter, points, curve, title);
2994 }
2995 
2996 void Analyze::processAutofocusComplete(double time, const QString &filter, const QString &points,
2997                                        const QString &curve, const QString &title, bool batchMode)
2998 {
2999     removeTemporarySession(&temporaryFocusSession);
3000     QBrush stripe;
3001     if (filterStripeBrush(filter, &stripe))
3002         addSession(autofocusStartedTime, time, FOCUS_Y, successBrush, &stripe);
3003     else
3004         addSession(autofocusStartedTime, time, FOCUS_Y, successBrush, nullptr);
3005     auto session = FocusSession(autofocusStartedTime, time, nullptr, true,
3006                                 autofocusStartedTemperature, filter, points, curve, title);
3007     focusSessions.add(session);
3008     addFocusPosition(session.focusPosition(), autofocusStartedTime);
3009     updateMaxX(time);
3010     if (!batchMode)
3011     {
3012         if (runtimeDisplay && keepCurrentCB->isChecked() && statsCursor == nullptr)
3013             focusSessionClicked(session, false);
3014         replot();
3015     }
3016     autofocusStartedTime = -1;
3017 }
3018 
3019 void Analyze::autofocusAborted(const QString &filter, const QString &points)
3020 {
3021     saveMessage("AutofocusAborted", QString("%1,%2").arg(filter, points));
3022     if (runtimeDisplay && autofocusStartedTime >= 0)
3023         processAutofocusAborted(logTime(), filter, points);
3024 }
3025 
3026 void Analyze::processAutofocusAborted(double time, const QString &filter, const QString &points, bool batchMode)
3027 {
3028     removeTemporarySession(&temporaryFocusSession);
3029     double duration = time - autofocusStartedTime;
3030     if (autofocusStartedTime >= 0 && duration < 1000)
3031     {
3032         // Just in case..
3033         addSession(autofocusStartedTime, time, FOCUS_Y, failureBrush);
3034         auto session = FocusSession(autofocusStartedTime, time, nullptr, false,
3035                                     autofocusStartedTemperature, filter, points, "", "");
3036         focusSessions.add(session);
3037         updateMaxX(time);
3038         if (!batchMode)
3039         {
3040             if (runtimeDisplay && keepCurrentCB->isChecked() && statsCursor == nullptr)
3041                 focusSessionClicked(session, false);
3042             replot();
3043         }
3044         autofocusStartedTime = -1;
3045     }
3046 }
3047 
3048 void Analyze::resetAutofocusState()
3049 {
3050     autofocusStartedTime = -1;
3051     autofocusStartedFilter = "";
3052     autofocusStartedTemperature = 0;
3053 }
3054 
3055 namespace
3056 {
3057 
3058 // TODO: move to ekos.h/cpp?
3059 Ekos::GuideState stringToGuideState(const QString &str)
3060 {
3061     if (str == i18n("Idle"))
3062         return GUIDE_IDLE;
3063     else if (str == i18n("Aborted"))
3064         return GUIDE_ABORTED;
3065     else if (str == i18n("Connected"))
3066         return GUIDE_CONNECTED;
3067     else if (str == i18n("Disconnected"))
3068         return GUIDE_DISCONNECTED;
3069     else if (str == i18n("Capturing"))
3070         return GUIDE_CAPTURE;
3071     else if (str == i18n("Looping"))
3072         return GUIDE_LOOPING;
3073     else if (str == i18n("Subtracting"))
3074         return GUIDE_DARK;
3075     else if (str == i18n("Subframing"))
3076         return GUIDE_SUBFRAME;
3077     else if (str == i18n("Selecting star"))
3078         return GUIDE_STAR_SELECT;
3079     else if (str == i18n("Calibrating"))
3080         return GUIDE_CALIBRATING;
3081     else if (str == i18n("Calibration error"))
3082         return GUIDE_CALIBRATION_ERROR;
3083     else if (str == i18n("Calibrated"))
3084         return GUIDE_CALIBRATION_SUCCESS;
3085     else if (str == i18n("Guiding"))
3086         return GUIDE_GUIDING;
3087     else if (str == i18n("Suspended"))
3088         return GUIDE_SUSPENDED;
3089     else if (str == i18n("Reacquiring"))
3090         return GUIDE_REACQUIRE;
3091     else if (str == i18n("Dithering"))
3092         return GUIDE_DITHERING;
3093     else if (str == i18n("Manual Dithering"))
3094         return GUIDE_MANUAL_DITHERING;
3095     else if (str == i18n("Dithering error"))
3096         return GUIDE_DITHERING_ERROR;
3097     else if (str == i18n("Dithering successful"))
3098         return GUIDE_DITHERING_SUCCESS;
3099     else if (str == i18n("Settling"))
3100         return GUIDE_DITHERING_SETTLE;
3101     else
3102         return GUIDE_IDLE;
3103 }
3104 
3105 Analyze::SimpleGuideState convertGuideState(Ekos::GuideState state)
3106 {
3107     switch (state)
3108     {
3109         case GUIDE_IDLE:
3110         case GUIDE_ABORTED:
3111         case GUIDE_CONNECTED:
3112         case GUIDE_DISCONNECTED:
3113         case GUIDE_LOOPING:
3114             return Analyze::G_IDLE;
3115         case GUIDE_GUIDING:
3116             return Analyze::G_GUIDING;
3117         case GUIDE_CAPTURE:
3118         case GUIDE_DARK:
3119         case GUIDE_SUBFRAME:
3120         case GUIDE_STAR_SELECT:
3121             return Analyze::G_IGNORE;
3122         case GUIDE_CALIBRATING:
3123         case GUIDE_CALIBRATION_ERROR:
3124         case GUIDE_CALIBRATION_SUCCESS:
3125             return Analyze::G_CALIBRATING;
3126         case GUIDE_SUSPENDED:
3127         case GUIDE_REACQUIRE:
3128             return Analyze::G_SUSPENDED;
3129         case GUIDE_DITHERING:
3130         case GUIDE_MANUAL_DITHERING:
3131         case GUIDE_DITHERING_ERROR:
3132         case GUIDE_DITHERING_SUCCESS:
3133         case GUIDE_DITHERING_SETTLE:
3134             return Analyze::G_DITHERING;
3135     }
3136     // Shouldn't get here--would get compile error, I believe with a missing case.
3137     return Analyze::G_IDLE;
3138 }
3139 
3140 const QBrush guideBrush(Analyze::SimpleGuideState simpleState)
3141 {
3142     switch (simpleState)
3143     {
3144         case Analyze::G_IDLE:
3145         case Analyze::G_IGNORE:
3146             // don't actually render these, so don't care.
3147             return offBrush;
3148         case Analyze::G_GUIDING:
3149             return successBrush;
3150         case Analyze::G_CALIBRATING:
3151             return progressBrush;
3152         case Analyze::G_SUSPENDED:
3153             return stoppedBrush;
3154         case Analyze::G_DITHERING:
3155             return progress2Brush;
3156     }
3157     // Shouldn't get here.
3158     return offBrush;
3159 }
3160 
3161 }  // namespace
3162 
3163 void Analyze::guideState(Ekos::GuideState state)
3164 {
3165     QString str = getGuideStatusString(state);
3166     saveMessage("GuideState", str);
3167     if (runtimeDisplay)
3168         processGuideState(logTime(), str);
3169 }
3170 
3171 void Analyze::processGuideState(double time, const QString &stateStr, bool batchMode)
3172 {
3173     Ekos::GuideState gstate = stringToGuideState(stateStr);
3174     SimpleGuideState state = convertGuideState(gstate);
3175     if (state == G_IGNORE)
3176         return;
3177     if (state == lastGuideStateStarted)
3178         return;
3179     // End the previous guide session and start the new one.
3180     if (guideStateStartedTime >= 0)
3181     {
3182         if (lastGuideStateStarted != G_IDLE)
3183         {
3184             // Don't render the idle guiding
3185             addSession(guideStateStartedTime, time, GUIDE_Y, guideBrush(lastGuideStateStarted));
3186             guideSessions.add(GuideSession(guideStateStartedTime, time, nullptr, lastGuideStateStarted));
3187         }
3188     }
3189     if (state == G_GUIDING)
3190     {
3191         addTemporarySession(&temporaryGuideSession, time, 1, GUIDE_Y, successBrush);
3192         temporaryGuideSession.simpleState = state;
3193     }
3194     else
3195         removeTemporarySession(&temporaryGuideSession);
3196 
3197     guideStateStartedTime = time;
3198     lastGuideStateStarted = state;
3199     updateMaxX(time);
3200     if (!batchMode)
3201         replot();
3202 }
3203 
3204 void Analyze::resetGuideState()
3205 {
3206     lastGuideStateStarted = G_IDLE;
3207     guideStateStartedTime = -1;
3208 }
3209 
3210 void Analyze::newTemperature(double temperatureDelta, double temperature)
3211 {
3212     Q_UNUSED(temperatureDelta);
3213     if (temperature > -200 && temperature != lastTemperature)
3214     {
3215         saveMessage("Temperature", QString("%1").arg(QString::number(temperature, 'f', 3)));
3216         lastTemperature = temperature;
3217         if (runtimeDisplay)
3218             processTemperature(logTime(), temperature);
3219     }
3220 }
3221 
3222 void Analyze::processTemperature(double time, double temperature, bool batchMode)
3223 {
3224     addTemperature(temperature, time);
3225     updateMaxX(time);
3226     if (!batchMode)
3227         replot();
3228 }
3229 
3230 void Analyze::resetTemperature()
3231 {
3232     lastTemperature = -1000;
3233 }
3234 
3235 void Analyze::newTargetDistance(double targetDistance)
3236 {
3237     saveMessage("TargetDistance", QString("%1").arg(QString::number(targetDistance, 'f', 0)));
3238     if (runtimeDisplay)
3239         processTargetDistance(logTime(), targetDistance);
3240 }
3241 
3242 void Analyze::processTargetDistance(double time, double targetDistance, bool batchMode)
3243 {
3244     addTargetDistance(targetDistance, time);
3245     updateMaxX(time);
3246     if (!batchMode)
3247         replot();
3248 }
3249 
3250 void Analyze::guideStats(double raError, double decError, int raPulse, int decPulse,
3251                          double snr, double skyBg, int numStars)
3252 {
3253     saveMessage("GuideStats", QString("%1,%2,%3,%4,%5,%6,%7")
3254                 .arg(QString::number(raError, 'f', 3), QString::number(decError, 'f', 3))
3255                 .arg(raPulse)
3256                 .arg(decPulse)
3257                 .arg(QString::number(snr, 'f', 3), QString::number(skyBg, 'f', 3))
3258                 .arg(numStars));
3259 
3260     if (runtimeDisplay)
3261         processGuideStats(logTime(), raError, decError, raPulse, decPulse, snr, skyBg, numStars);
3262 }
3263 
3264 void Analyze::processGuideStats(double time, double raError, double decError,
3265                                 int raPulse, int decPulse, double snr, double skyBg, int numStars, bool batchMode)
3266 {
3267     addGuideStats(raError, decError, raPulse, decPulse, snr, numStars, skyBg, time);
3268     updateMaxX(time);
3269     if (!batchMode)
3270         replot();
3271 }
3272 
3273 void Analyze::resetGuideStats()
3274 {
3275     lastGuideStatsTime = -1;
3276     lastCaptureRmsTime = -1;
3277     numStarsMax = 0;
3278     snrMax = 0;
3279     skyBgMax = 0;
3280 }
3281 
3282 namespace
3283 {
3284 
3285 // TODO: move to ekos.h/cpp
3286 AlignState convertAlignState(const QString &str)
3287 {
3288     for (int i = 0; i < alignStates.size(); ++i)
3289     {
3290         if (str == i18n(alignStates[i]))
3291             return static_cast<AlignState>(i);
3292     }
3293     return ALIGN_IDLE;
3294 }
3295 
3296 const QBrush alignBrush(AlignState state)
3297 {
3298     switch (state)
3299     {
3300         case ALIGN_IDLE:
3301             return offBrush;
3302         case ALIGN_COMPLETE:
3303         case ALIGN_SUCCESSFUL:
3304             return successBrush;
3305         case ALIGN_FAILED:
3306             return failureBrush;
3307         case ALIGN_PROGRESS:
3308             return progress3Brush;
3309         case ALIGN_SYNCING:
3310             return progress2Brush;
3311         case ALIGN_SLEWING:
3312             return progressBrush;
3313         case ALIGN_ROTATING:
3314             return progress4Brush;
3315         case ALIGN_ABORTED:
3316             return failureBrush;
3317         case ALIGN_SUSPENDED:
3318             return offBrush;
3319     }
3320     // Shouldn't get here.
3321     return offBrush;
3322 }
3323 }  // namespace
3324 
3325 void Analyze::alignState(AlignState state)
3326 {
3327     if (state == lastAlignStateReceived)
3328         return;
3329     lastAlignStateReceived = state;
3330 
3331     QString stateStr = getAlignStatusString(state);
3332     saveMessage("AlignState", stateStr);
3333     if (runtimeDisplay)
3334         processAlignState(logTime(), stateStr);
3335 }
3336 
3337 //ALIGN_IDLE, ALIGN_COMPLETE, ALIGN_FAILED, ALIGN_ABORTED,ALIGN_PROGRESS,ALIGN_SYNCING,ALIGN_SLEWING
3338 void Analyze::processAlignState(double time, const QString &statusString, bool batchMode)
3339 {
3340     AlignState state = convertAlignState(statusString);
3341 
3342     if (state == lastAlignStateStarted)
3343         return;
3344 
3345     bool lastStateInteresting = (lastAlignStateStarted == ALIGN_PROGRESS ||
3346                                  lastAlignStateStarted == ALIGN_SYNCING ||
3347                                  lastAlignStateStarted == ALIGN_SLEWING);
3348     if (lastAlignStateStartedTime >= 0 && lastStateInteresting)
3349     {
3350         if (state == ALIGN_COMPLETE || state == ALIGN_FAILED || state == ALIGN_ABORTED)
3351         {
3352             // These states are really commetaries on the previous states.
3353             addSession(lastAlignStateStartedTime, time, ALIGN_Y, alignBrush(state));
3354             alignSessions.add(AlignSession(lastAlignStateStartedTime, time, nullptr, state));
3355         }
3356         else
3357         {
3358             addSession(lastAlignStateStartedTime, time, ALIGN_Y, alignBrush(lastAlignStateStarted));
3359             alignSessions.add(AlignSession(lastAlignStateStartedTime, time, nullptr, lastAlignStateStarted));
3360         }
3361     }
3362     bool stateInteresting = (state == ALIGN_PROGRESS || state == ALIGN_SYNCING ||
3363                              state == ALIGN_SLEWING);
3364     if (stateInteresting)
3365     {
3366         addTemporarySession(&temporaryAlignSession, time, 1, ALIGN_Y, temporaryBrush);
3367         temporaryAlignSession.state = state;
3368     }
3369     else
3370         removeTemporarySession(&temporaryAlignSession);
3371 
3372     lastAlignStateStartedTime = time;
3373     lastAlignStateStarted = state;
3374     updateMaxX(time);
3375     if (!batchMode)
3376         replot();
3377 
3378 }
3379 
3380 void Analyze::resetAlignState()
3381 {
3382     lastAlignStateReceived = ALIGN_IDLE;
3383     lastAlignStateStarted = ALIGN_IDLE;
3384     lastAlignStateStartedTime = -1;
3385 }
3386 
3387 namespace
3388 {
3389 
3390 const QBrush mountBrush(ISD::Mount::Status state)
3391 {
3392     switch (state)
3393     {
3394         case ISD::Mount::MOUNT_IDLE:
3395             return offBrush;
3396         case ISD::Mount::MOUNT_ERROR:
3397             return failureBrush;
3398         case ISD::Mount::MOUNT_MOVING:
3399         case ISD::Mount::MOUNT_SLEWING:
3400             return progressBrush;
3401         case ISD::Mount::MOUNT_TRACKING:
3402             return successBrush;
3403         case ISD::Mount::MOUNT_PARKING:
3404             return stoppedBrush;
3405         case ISD::Mount::MOUNT_PARKED:
3406             return stopped2Brush;
3407     }
3408     // Shouldn't get here.
3409     return offBrush;
3410 }
3411 
3412 }  // namespace
3413 
3414 // Mount status can be:
3415 // MOUNT_IDLE, MOUNT_MOVING, MOUNT_SLEWING, MOUNT_TRACKING, MOUNT_PARKING, MOUNT_PARKED, MOUNT_ERROR
3416 void Analyze::mountState(ISD::Mount::Status state)
3417 {
3418     QString statusString = mountStatusString(state);
3419     saveMessage("MountState", statusString);
3420     if (runtimeDisplay)
3421         processMountState(logTime(), statusString);
3422 }
3423 
3424 void Analyze::processMountState(double time, const QString &statusString, bool batchMode)
3425 {
3426     ISD::Mount::Status state = toMountStatus(statusString);
3427     if (mountStateStartedTime >= 0 && lastMountState != ISD::Mount::MOUNT_IDLE)
3428     {
3429         addSession(mountStateStartedTime, time, MOUNT_Y, mountBrush(lastMountState));
3430         mountSessions.add(MountSession(mountStateStartedTime, time, nullptr, lastMountState));
3431     }
3432 
3433     if (state != ISD::Mount::MOUNT_IDLE)
3434     {
3435         addTemporarySession(&temporaryMountSession, time, 1, MOUNT_Y,
3436                             (state == ISD::Mount::MOUNT_TRACKING) ? successBrush : temporaryBrush);
3437         temporaryMountSession.state = state;
3438     }
3439     else
3440         removeTemporarySession(&temporaryMountSession);
3441 
3442     mountStateStartedTime = time;
3443     lastMountState = state;
3444     updateMaxX(time);
3445     if (!batchMode)
3446         replot();
3447 }
3448 
3449 void Analyze::resetMountState()
3450 {
3451     mountStateStartedTime = -1;
3452     lastMountState = ISD::Mount::Status::MOUNT_IDLE;
3453 }
3454 
3455 // This message comes from the mount module
3456 void Analyze::mountCoords(const SkyPoint &position, ISD::Mount::PierSide pierSide, const dms &haValue)
3457 {
3458     double ra = position.ra().Degrees();
3459     double dec = position.dec().Degrees();
3460     double ha = haValue.Degrees();
3461     double az = position.az().Degrees();
3462     double alt = position.alt().Degrees();
3463 
3464     // Only process the message if something's changed by 1/4 degree or more.
3465     constexpr double MIN_DEGREES_CHANGE = 0.25;
3466     if ((fabs(ra - lastMountRa) > MIN_DEGREES_CHANGE) ||
3467             (fabs(dec - lastMountDec) > MIN_DEGREES_CHANGE) ||
3468             (fabs(ha - lastMountHa) > MIN_DEGREES_CHANGE) ||
3469             (fabs(az - lastMountAz) > MIN_DEGREES_CHANGE) ||
3470             (fabs(alt - lastMountAlt) > MIN_DEGREES_CHANGE) ||
3471             (pierSide != lastMountPierSide))
3472     {
3473         saveMessage("MountCoords", QString("%1,%2,%3,%4,%5,%6")
3474                     .arg(QString::number(ra, 'f', 4), QString::number(dec, 'f', 4),
3475                          QString::number(az, 'f', 4), QString::number(alt, 'f', 4))
3476                     .arg(pierSide)
3477                     .arg(QString::number(ha, 'f', 4)));
3478 
3479         if (runtimeDisplay)
3480             processMountCoords(logTime(), ra, dec, az, alt, pierSide, ha);
3481 
3482         lastMountRa = ra;
3483         lastMountDec = dec;
3484         lastMountHa = ha;
3485         lastMountAz = az;
3486         lastMountAlt = alt;
3487         lastMountPierSide = pierSide;
3488     }
3489 }
3490 
3491 void Analyze::processMountCoords(double time, double ra, double dec, double az,
3492                                  double alt, int pierSide, double ha, bool batchMode)
3493 {
3494     addMountCoords(ra, dec, az, alt, pierSide, ha, time);
3495     updateMaxX(time);
3496     if (!batchMode)
3497         replot();
3498 }
3499 
3500 void Analyze::resetMountCoords()
3501 {
3502     lastMountRa = -1;
3503     lastMountDec = -1;
3504     lastMountHa = -1;
3505     lastMountAz = -1;
3506     lastMountAlt = -1;
3507     lastMountPierSide = -1;
3508 }
3509 
3510 namespace
3511 {
3512 
3513 // TODO: Move to mount.h/cpp?
3514 MeridianFlipState::MeridianFlipMountState convertMountFlipState(const QString &statusStr)
3515 {
3516     if (statusStr == "MOUNT_FLIP_NONE")
3517         return MeridianFlipState::MOUNT_FLIP_NONE;
3518     else if (statusStr == "MOUNT_FLIP_PLANNED")
3519         return MeridianFlipState::MOUNT_FLIP_PLANNED;
3520     else if (statusStr == "MOUNT_FLIP_WAITING")
3521         return MeridianFlipState::MOUNT_FLIP_WAITING;
3522     else if (statusStr == "MOUNT_FLIP_ACCEPTED")
3523         return MeridianFlipState::MOUNT_FLIP_ACCEPTED;
3524     else if (statusStr == "MOUNT_FLIP_RUNNING")
3525         return MeridianFlipState::MOUNT_FLIP_RUNNING;
3526     else if (statusStr == "MOUNT_FLIP_COMPLETED")
3527         return MeridianFlipState::MOUNT_FLIP_COMPLETED;
3528     else if (statusStr == "MOUNT_FLIP_ERROR")
3529         return MeridianFlipState::MOUNT_FLIP_ERROR;
3530     return MeridianFlipState::MOUNT_FLIP_ERROR;
3531 }
3532 
3533 QBrush mountFlipStateBrush(MeridianFlipState::MeridianFlipMountState state)
3534 {
3535     switch (state)
3536     {
3537         case MeridianFlipState::MOUNT_FLIP_NONE:
3538             return offBrush;
3539         case MeridianFlipState::MOUNT_FLIP_PLANNED:
3540             return stoppedBrush;
3541         case MeridianFlipState::MOUNT_FLIP_WAITING:
3542             return stopped2Brush;
3543         case MeridianFlipState::MOUNT_FLIP_ACCEPTED:
3544             return progressBrush;
3545         case MeridianFlipState::MOUNT_FLIP_RUNNING:
3546             return progress2Brush;
3547         case MeridianFlipState::MOUNT_FLIP_COMPLETED:
3548             return successBrush;
3549         case MeridianFlipState::MOUNT_FLIP_ERROR:
3550             return failureBrush;
3551     }
3552     // Shouldn't get here.
3553     return offBrush;
3554 }
3555 }  // namespace
3556 
3557 void Analyze::mountFlipStatus(MeridianFlipState::MeridianFlipMountState state)
3558 {
3559     if (state == lastMountFlipStateReceived)
3560         return;
3561     lastMountFlipStateReceived = state;
3562 
3563     QString stateStr = MeridianFlipState::meridianFlipStatusString(state);
3564     saveMessage("MeridianFlipState", stateStr);
3565     if (runtimeDisplay)
3566         processMountFlipState(logTime(), stateStr);
3567 
3568 }
3569 
3570 // MeridianFlipState::MOUNT_FLIP_NONE MeridianFlipState::MOUNT_FLIP_PLANNED MeridianFlipState::MOUNT_FLIP_WAITING MeridianFlipState::MOUNT_FLIP_ACCEPTED MeridianFlipState::MOUNT_FLIP_RUNNING MeridianFlipState::MOUNT_FLIP_COMPLETED MeridianFlipState::MOUNT_FLIP_ERROR
3571 void Analyze::processMountFlipState(double time, const QString &statusString, bool batchMode)
3572 {
3573     MeridianFlipState::MeridianFlipMountState state = convertMountFlipState(statusString);
3574     if (state == lastMountFlipStateStarted)
3575         return;
3576 
3577     bool lastStateInteresting =
3578         (lastMountFlipStateStarted == MeridianFlipState::MOUNT_FLIP_PLANNED ||
3579          lastMountFlipStateStarted == MeridianFlipState::MOUNT_FLIP_WAITING ||
3580          lastMountFlipStateStarted == MeridianFlipState::MOUNT_FLIP_ACCEPTED ||
3581          lastMountFlipStateStarted == MeridianFlipState::MOUNT_FLIP_RUNNING);
3582     if (mountFlipStateStartedTime >= 0 && lastStateInteresting)
3583     {
3584         if (state == MeridianFlipState::MOUNT_FLIP_COMPLETED || state == MeridianFlipState::MOUNT_FLIP_ERROR)
3585         {
3586             // These states are really commentaries on the previous states.
3587             addSession(mountFlipStateStartedTime, time, MERIDIAN_MOUNT_FLIP_Y, mountFlipStateBrush(state));
3588             mountFlipSessions.add(MountFlipSession(mountFlipStateStartedTime, time, nullptr, state));
3589         }
3590         else
3591         {
3592             addSession(mountFlipStateStartedTime, time, MERIDIAN_MOUNT_FLIP_Y, mountFlipStateBrush(lastMountFlipStateStarted));
3593             mountFlipSessions.add(MountFlipSession(mountFlipStateStartedTime, time, nullptr, lastMountFlipStateStarted));
3594         }
3595     }
3596     bool stateInteresting =
3597         (state == MeridianFlipState::MOUNT_FLIP_PLANNED ||
3598          state == MeridianFlipState::MOUNT_FLIP_WAITING ||
3599          state == MeridianFlipState::MOUNT_FLIP_ACCEPTED ||
3600          state == MeridianFlipState::MOUNT_FLIP_RUNNING);
3601     if (stateInteresting)
3602     {
3603         addTemporarySession(&temporaryMountFlipSession, time, 1, MERIDIAN_MOUNT_FLIP_Y, temporaryBrush);
3604         temporaryMountFlipSession.state = state;
3605     }
3606     else
3607         removeTemporarySession(&temporaryMountFlipSession);
3608 
3609     mountFlipStateStartedTime = time;
3610     lastMountFlipStateStarted = state;
3611     updateMaxX(time);
3612     if (!batchMode)
3613         replot();
3614 }
3615 
3616 void Analyze::resetMountFlipState()
3617 {
3618     lastMountFlipStateReceived = MeridianFlipState::MOUNT_FLIP_NONE;
3619     lastMountFlipStateStarted = MeridianFlipState::MOUNT_FLIP_NONE;
3620     mountFlipStateStartedTime = -1;
3621 }
3622 
3623 QBrush Analyze::schedulerJobBrush(const QString &jobName, bool temporary)
3624 {
3625     QList<QColor> colors =
3626     {
3627         {110, 120, 150}, {150, 180, 180}, {180, 165, 130}, {180, 200, 140}, {250, 180, 130},
3628         {190, 170, 160}, {140, 110, 160}, {250, 240, 190}, {250, 200, 220}, {150, 125, 175}
3629     };
3630 
3631     Qt::BrushStyle pattern = temporary ? Qt::Dense4Pattern : Qt::SolidPattern;
3632     auto it = schedulerJobColors.constFind(jobName);
3633     if (it == schedulerJobColors.constEnd())
3634     {
3635         const int numSoFar = schedulerJobColors.size();
3636         auto color = colors[numSoFar % colors.size()];
3637         schedulerJobColors[jobName] = color;
3638         return QBrush(color, pattern);
3639     }
3640     else
3641     {
3642         return QBrush(*it, pattern);
3643     }
3644 }
3645 
3646 void Analyze::schedulerJobStarted(const QString &jobName)
3647 {
3648     saveMessage("SchedulerJobStart", jobName);
3649     if (runtimeDisplay)
3650         processSchedulerJobStarted(logTime(), jobName);
3651 
3652 }
3653 
3654 void Analyze::schedulerJobEnded(const QString &jobName, const QString &reason)
3655 {
3656     saveMessage("SchedulerJobEnd", QString("%1,%2").arg(jobName, reason));
3657     if (runtimeDisplay)
3658         processSchedulerJobEnded(logTime(), jobName, reason);
3659 }
3660 
3661 
3662 // Called by either the above (when live data is received), or reading from file.
3663 // BatchMode would be true when reading from file.
3664 void Analyze::processSchedulerJobStarted(double time, const QString &jobName)
3665 {
3666     checkForMissingSchedulerJobEnd(time - 1);
3667     schedulerJobStartedTime = time;
3668     schedulerJobStartedJobName = jobName;
3669     updateMaxX(time);
3670 
3671     addTemporarySession(&temporarySchedulerJobSession, time, 1, SCHEDULER_Y, schedulerJobBrush(jobName, true));
3672     temporarySchedulerJobSession.jobName = jobName;
3673 }
3674 
3675 // Called when the captureComplete slot receives a signal.
3676 void Analyze::processSchedulerJobEnded(double time, const QString &jobName, const QString &reason, bool batchMode)
3677 {
3678     removeTemporarySession(&temporarySchedulerJobSession);
3679 
3680     if (schedulerJobStartedTime < 0)
3681     {
3682         replot();
3683         return;
3684     }
3685 
3686     addSession(schedulerJobStartedTime, time, SCHEDULER_Y, schedulerJobBrush(jobName, false));
3687     auto session = SchedulerJobSession(schedulerJobStartedTime, time, nullptr, jobName, reason);
3688     schedulerJobSessions.add(session);
3689     updateMaxX(time);
3690     resetSchedulerJob();
3691     if (!batchMode)
3692         replot();
3693 }
3694 
3695 // Just called in batch mode, in case the processSchedulerJobEnded was never called.
3696 void Analyze::checkForMissingSchedulerJobEnd(double time)
3697 {
3698     if (schedulerJobStartedTime < 0)
3699         return;
3700     removeTemporarySession(&temporarySchedulerJobSession);
3701     addSession(schedulerJobStartedTime, time, SCHEDULER_Y, schedulerJobBrush(schedulerJobStartedJobName, false));
3702     auto session = SchedulerJobSession(schedulerJobStartedTime, time, nullptr, schedulerJobStartedJobName, "missing job end");
3703     schedulerJobSessions.add(session);
3704     updateMaxX(time);
3705     resetSchedulerJob();
3706 }
3707 
3708 void Analyze::resetSchedulerJob()
3709 {
3710     schedulerJobStartedTime = -1;
3711     schedulerJobStartedJobName = "";
3712 }
3713 
3714 void Analyze::appendLogText(const QString &text)
3715 {
3716     m_LogText.insert(0, i18nc("log entry; %1 is the date, %2 is the text", "%1 %2",
3717                               KStarsData::Instance()->lt().toString("yyyy-MM-ddThh:mm:ss"), text));
3718 
3719     qCInfo(KSTARS_EKOS_ANALYZE) << text;
3720 
3721     emit newLog(text);
3722 }
3723 
3724 void Analyze::clearLog()
3725 {
3726     m_LogText.clear();
3727     emit newLog(QString());
3728 }
3729 
3730 }  // namespace Ekos