[education/kstars] /: Changed the help popup so that it scrolls. Added a way to input an alternate directory for FITS files.

Jasem Mutlaq null at kde.org
Thu Aug 27 10:01:48 BST 2020


Git commit 7b45612a971b51e53b5b55ebb2719dbfc2bcf22a by Jasem Mutlaq, on behalf of Hy Murveit.
Committed on 27/08/2020 at 09:01.
Pushed by mutlaqja into branch 'master'.

Changed the help popup so that it scrolls. Added a way to input an alternate directory for FITS files.

A  +42   -0    doc/ekos-analyze.docbook
M  +1    -0    doc/ekos.docbook
A  +-    --    doc/ekos_analyze.png
M  +1    -0    doc/index.docbook
M  +6    -0    kstars/CMakeLists.txt
A  +-    --    kstars/data/icons/ekos_analyze.png
M  +1    -0    kstars/data/kstars.qrc
A  +2599 -0    kstars/ekos/analyze/analyze.cpp     [License: GPL (v2+)]
A  +473  -0    kstars/ekos/analyze/analyze.h     [License: GPL (v2+)]
A  +851  -0    kstars/ekos/analyze/analyze.ui
M  +6    -0    kstars/ekos/capture/capture.cpp
M  +6    -0    kstars/ekos/capture/capture.h
M  +33   -0    kstars/ekos/focus/focus.cpp
M  +10   -0    kstars/ekos/focus/focus.h
M  +4    -0    kstars/ekos/guide/externalguide/phd2.cpp
M  +4    -1    kstars/ekos/guide/guide.cpp
M  +5    -1    kstars/ekos/guide/guide.h
M  +2    -1    kstars/ekos/guide/guideinterface.h
M  +25   -0    kstars/ekos/guide/internalguide/gmath.cpp
M  +7    -0    kstars/ekos/guide/internalguide/gmath.h
M  +1    -0    kstars/ekos/guide/internalguide/internalguider.cpp
M  +81   -14   kstars/ekos/manager.cpp
M  +7    -1    kstars/ekos/manager.h
M  +2    -1    kstars/ekos/mount/mount.cpp
M  +2    -2    kstars/ekos/mount/mount.h
M  +20   -6    kstars/fitsviewer/fitsdata.cpp
M  +13   -3    kstars/fitsviewer/fitssepdetector.cpp
M  +65   -55   kstars/fitsviewer/fitssepdetector.h
M  +13   -0    kstars/fitsviewer/opsfits.ui
M  +70   -0    kstars/kstars.kcfg

https://invent.kde.org/education/kstars/commit/7b45612a971b51e53b5b55ebb2719dbfc2bcf22a

diff --git a/doc/ekos-analyze.docbook b/doc/ekos-analyze.docbook
new file mode 100644
index 000000000..2c839ba59
--- /dev/null
+++ b/doc/ekos-analyze.docbook
@@ -0,0 +1,42 @@
+<sect2 id="ekos-analyze">
+    <title>Analyze</title>
+    <indexterm>
+        <primary>Tools</primary>
+        <secondary>Ekos</secondary>
+        <tertiary>Analyze</tertiary>
+    </indexterm>    
+    <screenshot>
+        <screeninfo>
+            Ekos Analyze Module
+        </screeninfo>
+        <mediaobject>
+            <imageobject>
+                <imagedata fileref="ekos_analyze.png" format="PNG"/>
+            </imageobject>
+            <textobject>
+                <phrase>Ekos Analyze Module</phrase>
+            </textobject>
+        </mediaobject>
+    </screenshot>
+    <sect3 id="analyze-Introduction">
+        <title>Introduction</title>
+        <para>
+          The Analyze Module records and displays what happened in an imaging session. That is, it does not control any if your imaging, but rather reviews what occurred. Sessions are stored in an <guilabel>analyze</guilabel> folder, a sister folder to the main logging folder. The .analyze files written there can be loaded into the Analyze tab to be viewed. Analyze also can display data from the current imaging session.
+        </para>
+        <para>
+          There are two main graphs, Timeline and Stats. They are coordinated--they always display the same time interval from the Ekos session, though the x-axis of the Timeline shows seconds elapsed from the start of the log, and Stats shows clock time. The x-axis can be zoomed in and out with the +/- button, mouse wheel, as well as with standard keyboard shortcuts (e.g. zoom-in == control +) The x-axis can be panned with the scroll bar as well as with the left and right arrow keys. You can view your current imaging session, or review old sessions by loading .analyze files using the <guilabel>Input</guilabel> dropdown. Checking <guilabel>Full Width</guilabel> displays all the data, and <guilabel>Latest</guilabel> displays the most recent data (you can control the width by zooming).
+        </para>
+    </sect3>
+    <sect3 id="analyze-timeline">
+        <title>Timeline</title>
+        <para>
+        Timeline shows the major Ekos processes, and when they were active. For intance, the <guilabel>Capture</guilabel> line shows when images were taken (green sections) and when imaging was aborted (red sections). Clicking on a green section gives information about that image, and double clicking on one brings up the image taken then in a fitsviewer, if it's available. [Note: if you've moved your captured images, you can set <guilabel>alternate directory</guilabel> in the input menu to a directory which is the base of part of the original file path.] Clicking on a <guilabel>Focus</guilabel> segment shows focus session information and displays up the position vs HFR measurements from that session. Clicking on a <guilabel>Guider</guilabel> segment shows a drift plot from that session, (if it's guiding) and the session's RMS statistics. Other timelines show status information when clicked. 
+        </para>
+    </sect3>
+    <sect3 id="analyze-statistics">
+        <title>Statistics</title>
+        <para>
+        A variety of statistics can be displayed on the Stats graph. There are too many for all to be shown in a readable way, so select among them with the checkboxes. A reasonable way to start might be to use <guilabel>rms</guilabel>, <guilabel>snr</guilabel> (using the internal guider with SEP Multistar), and <guilabel>hfr</guilabel> (if you have auto-compute HFR in the FITS options). Experiment with others.  The axis shown (0-5) is appropriate only for ra/dec error, drift, rms, pulses, and hfr. These may be y-axis scaled (awkwardly) using the mouse wheel, but the other graphs cannot be scaled. To reset y-axis zooming, right-click on the Stats plot. Clicking on the graph fills in the values of the displayed statistics. This graph is zoomed and panned horizontally in coordination with the timeline. 
+        </para>
+    </sect3>
+</sect2>
diff --git a/doc/ekos.docbook b/doc/ekos.docbook
index 166de0a37..50d79ccc5 100644
--- a/doc/ekos.docbook
+++ b/doc/ekos.docbook
@@ -137,6 +137,7 @@
 &tool-ekos-guide;
 &tool-ekos-align;
 &tool-ekos-scheduler;
+&tool-ekos-analyze;
 &tool-ekos-tutorials;
 
 </sect1>
diff --git a/doc/ekos_analyze.png b/doc/ekos_analyze.png
new file mode 100644
index 000000000..988772789
Binary files /dev/null and b/doc/ekos_analyze.png differ
diff --git a/doc/index.docbook b/doc/index.docbook
index 73c83edaf..8e8dfa041 100644
--- a/doc/index.docbook
+++ b/doc/index.docbook
@@ -66,6 +66,7 @@
   <!ENTITY tool-ekos-profile-editor SYSTEM "ekos-profile-editor.docbook">
   <!ENTITY tool-ekos-profile-wizard SYSTEM "ekos-profile-wizard.docbook">
   <!ENTITY tool-ekos-scheduler SYSTEM "ekos-scheduler.docbook">
+  <!ENTITY tool-ekos-analyze SYSTEM "ekos-analyze.docbook">
   <!ENTITY tool-ekos-setup SYSTEM "ekos-setup.docbook">
   <!ENTITY tool-ekos-tutorials SYSTEM "ekos-tutorials.docbook">
   <!ENTITY tool-ekos-user-interface SYSTEM "ekos-user-interface.docbook">
diff --git a/kstars/CMakeLists.txt b/kstars/CMakeLists.txt
index b34d0449c..6bcd4d93d 100644
--- a/kstars/CMakeLists.txt
+++ b/kstars/CMakeLists.txt
@@ -102,6 +102,8 @@ if (INDI_FOUND)
             ekos/manager.ui
             ekos/profileeditor.ui
             ekos/profilewizard.ui
+            # Analyze
+            ekos/analyze/analyze.ui
             # Scheduler
             ekos/scheduler/scheduler.ui
             ekos/scheduler/mosaic.ui
@@ -168,6 +170,9 @@ if (INDI_FOUND)
             ekos/capture/rotatorsettings.cpp
             ekos/capture/customproperties.cpp
 
+            # Analyze
+            ekos/analyze/analyze.cpp
+
             # Scheduler
             ekos/scheduler/schedulerjob.cpp
             ekos/scheduler/scheduler.cpp
@@ -982,6 +987,7 @@ ecm_qt_declare_logging_category(kstars_SRCS HEADER ekos_align_debug.h IDENTIFIER
 ecm_qt_declare_logging_category(kstars_SRCS HEADER ekos_guide_debug.h IDENTIFIER KSTARS_EKOS_GUIDE CATEGORY_NAME org.kde.kstars.ekos.guide)
 ecm_qt_declare_logging_category(kstars_SRCS HEADER ekos_mount_debug.h IDENTIFIER KSTARS_EKOS_MOUNT CATEGORY_NAME org.kde.kstars.ekos.mount)
 ecm_qt_declare_logging_category(kstars_SRCS HEADER ekos_scheduler_debug.h IDENTIFIER KSTARS_EKOS_SCHEDULER CATEGORY_NAME org.kde.kstars.ekos.scheduler)
+ecm_qt_declare_logging_category(kstars_SRCS HEADER ekos_analyze_debug.h IDENTIFIER KSTARS_EKOS_ANALYZE CATEGORY_NAME org.kde.kstars.ekos.analyze)
 ecm_qt_declare_logging_category(kstars_SRCS HEADER ekos_observatory_debug.h IDENTIFIER KSTARS_EKOS_OBSERVATORY CATEGORY_NAME org.kde.kstars.ekos.observatory)
 
 kconfig_add_kcfg_files(kstars_SRCS ${kstars_KCFG_SRCS})
diff --git a/kstars/data/icons/ekos_analyze.png b/kstars/data/icons/ekos_analyze.png
new file mode 100644
index 000000000..9808e8f75
Binary files /dev/null and b/kstars/data/icons/ekos_analyze.png differ
diff --git a/kstars/data/kstars.qrc b/kstars/data/kstars.qrc
index 4b64f17b9..a12698e58 100644
--- a/kstars/data/kstars.qrc
+++ b/kstars/data/kstars.qrc
@@ -6,6 +6,7 @@
         <file>icons/ekos_guide.png</file>
         <file>icons/ekos_mount.png</file>
         <file>icons/ekos_scheduler.png</file>
+        <file>icons/ekos_analyze.png</file>
         <file>icons/ekos_setup.png</file>
         <file>icons/fov.png</file>
         <file>icons/histogram.png</file>
diff --git a/kstars/ekos/analyze/analyze.cpp b/kstars/ekos/analyze/analyze.cpp
new file mode 100644
index 000000000..f2070265d
--- /dev/null
+++ b/kstars/ekos/analyze/analyze.cpp
@@ -0,0 +1,2599 @@
+/*  Ekos Analyze
+    Copyright (C) 2020 Hy Murveit <hy at murveit.com>
+
+    This application is free software; you can redistribute it and/or
+    modify it under the terms of the GNU General Public
+    License as published by the Free Software Foundation; either
+    version 2 of the License, or (at your option) any later version.
+ */
+
+#include "analyze.h"
+
+#include <KNotifications/KNotification>
+#include <QDateTime>
+#include <QShortcut>
+#include <QtGlobal>
+
+#include "auxiliary/kspaths.h"
+#include "dms.h"
+#include "ekos/manager.h"
+#include "fitsviewer/fitsdata.h"
+#include "fitsviewer/fitsviewer.h"
+#include "ksmessagebox.h"
+#include "kstars.h"
+#include "Options.h"
+
+#include <ekos_analyze_debug.h>
+#include <KHelpClient>
+#include <version.h>
+
+// Subclass QCPAxisTickerDateTime, so that times are offset from the start
+// of the log, instead of being offset from the UNIX 0-seconds time.
+class OffsetDateTimeTicker : public QCPAxisTickerDateTime
+{
+    public:
+        void setOffset(double offset)
+        {
+            timeOffset = offset;
+        }
+        QString getTickLabel(double tick, const QLocale &locale, QChar formatChar, int precision) override
+        {
+            Q_UNUSED(precision);
+            Q_UNUSED(formatChar);
+            // Seconds are offset from the unix origin by
+            return locale.toString(keyToDateTime(tick + timeOffset).toTimeSpec(mDateTimeSpec), mDateTimeFormat);
+        }
+    private:
+        double timeOffset = 0;
+};
+
+namespace
+{
+
+// QDateTime is written to file with this format.
+QString timeFormat = "yyyy-MM-dd hh:mm:ss.zzz";
+
+// The resolution of the scroll bar.
+constexpr int MAX_SCROLL_VALUE = 10000;
+
+// Half the height of a timeline line.
+// That is timeline lines are horizontal bars along y=1 or y=2 ... and their
+// vertical widths are from y-halfTimelineHeight to y+halfTimelineHeight.
+constexpr double halfTimelineHeight = 0.35;
+
+// These are initialized in initStatsPlot when the graphs are added.
+// They index the graphs in statsPlot, e.g. statsPlot->graph(HFR_GRAPH)->addData(...)
+int HFR_GRAPH = -1;
+int NUMSTARS_GRAPH = -1;
+int SKYBG_GRAPH = -1;
+int SNR_GRAPH = -1;
+int RA_GRAPH = -1;
+int DEC_GRAPH = -1;
+int RA_PULSE_GRAPH = -1;
+int DEC_PULSE_GRAPH = -1;
+int DRIFT_GRAPH = -1;
+int RMS_GRAPH = -1;
+int MOUNT_RA_GRAPH = -1;
+int MOUNT_DEC_GRAPH = -1;
+int MOUNT_HA_GRAPH = -1;
+int AZ_GRAPH = -1;
+int ALT_GRAPH = -1;
+int PIER_SIDE_GRAPH = -1;
+
+// Initialized in initGraphicsPlot().
+int FOCUS_GRAPHICS = -1;
+int FOCUS_GRAPHICS_FINAL = -1;
+int GUIDER_GRAPHICS = -1;
+
+// Brushes used in the timeline plot.
+const QBrush temporaryBrush(Qt::green, Qt::DiagCrossPattern);
+const QBrush timelineSelectionBrush(QColor(255, 100, 100, 150), Qt::SolidPattern);
+const QBrush successBrush(Qt::green, Qt::SolidPattern);
+const QBrush failureBrush(Qt::red, Qt::SolidPattern);
+const QBrush offBrush(Qt::gray, Qt::SolidPattern);
+const QBrush progressBrush(Qt::blue, Qt::SolidPattern);
+const QBrush progress2Brush(QColor(0, 165, 255), Qt::SolidPattern);
+const QBrush progress3Brush(Qt::cyan, Qt::SolidPattern);
+const QBrush stoppedBrush(Qt::yellow, Qt::SolidPattern);
+const QBrush stopped2Brush(Qt::darkYellow, Qt::SolidPattern);
+
+// Utility to checks if a file exists and is not a directory.
+bool fileExists(const QString &path)
+{
+    QFileInfo info(path);
+    return info.exists() && info.isFile();
+}
+
+// Utilities to go between a mount status and a string.
+// Move to inditelescope.h/cpp?
+const QString mountStatusString(ISD::Telescope::Status status)
+{
+    switch (status)
+    {
+        case ISD::Telescope::MOUNT_IDLE:
+            return i18n("Idle");
+        case ISD::Telescope::MOUNT_PARKED:
+            return i18n("Parked");
+        case ISD::Telescope::MOUNT_PARKING:
+            return i18n("Parking");
+        case ISD::Telescope::MOUNT_SLEWING:
+            return i18n("Slewing");
+        case ISD::Telescope::MOUNT_MOVING:
+            return i18n("Moving");
+        case ISD::Telescope::MOUNT_TRACKING:
+            return i18n("Tracking");
+        case ISD::Telescope::MOUNT_ERROR:
+            return i18n("Error");
+    }
+    return i18n("Error");
+}
+
+ISD::Telescope::Status toMountStatus(const QString &str)
+{
+    if (str == i18n("Idle"))
+        return ISD::Telescope::MOUNT_IDLE;
+    else if (str == i18n("Parked"))
+        return ISD::Telescope::MOUNT_PARKED;
+    else if (str == i18n("Parking"))
+        return ISD::Telescope::MOUNT_PARKING;
+    else if (str == i18n("Slewing"))
+        return ISD::Telescope::MOUNT_SLEWING;
+    else if (str == i18n("Moving"))
+        return ISD::Telescope::MOUNT_MOVING;
+    else if (str == i18n("Tracking"))
+        return ISD::Telescope::MOUNT_TRACKING;
+    else
+        return ISD::Telescope::MOUNT_ERROR;
+}
+
+// Returns the stripe color used when drawing the capture timeline for various filters.
+// TODO: Not sure how to internationalize this.
+bool filterStripeBrush(const QString &filter, QBrush *brush)
+{
+    if (!filter.compare("red", Qt::CaseInsensitive) ||
+            !filter.compare("r", Qt::CaseInsensitive))
+    {
+        *brush = QBrush(Qt::red, Qt::SolidPattern);
+        return true;
+    }
+    else if (!filter.compare("green", Qt::CaseInsensitive) ||
+             !filter.compare("g", Qt::CaseInsensitive))
+    {
+        *brush = QBrush(Qt::green, Qt::SolidPattern);
+        return true;
+    }
+    else if (!filter.compare("blue", Qt::CaseInsensitive) ||
+             !filter.compare("b", Qt::CaseInsensitive))
+    {
+        *brush = QBrush(Qt::blue, Qt::SolidPattern);
+        return true;
+    }
+    else if (!filter.compare("ha", Qt::CaseInsensitive) ||
+             !filter.compare("h", Qt::CaseInsensitive) ||
+             !filter.compare("h_alpha", Qt::CaseInsensitive) ||
+             !filter.compare("halpha", Qt::CaseInsensitive))
+    {
+        *brush = QBrush(Qt::darkRed, Qt::SolidPattern);
+        return true;
+    }
+    else if (!filter.compare("oiii", Qt::CaseInsensitive) ||
+             !filter.compare("o3", Qt::CaseInsensitive))
+    {
+        *brush = QBrush(Qt::cyan, Qt::SolidPattern);
+        return true;
+    }
+    else if (!filter.compare("sii", Qt::CaseInsensitive) ||
+             !filter.compare("s2", Qt::CaseInsensitive))
+    {
+        // Pink.
+        *brush = QBrush(QColor(255, 182, 193), Qt::SolidPattern);
+        return true;
+    }
+    else if (!filter.compare("lpr", Qt::CaseInsensitive) ||
+             !filter.compare("L", Qt::CaseInsensitive) ||
+             !filter.compare("luminance", Qt::CaseInsensitive) ||
+             !filter.compare("lum", Qt::CaseInsensitive) ||
+             !filter.compare("lps", Qt::CaseInsensitive) ||
+             !filter.compare("cls", Qt::CaseInsensitive))
+    {
+        *brush = QBrush(Qt::white, Qt::SolidPattern);
+        return true;
+    }
+    return false;
+}
+
+// Used when searching for FITS files to display.
+// If filename isn't found as is, it tries alterateDirectory in several ways
+// e.g. if filename = /1/2/3/4/name is not found, then try alternateDirectory/name,
+// then alternateDirectory/4/name, then alternateDirectory/3/4/name,
+// then alternateDirectory/2/3/4/name, and so on.
+// If it cannot find the FITS file, it returns an empty string, otherwise it returns
+// the full path where the file was found.
+QString findFilename(const QString &filename, const QString &alternateDirectory)
+{
+    // Try the origial full path.
+    QFileInfo info(filename);
+    if (info.exists() && info.isFile())
+        return filename;
+
+    // Try putting the filename at the end of the full path onto alternateDirectory.
+    QString name = info.fileName();
+    QString temp = QString("%1/%2").arg(alternateDirectory).arg(name);
+    if (fileExists(temp))
+        return temp;
+
+    // Try appending the filename plus the ending directories onto alternateDirectory.
+    int size = filename.size();
+    int searchBackFrom = size - name.size();
+    int num = 0;
+    while (searchBackFrom >= 0)
+    {
+        int index = filename.lastIndexOf('/', searchBackFrom);
+        if (index < 0)
+            break;
+
+        QString temp2 = QString("%1%2").arg(alternateDirectory).arg(filename.right(size - index));
+        if (fileExists(temp2))
+            return temp2;
+
+        searchBackFrom = index - 1;
+
+        // Paranoia
+        if (++num > 20)
+            break;
+    }
+    return "";
+}
+
+// This is an exhaustive search for now.
+// This is reasonable as the number of sessions should be limited.
+template <class T>
+class IntervalFinder
+{
+    public:
+        IntervalFinder() {}
+        ~IntervalFinder() {}
+        void add(T value)
+        {
+            intervals.append(value);
+        }
+        void clear()
+        {
+            intervals.clear();
+        }
+        QList<T> find(double t)
+        {
+            QList<T> result;
+            for (const auto i : intervals)
+            {
+                if (t >= i.start && t <= i.end)
+                    result.push_back(i);
+            }
+            return result;
+        }
+    private:
+        QList<T> intervals;
+};
+
+IntervalFinder<Ekos::Analyze::CaptureSession> captureSessions;
+IntervalFinder<Ekos::Analyze::FocusSession> focusSessions;
+IntervalFinder<Ekos::Analyze::GuideSession> guideSessions;
+IntervalFinder<Ekos::Analyze::MountSession> mountSessions;
+IntervalFinder<Ekos::Analyze::AlignSession> alignSessions;
+IntervalFinder<Ekos::Analyze::MountFlipSession> mountFlipSessions;
+
+}  // namespace
+
+namespace Ekos
+{
+
+Analyze::Analyze()
+{
+    setupUi(this);
+
+    initRmsFilter();
+
+    alternateFolder = QDir::homePath();
+
+    initInputSelection();
+    initTimelinePlot();
+    initStatsPlot();
+    initGraphicsPlot();
+    fullWidthCB->setChecked(true);
+    runtimeDisplay = true;
+    fullWidthCB->setVisible(true);
+    fullWidthCB->setDisabled(false);
+    connect(fullWidthCB, &QCheckBox::toggled, [ = ](bool checked)
+    {
+        if (checked)
+            this->replot();
+    });
+
+    initStatsCheckboxes();
+
+    connect(zoomInB, &QPushButton::clicked, this, &Ekos::Analyze::zoomIn);
+    connect(zoomOutB, &QPushButton::clicked, this, &Ekos::Analyze::zoomOut);
+    connect(timelinePlot, &QCustomPlot::mousePress, this, &Ekos::Analyze::timelineMousePress);
+    connect(timelinePlot, &QCustomPlot::mouseDoubleClick, this, &Ekos::Analyze::timelineMouseDoubleClick);
+    connect(timelinePlot, &QCustomPlot::mouseWheel, this, &Ekos::Analyze::timelineMouseWheel);
+    connect(statsPlot, &QCustomPlot::mousePress, this, &Ekos::Analyze::statsMousePress);
+    connect(statsPlot, &QCustomPlot::mouseDoubleClick, this, &Ekos::Analyze::statsMouseDoubleClick);
+    connect(statsPlot, &QCustomPlot::mouseMove, this, &Ekos::Analyze::statsMouseMove);
+    connect(analyzeSB, &QScrollBar::valueChanged, this, &Ekos::Analyze::scroll);
+    analyzeSB->setRange(0, MAX_SCROLL_VALUE);
+    connect(helpB, &QPushButton::clicked, this, &Ekos::Analyze::helpMessage);
+    connect(keepCurrentCB, &QCheckBox::stateChanged, this, &Ekos::Analyze::keepCurrent);
+
+    setupKeyboardShortcuts(timelinePlot);
+
+    replot();
+}
+
+// Mouse wheel over the Timeline plot causes an x-axis zoom.
+void Analyze::timelineMouseWheel(QWheelEvent *event)
+{
+    if (event->angleDelta().y() > 0)
+        zoomIn();
+    else if (event->angleDelta().y() < 0)
+        zoomOut();
+}
+
+// This callback is used so that when keepCurrent is checked, we replot immediately.
+// The actual keepCurrent work is done in replot().
+void Analyze::keepCurrent(int state)
+{
+    Q_UNUSED(state);
+    if (keepCurrentCB->isChecked())
+    {
+        removeStatsCursor();
+        replot();
+    }
+}
+
+// Implements the input selection UI. User can either choose the current Ekos
+// session, or a file read from disk, or set the alternateDirectory variable.
+void Analyze::initInputSelection()
+{
+    // Setup the input combo box.
+    dirPath = QUrl::fromLocalFile(KSPaths::writableLocation(
+                                      QStandardPaths::GenericDataLocation) + "analyze/");
+
+    inputCombo->addItem("Current Session");
+    inputCombo->addItem("Read from File");
+    inputCombo->addItem("Set alternative image-file base directory");
+    inputValue->setText("");
+    connect(inputCombo, static_cast<void (QComboBox::*)(int)>(&QComboBox::activated), this, [&](int index)
+    {
+        if (index == 0)
+        {
+            // Input from current session
+            if (!runtimeDisplay)
+            {
+                reset();
+                inputValue->setText("Current Session");
+                maxXValue = readDataFromFile(logFilename);
+                runtimeDisplay = true;
+            }
+            fullWidthCB->setChecked(true);
+            fullWidthCB->setVisible(true);
+            fullWidthCB->setDisabled(false);
+            replot();
+        }
+        else if (index == 1)
+        {
+            // Input from a file.
+            QUrl inputURL = QFileDialog::getOpenFileUrl(this, i18n("Select input file"), dirPath,
+                            "Analyze Log (*.analyze);;All Files (*)");
+            if (inputURL.isEmpty())
+                return;
+            dirPath = QUrl(inputURL.url(QUrl::RemoveFilename));
+
+            reset();
+            inputValue->setText(inputURL.fileName());
+
+            // If we do this after the readData call below, it would animate the sequence.
+            runtimeDisplay = false;
+
+            maxXValue = readDataFromFile(inputURL.toLocalFile());
+            plotStart = 0;
+            plotWidth = maxXValue + 5;
+            replot();
+        }
+        else if (index == 2)
+        {
+            QString dir = QFileDialog::getExistingDirectory(
+                              this, tr("Set an alternate base directory for your captured images"),
+                              QDir::homePath(),
+                              QFileDialog::ShowDirsOnly);
+            if (dir.size() > 0)
+            {
+                // TODO: replace with an option.
+                alternateFolder = dir;
+            }
+            // This is not a destiation, reset to one of the above.
+            if (runtimeDisplay)
+                inputCombo->setCurrentIndex(0);
+            else
+                inputCombo->setCurrentIndex(1);
+        }
+    });
+}
+
+void Analyze::setupKeyboardShortcuts(QCustomPlot *plot)
+{
+    // Shortcuts defined: https://doc.qt.io/archives/qt-4.8/qkeysequence.html#standard-shortcuts
+    QShortcut *s = new QShortcut(QKeySequence(QKeySequence::ZoomIn), plot);
+    connect(s, &QShortcut::activated, this, &Ekos::Analyze::zoomIn);
+    s = new QShortcut(QKeySequence(QKeySequence::ZoomOut), plot);
+    connect(s, &QShortcut::activated, this, &Ekos::Analyze::zoomOut);
+    s = new QShortcut(QKeySequence(QKeySequence::MoveToNextChar), plot);
+    connect(s, &QShortcut::activated, this, &Ekos::Analyze::scrollRight);
+    s = new QShortcut(QKeySequence(QKeySequence::MoveToPreviousChar), plot);
+    connect(s, &QShortcut::activated, this, &Ekos::Analyze::scrollLeft);
+    s = new QShortcut(QKeySequence("?"), plot);
+    connect(s, &QShortcut::activated, this, &Ekos::Analyze::helpMessage);
+    s = new QShortcut(QKeySequence("h"), plot);
+    connect(s, &QShortcut::activated, this, &Ekos::Analyze::helpMessage);
+    s = new QShortcut(QKeySequence(QKeySequence::HelpContents), plot);
+    connect(s, &QShortcut::activated, this, &Ekos::Analyze::helpMessage);
+}
+
+Analyze::~Analyze()
+{
+    // TODO:
+    // We should write out to disk any sessions that haven't terminated
+    // (e.g. capture, focus, guide)
+}
+
+// When a user selects a timeline session, the previously selected one
+// is deselected.  Note: this does not replot().
+void Analyze::unhighlightTimelineItem()
+{
+    if (selectionHighlight != nullptr)
+    {
+        timelinePlot->removeItem(selectionHighlight);
+        selectionHighlight = nullptr;
+    }
+    infoBox->clear();
+}
+
+// Highlight the area between start and end on row y in Timeline.
+// Note that this doesn't replot().
+void Analyze::highlightTimelineItem(double y, double start, double end)
+{
+    constexpr double halfHeight = 0.5;
+    unhighlightTimelineItem();
+
+    QCPItemRect *rect = new QCPItemRect(timelinePlot);
+    rect->topLeft->setCoords(start, y + halfHeight);
+    rect->bottomRight->setCoords(end, y - halfHeight);
+    rect->setBrush(timelineSelectionBrush);
+    selectionHighlight = rect;
+}
+
+// These help calculate the RMS values of the ra and drift errors. It takes
+// an approximate moving average of the squared errors roughtly averaged over
+// 40 samples implemented by a simple digital low-pass filter.
+void Analyze::initRmsFilter()
+{
+    constexpr double timeConstant = 40.0;
+    rmsFilterAlpha = 1.0 / pow(timeConstant, 0.865);
+}
+
+double Analyze::rmsFilter(double x)
+{
+    filteredRMS = rmsFilterAlpha * x + (1.0 - rmsFilterAlpha) * filteredRMS;
+    return filteredRMS;
+}
+
+void Analyze::resetRmsFilter()
+{
+    filteredRMS = 0;
+}
+
+// Creates a fat line-segment on the Timeline, optionally with a stripe in the middle.
+QCPItemRect * Analyze::addSession(double start, double end, double y,
+                                  const QBrush &brush, const QBrush *stripeBrush)
+{
+    QPen pen = QPen(Qt::black, 1, Qt::SolidLine);
+    QCPItemRect *rect = new QCPItemRect(timelinePlot);
+    rect->topLeft->setCoords(start, y + halfTimelineHeight);
+    rect->bottomRight->setCoords(end, y - halfTimelineHeight);
+    rect->setPen(pen);
+    rect->setSelectedPen(pen);
+    rect->setBrush(brush);
+    rect->setSelectedBrush(brush);
+
+    if (stripeBrush != nullptr)
+    {
+        QCPItemRect *stripe = new QCPItemRect(timelinePlot);
+        stripe->topLeft->setCoords(start, y + halfTimelineHeight / 2.0);
+        stripe->bottomRight->setCoords(end, y - halfTimelineHeight / 2.0);
+        stripe->setPen(pen);
+        stripe->setBrush(*stripeBrush);
+    }
+    return rect;
+}
+
+// Add the guide stats values to the Stats graphs.
+// We want to avoid drawing guide-stat values when not guiding.
+// That is, we have no input samples then, but the graph would connect
+// two points with a line. By adding NaN values into the graph,
+// those places are made invisible.
+void Analyze::addGuideStats(double raDrift, double decDrift, int raPulse, int decPulse, double snr,
+                            int numStars, double skyBackground, double time)
+{
+    double MAX_GUIDE_STATS_GAP = 30;
+
+    if (time - lastGuideStatsTime > MAX_GUIDE_STATS_GAP &&
+            lastGuideStatsTime >= 0)
+    {
+        addGuideStatsInternal(qQNaN(), qQNaN(), 0, 0, qQNaN(), qQNaN(), qQNaN(), qQNaN(), qQNaN(),
+                              lastGuideStatsTime + .0001);
+        addGuideStatsInternal(qQNaN(), qQNaN(), 0, 0, qQNaN(), qQNaN(), qQNaN(), qQNaN(), qQNaN(), time - .0001);
+        resetRmsFilter();
+    }
+
+    const double drift = std::hypot(raDrift, decDrift);
+
+    // To compute the RMS error, which is sqrt(sum square error / N), filter the squared
+    // error, which effectively returns sum squared error / N.
+    // The RMS value is then the sqrt of the filtered value.
+    const double rms = sqrt(rmsFilter(raDrift * raDrift + decDrift * decDrift));
+
+    addGuideStatsInternal(raDrift, decDrift, double(raPulse), double(decPulse), snr, numStars, skyBackground, drift, rms, time);
+    lastGuideStatsTime = time;
+}
+
+void Analyze::addGuideStatsInternal(double raDrift, double decDrift, double raPulse,
+                                    double decPulse, double snr,
+                                    double numStars, double skyBackground,
+                                    double drift, double rms, double time)
+{
+    statsPlot->graph(RA_GRAPH)->addData(time, raDrift);
+    statsPlot->graph(DEC_GRAPH)->addData(time, decDrift);
+    statsPlot->graph(RA_PULSE_GRAPH)->addData(time, raPulse);
+    statsPlot->graph(DEC_PULSE_GRAPH)->addData(time, decPulse);
+    statsPlot->graph(DRIFT_GRAPH)->addData(time, drift);
+    statsPlot->graph(RMS_GRAPH)->addData(time, rms);
+
+    // Set the SNR axis' maximum to 95% of the way up from the middle to the top.
+    if (!qIsNaN(snr))
+        snrMax = std::max(snr, snrMax);
+    if (!qIsNaN(skyBackground))
+        skyBgMax = std::max(skyBackground, skyBgMax);
+    if (!qIsNaN(numStars))
+        numStarsMax = std::max(numStars, static_cast<double>(numStarsMax));
+
+    snrAxis->setRange(-1.05 * snrMax, std::max(10.0, 1.05 * snrMax));
+    skyBgAxis->setRange(0, std::max(10.0, 1.15 * skyBgMax));
+    numStarsAxis->setRange(0, std::max(10.0, 1.25 * numStarsMax));
+
+    statsPlot->graph(SNR_GRAPH)->addData(time, snr);
+    statsPlot->graph(NUMSTARS_GRAPH)->addData(time, numStars);
+    statsPlot->graph(SKYBG_GRAPH)->addData(time, skyBackground);
+}
+
+// Add the HFR values to the Stats graph, as a constant value between startTime and time.
+void Analyze::addHFR(double hfr, double time, double startTime)
+{
+    // The HFR corresponds to the last capture
+    statsPlot->graph(HFR_GRAPH)->addData(startTime - .0001, qQNaN());
+    statsPlot->graph(HFR_GRAPH)->addData(startTime, hfr);
+    statsPlot->graph(HFR_GRAPH)->addData(time, hfr);
+    statsPlot->graph(HFR_GRAPH)->addData(time + .0001, qQNaN());
+}
+
+// Add the Mount Coordinates values to the Stats graph.
+// All but pierSide are in double degrees.
+void Analyze::addMountCoords(double ra, double dec, double az,
+                             double alt, int pierSide, double ha, double time)
+{
+    statsPlot->graph(MOUNT_RA_GRAPH)->addData(time, ra);
+    statsPlot->graph(MOUNT_DEC_GRAPH)->addData(time, dec);
+    statsPlot->graph(MOUNT_HA_GRAPH)->addData(time, ha);
+    statsPlot->graph(AZ_GRAPH)->addData(time, az);
+    statsPlot->graph(ALT_GRAPH)->addData(time, alt);
+    statsPlot->graph(PIER_SIDE_GRAPH)->addData(time, double(pierSide));
+}
+
+// Read a .analyze file, and setup all the graphics.
+double Analyze::readDataFromFile(const QString &filename)
+{
+    double lastTime = 10;
+    QFile inputFile(filename);
+    if (inputFile.open(QIODevice::ReadOnly))
+    {
+        QTextStream in(&inputFile);
+        while (!in.atEnd())
+        {
+            QString line = in.readLine();
+            double time = processInputLine(line);
+            if (time > lastTime)
+                lastTime = time;
+        }
+        inputFile.close();
+    }
+    return lastTime;
+}
+
+// Process an input line read from a .analyze file.
+double Analyze::processInputLine(const QString &line)
+{
+    bool ok;
+    // Break the line into comma-separated components
+    QStringList list = line.split(QLatin1Char(','));
+    // We need at least a command and a timestamp
+    if (list.size() < 2)
+        return 0;
+    if (list[0].at(0).toLatin1() == '#')
+    {
+        // Comment character # must be at start of line.
+        return 0;
+    }
+
+    if ((list[0] == "AnalyzeStartTime") && list.size() == 3)
+    {
+        displayStartTime = QDateTime::fromString(list[1], timeFormat);
+        startTimeInitialized = true;
+        analyzeTimeZone = list[2];
+        return 0;
+    }
+
+    // Except for comments and the above AnalyzeStartTime, the second item
+    // in the csv line is a double which represents seconds since start of the log.
+    const double time = QString(list[1]).toDouble(&ok);
+    if (!ok)
+        return 0;
+
+    if ((list[0] == "CaptureStarting") && (list.size() == 4))
+    {
+        const double exposureSeconds = QString(list[2]).toDouble(&ok);
+        if (!ok)
+            return 0;
+        const QString filter = list[3];
+        processCaptureStarting(time, exposureSeconds, filter, true);
+    }
+    else if ((list[0] == "CaptureComplete") && (list.size() == 6))
+    {
+        const double exposureSeconds = QString(list[2]).toDouble(&ok);
+        if (!ok)
+            return 0;
+        const QString filter = list[3];
+        const double hfr = QString(list[4]).toDouble(&ok);
+        if (!ok)
+            return 0;
+        const QString filename = list[5];
+        processCaptureComplete(time, filename, exposureSeconds, filter, hfr, true);
+    }
+    else if ((list[0] == "CaptureAborted") && (list.size() == 3))
+    {
+        const double exposureSeconds = QString(list[2]).toDouble(&ok);
+        if (!ok)
+            return 0;
+        processCaptureAborted(time, exposureSeconds, true);
+    }
+    else if ((list[0] == "AutofocusStarting") && (list.size() == 4))
+    {
+        QString filter = list[2];
+        double temperature = QString(list[3]).toDouble(&ok);
+        if (!ok)
+            return 0;
+        processAutofocusStarting(time, temperature, filter, true);
+    }
+    else if ((list[0] == "AutofocusComplete") && (list.size() == 4))
+    {
+        QString filter = list[2];
+        QString samples = list[3];
+        processAutofocusComplete(time, filter, samples, true);
+    }
+    else if ((list[0] == "AutofocusAborted") && (list.size() == 4))
+    {
+        QString filter = list[2];
+        QString samples = list[3];
+        processAutofocusAborted(time, filter, samples, true);
+    }
+    else if ((list[0] == "GuideState") && list.size() == 3)
+    {
+        processGuideState(time, list[2], true);
+    }
+    else if ((list[0] == "GuideStats") && list.size() == 9)
+    {
+        const double ra = QString(list[2]).toDouble(&ok);
+        if (!ok)
+            return 0;
+        const double dec = QString(list[3]).toDouble(&ok);
+        if (!ok)
+            return 0;
+        const double raPulse = QString(list[4]).toInt(&ok);
+        if (!ok)
+            return 0;
+        const double decPulse = QString(list[5]).toInt(&ok);
+        if (!ok)
+            return 0;
+        const double snr = QString(list[6]).toDouble(&ok);
+        if (!ok)
+            return 0;
+        const double skyBg = QString(list[7]).toDouble(&ok);
+        if (!ok)
+            return 0;
+        const double numStars = QString(list[8]).toInt(&ok);
+        if (!ok)
+            return 0;
+        processGuideStats(time, ra, dec, raPulse, decPulse, snr, skyBg, numStars, true);
+    }
+    else if ((list[0] == "MountState") && list.size() == 3)
+    {
+        processMountState(time, list[2], true);
+    }
+    else if ((list[0] == "MountCoords") && (list.size() == 7 || list.size() == 8))
+    {
+        const double ra = QString(list[2]).toDouble(&ok);
+        if (!ok)
+            return 0;
+        const double dec = QString(list[3]).toDouble(&ok);
+        if (!ok)
+            return 0;
+        const double az = QString(list[4]).toDouble(&ok);
+        if (!ok)
+            return 0;
+        const double alt = QString(list[5]).toDouble(&ok);
+        if (!ok)
+            return 0;
+        const int side = QString(list[6]).toInt(&ok);
+        if (!ok)
+            return 0;
+        const double ha = (list.size() > 7) ? QString(list[7]).toDouble(&ok) : 0;
+        if (!ok)
+            return 0;
+        processMountCoords(time, ra, dec, az, alt, side, ha, true);
+    }
+    else if ((list[0] == "AlignState") && list.size() == 3)
+    {
+        processAlignState(time, list[2], true);
+    }
+    else if ((list[0] == "MeridianFlipState") && list.size() == 3)
+    {
+        processMountFlipState(time, list[2], true);
+    }
+    else
+    {
+        return 0;
+    }
+    return time;
+}
+
+// Helper to create tables in the infoBox display.
+// Start the table, displaying the heading and timing information, common to all sessions.
+void Analyze::Session::startTable(const QString &name, const QString &status,
+                                  const QDateTime &startClock, const QDateTime &endClock)
+{
+    QString startDateStr = startClock.toString("dd.MM.yyyy");
+    QString startTimeStr = startClock.toString("hh:mm:ss");
+    QString endTimeStr = isTemporary() ? "Ongoing"
+                         : endClock.toString("hh:mm:ss");
+
+    htmlString = QString("<style>td { padding: 0px 10px }</style>"
+                         "<html><table><tr style=\"color:yellow\"><td>%1</td><td>%2</td></tr>"
+                         "<tr><td style=\"color:yellow\">Date</td><td>%3</td><td></td></tr>"
+                         "<tr><td style=\"color:yellow\">Interval</td><td>%4s</td><td>%5s</td></tr>"
+                         "<tr><td style=\"color:yellow\">Clock</td><td>%6</td><td>%7</td></tr>"
+                         "<tr><td style=\"color:yellow\">Duration</td><td>%8s</td><td></td></tr>")
+                 .arg(name).arg(status)
+                 .arg(startDateStr)
+                 .arg(QString::number(start, 'f', 3))
+                 .arg(isTemporary() ? "Ongoing" : QString::number(end, 'f', 3))
+                 .arg(startTimeStr).arg(endTimeStr)
+                 .arg(QString::number(end - start, 'f', 1));
+}
+
+// Add a new row to the table, which is specific to the particular Timeline line.
+void Analyze::Session::addRow(const QString &key, const QString &value1String, const QString &value2String)
+{
+
+    QString row;
+    if (value2String.size() == 0)
+        // When the 2nd value is empty, have the 1st span both columns.
+        row = QString("<tr><td style=\"color:yellow\">%1</td><td colspan=\"2\">%2</td></tr>")
+              .arg(key).arg(value1String).arg(value2String);
+    else
+        row = QString("<tr><td style=\"color:yellow\">%1</td><td>%2</td><td>%3</td></tr>")
+              .arg(key).arg(value1String).arg(value2String);
+    htmlString = htmlString + row;
+}
+
+// Complete the table.
+QString Analyze::Session::html() const
+{
+    return htmlString + "</table></html>";
+}
+
+bool Analyze::Session::isTemporary() const
+{
+    return rect != nullptr;
+}
+
+// The focus session parses the "pipe-separate-values" list of positions
+// and HFRs given it, eventually to be used to plot the focus v-curve.
+Analyze::FocusSession::FocusSession(double start_, double end_, QCPItemRect *rect, bool ok, double temperature_,
+                                    const QString &filter_, const QString &points_)
+    : Session(start_, end_, FOCUS_Y, rect), success(ok),
+      temperature(temperature_), filter(filter_), points(points_)
+{
+    const QStringList list = points.split(QLatin1Char('|'));
+    const int size = list.size();
+    // Size can be 1 if points_ is an empty string.
+    if (size < 2)
+        return;
+
+    for (int i = 0; i < size; )
+    {
+        bool parsed1, parsed2;
+        int position = QString(list[i++]).toInt(&parsed1);
+        if (i >= size)
+            break;
+        double hfr = QString(list[i++]).toDouble(&parsed2);
+        if (!parsed1 || !parsed2)
+        {
+            positions.clear();
+            hfrs.clear();
+            fprintf(stderr, "Bad focus position %d in %s\n", i - 2, points.toLatin1().data());
+            return;
+        }
+        positions.push_back(position);
+        hfrs.push_back(hfr);
+    }
+}
+
+// When the user clicks on a partular capture session in the timeline,
+// a table is rendered in the infoBox, and, if it was a double click,
+// the fits file is displayed, if it can be found.
+void Analyze::captureSessionClicked(CaptureSession &c, bool doubleClick)
+{
+    highlightTimelineItem(c.offset, c.start, c.end);
+
+    if (c.isTemporary())
+        c.startTable("Capture", "in progress", clockTime(c.start), clockTime(c.start));
+    else if (c.aborted)
+        c.startTable("Capture", "ABORTED", clockTime(c.start), clockTime(c.end));
+    else
+        c.startTable("Capture", "successful", clockTime(c.start), clockTime(c.end));
+
+    c.addRow("Filter", c.filter);
+
+    double raRMS, decRMS, totalRMS;
+    int numSamples;
+    displayGuideGraphics(c.start, c.end, &raRMS, &decRMS, &totalRMS, &numSamples);
+    if (numSamples > 0)
+        c.addRow("GuideRMS", QString::number(totalRMS, 'f', 2));
+
+    c.addRow("Exposure", QString::number(c.duration, 'f', 2));
+    if (!c.isTemporary())
+        c.addRow("Filename", c.filename);
+    infoBox->setHtml(c.html());
+
+    if (doubleClick && !c.isTemporary())
+    {
+        QString filename = findFilename(c.filename, alternateFolder);
+        if (filename.size() > 0)
+            displayFITS(filename);
+        else
+        {
+            QString message = i18n("Could not find image file: %1", c.filename);
+            KSNotification::sorry(message, i18n("Invalid URL"));
+        }
+    }
+}
+
+// When the user clicks on a focus session in the timeline,
+// a table is rendered in the infoBox, and the HFR/position plot
+// is displayed in the graphics plot. If focus is ongoing
+// the information for the graphics is not plotted as it is not yet available.
+void Analyze::focusSessionClicked(FocusSession &c, bool doubleClick)
+{
+    Q_UNUSED(doubleClick);
+    highlightTimelineItem(c.offset, c.start, c.end);
+
+    if (c.success)
+        c.startTable("Focus", "successful", clockTime(c.start), clockTime(c.end));
+    else if (c.isTemporary())
+        c.startTable("Focus", "in progress", clockTime(c.start), clockTime(c.start));
+    else
+        c.startTable("Focus", "FAILED", clockTime(c.start), clockTime(c.end));
+
+    double finalHfr = c.hfrs.size() > 0 ? c.hfrs.last() : 0;
+    if (c.success)
+        c.addRow("HFR", c.isTemporary() ? "" : QString::number(finalHfr, 'f', 2));
+    if (!c.isTemporary())
+        c.addRow("Iterations", QString::number(c.positions.size()));
+    c.addRow("Filter", c.filter);
+    c.addRow("Temperature", QString::number(c.temperature, 'f', 1));
+    infoBox->setHtml(c.html());
+
+    if (c.isTemporary())
+        resetGraphicsPlot();
+    else
+        displayFocusGraphics(c.positions, c.hfrs, c.success);
+}
+
+// When the user clicks on a guide session in the timeline,
+// a table is rendered in the infoBox. If it has a G_GUIDING state
+// then a drift plot is generated and  RMS values are calculated
+// for the guiding session's time interval.
+void Analyze::guideSessionClicked(GuideSession &c, bool doubleClick)
+{
+    Q_UNUSED(doubleClick);
+    highlightTimelineItem(GUIDE_Y, c.start, c.end);
+
+    QString st;
+    if (c.simpleState == G_IDLE)
+        st = "Idle";
+    else if (c.simpleState == G_GUIDING)
+        st = "Guiding";
+    else if (c.simpleState == G_CALIBRATING)
+        st = "Calibrating";
+    else if (c.simpleState == G_SUSPENDED)
+        st = "Suspended";
+    else if (c.simpleState == G_DITHERING)
+        st = "Dithering";
+
+    c.startTable("Guide", st, clockTime(c.start), clockTime(c.end));
+    resetGraphicsPlot();
+    if (c.simpleState == G_GUIDING)
+    {
+        double raRMS, decRMS, totalRMS;
+        int numSamples;
+        displayGuideGraphics(c.start, c.end, &raRMS, &decRMS, &totalRMS, &numSamples);
+        if (numSamples > 0)
+        {
+            c.addRow("total RMS", QString::number(totalRMS, 'f', 2));
+            c.addRow("ra RMS", QString::number(raRMS, 'f', 2));
+            c.addRow("dec RMS", QString::number(decRMS, 'f', 2));
+        }
+        c.addRow("Num Samples", QString::number(numSamples));
+    }
+    infoBox->setHtml(c.html());
+}
+
+void Analyze::displayGuideGraphics(double start, double end, double *raRMS,
+                                   double *decRMS, double *totalRMS, int *numSamples)
+{
+    resetGraphicsPlot();
+    auto ra = statsPlot->graph(RA_GRAPH)->data()->findBegin(start);
+    auto dec = statsPlot->graph(DEC_GRAPH)->data()->findBegin(start);
+    auto raEnd = statsPlot->graph(RA_GRAPH)->data()->findEnd(end);
+    auto decEnd = statsPlot->graph(DEC_GRAPH)->data()->findEnd(end);
+    int num = 0;
+    double raSquareErrorSum = 0, decSquareErrorSum = 0;
+    while (ra != raEnd && dec != decEnd &&
+            ra->mainKey() < end && dec->mainKey() < end &&
+            ra != statsPlot->graph(RA_GRAPH)->data()->constEnd() &&
+            dec != statsPlot->graph(DEC_GRAPH)->data()->constEnd() &&
+            ra->mainKey() < end && dec->mainKey() < end)
+    {
+        const double raVal = ra->mainValue();
+        const double decVal = dec->mainValue();
+        graphicsPlot->graph(GUIDER_GRAPHICS)->addData(raVal, decVal);
+        if (!qIsNaN(raVal) && !qIsNaN(decVal))
+        {
+            raSquareErrorSum += raVal * raVal;
+            decSquareErrorSum += decVal * decVal;
+            num++;
+        }
+        ra++;
+        dec++;
+    }
+    if (numSamples != nullptr)
+        *numSamples = num;
+    if (num > 0)
+    {
+        if (raRMS != nullptr)
+            *raRMS = sqrt(raSquareErrorSum / num);
+        if (decRMS != nullptr)
+            *decRMS = sqrt(decSquareErrorSum / num);
+        if (totalRMS != nullptr)
+            *totalRMS = sqrt((raSquareErrorSum + decSquareErrorSum) / num);
+        if (numSamples != nullptr)
+            *numSamples = num;
+    }
+    QCPItemEllipse *c1 = new QCPItemEllipse(graphicsPlot);
+    c1->bottomRight->setCoords(1.0, -1.0);
+    c1->topLeft->setCoords(-1.0, 1.0);
+    QCPItemEllipse *c2 = new QCPItemEllipse(graphicsPlot);
+    c2->bottomRight->setCoords(2.0, -2.0);
+    c2->topLeft->setCoords(-2.0, 2.0);
+    c1->setPen(QPen(Qt::green));
+    c2->setPen(QPen(Qt::yellow));
+
+    // Since the plot is wider than it is tall, these lines set the
+    // vertical range to 2.5, and the horizontal range to whatever it
+    // takes to keep the two axes' scales (number of pixels per value)
+    // the same, so that circles stay circular (i.e. circles are not stretch
+    // wide even though the graph area is not square).
+    graphicsPlot->xAxis->setRange(-2.5, 2.5);
+    graphicsPlot->yAxis->setRange(-2.5, 2.5);
+    graphicsPlot->xAxis->setScaleRatio(graphicsPlot->yAxis);
+}
+
+// When the user clicks on a partular mount session in the timeline,
+// a table is rendered in the infoBox.
+void Analyze::mountSessionClicked(MountSession &c, bool doubleClick)
+{
+    Q_UNUSED(doubleClick);
+    highlightTimelineItem(MOUNT_Y, c.start, c.end);
+
+    c.startTable("Mount", mountStatusString(c.state), clockTime(c.start),
+                 clockTime(c.isTemporary() ? c.start : c.end));
+    infoBox->setHtml(c.html());
+}
+
+// When the user clicks on a partular align session in the timeline,
+// a table is rendered in the infoBox.
+void Analyze::alignSessionClicked(AlignSession &c, bool doubleClick)
+{
+    Q_UNUSED(doubleClick);
+    highlightTimelineItem(ALIGN_Y, c.start, c.end);
+    c.startTable("Align", getAlignStatusString(c.state), clockTime(c.start),
+                 clockTime(c.isTemporary() ? c.start : c.end));
+    infoBox->setHtml(c.html());
+}
+
+// When the user clicks on a partular meridian flip session in the timeline,
+// a table is rendered in the infoBox.
+void Analyze::mountFlipSessionClicked(MountFlipSession &c, bool doubleClick)
+{
+    Q_UNUSED(doubleClick);
+    highlightTimelineItem(MERIDIAN_FLIP_Y, c.start, c.end);
+    c.startTable("Meridian Flip", Mount::meridianFlipStatusString(c.state),
+                 clockTime(c.start), clockTime(c.isTemporary() ? c.start : c.end));
+    infoBox->setHtml(c.html());
+}
+
+// This method determines which timeline session (if any) was selected
+// when the user clicks in the Timeline plot. It also sets a cursor
+// in the stats plot.
+void Analyze::processTimelineClick(QMouseEvent *event, bool doubleClick)
+{
+    unhighlightTimelineItem();
+    double xval = timelinePlot->xAxis->pixelToCoord(event->x());
+    double yval = timelinePlot->yAxis->pixelToCoord(event->y());
+    if (yval >= CAPTURE_Y - 0.5 && yval <= CAPTURE_Y + 0.5)
+    {
+        QList<CaptureSession> candidates = captureSessions.find(xval);
+        if (candidates.size() > 0)
+            captureSessionClicked(candidates[0], doubleClick);
+        else if ((temporaryCaptureSession.rect != nullptr) &&
+                 (xval > temporaryCaptureSession.start))
+            captureSessionClicked(temporaryCaptureSession, doubleClick);
+    }
+    else if (yval >= FOCUS_Y - 0.5 && yval <= FOCUS_Y + 0.5)
+    {
+        QList<FocusSession> candidates = focusSessions.find(xval);
+        if (candidates.size() > 0)
+            focusSessionClicked(candidates[0], doubleClick);
+        else if ((temporaryFocusSession.rect != nullptr) &&
+                 (xval > temporaryFocusSession.start))
+            focusSessionClicked(temporaryFocusSession, doubleClick);
+    }
+    else if (yval >= GUIDE_Y - 0.5 && yval <= GUIDE_Y + 0.5)
+    {
+        QList<GuideSession> candidates = guideSessions.find(xval);
+        if (candidates.size() > 0)
+            guideSessionClicked(candidates[0], doubleClick);
+        else if ((temporaryGuideSession.rect != nullptr) &&
+                 (xval > temporaryGuideSession.start))
+            guideSessionClicked(temporaryGuideSession, doubleClick);
+    }
+    else if (yval >= MOUNT_Y - 0.5 && yval <= MOUNT_Y + 0.5)
+    {
+        QList<MountSession> candidates = mountSessions.find(xval);
+        if (candidates.size() > 0)
+            mountSessionClicked(candidates[0], doubleClick);
+        else if ((temporaryMountSession.rect != nullptr) &&
+                 (xval > temporaryMountSession.start))
+            mountSessionClicked(temporaryMountSession, doubleClick);
+    }
+    else if (yval >= ALIGN_Y - 0.5 && yval <= ALIGN_Y + 0.5)
+    {
+        QList<AlignSession> candidates = alignSessions.find(xval);
+        if (candidates.size() > 0)
+            alignSessionClicked(candidates[0], doubleClick);
+        else if ((temporaryAlignSession.rect != nullptr) &&
+                 (xval > temporaryAlignSession.start))
+            alignSessionClicked(temporaryAlignSession, doubleClick);
+    }
+    else if (yval >= MERIDIAN_FLIP_Y - 0.5 && yval <= MERIDIAN_FLIP_Y + 0.5)
+    {
+        QList<MountFlipSession> candidates = mountFlipSessions.find(xval);
+        if (candidates.size() > 0)
+            mountFlipSessionClicked(candidates[0], doubleClick);
+        else if ((temporaryMountFlipSession.rect != nullptr) &&
+                 (xval > temporaryMountFlipSession.start))
+            mountFlipSessionClicked(temporaryMountFlipSession, doubleClick);
+    }
+    setStatsCursor(xval);
+    replot();
+}
+
+void Analyze::setStatsCursor(double time)
+{
+    removeStatsCursor();
+    QCPItemLine *line = new QCPItemLine(statsPlot);
+    line->setPen(QPen(Qt::darkGray, 1, Qt::SolidLine));
+    double top = statsPlot->yAxis->range().upper;
+    line->start->setCoords(time, 0);
+    line->end->setCoords(time, top);
+    statsCursor = line;
+    cursorTimeOut->setText(QString("%1s").arg(time));
+    cursorClockTimeOut->setText(QString("%1")
+                                .arg(clockTime(time).toString("hh:mm:ss")));
+    statsCursorTime = time;
+    keepCurrentCB->setCheckState(Qt::Unchecked);
+}
+
+void Analyze::removeStatsCursor()
+{
+    if (statsCursor != nullptr)
+        statsPlot->removeItem(statsCursor);
+    statsCursor = nullptr;
+    cursorTimeOut->setText("");
+    cursorClockTimeOut->setText("");
+    statsCursorTime = -1;
+}
+
+// When the users clicks in the stats plot, the cursor is set at the corresponding time.
+void Analyze::processStatsClick(QMouseEvent *event, bool doubleClick)
+{
+    Q_UNUSED(doubleClick);
+    double xval = statsPlot->xAxis->pixelToCoord(event->x());
+    if (event->button() == Qt::RightButton)
+        // Resets the range. Replot will take care of ra/dec needing negative values.
+        statsPlot->yAxis->setRange(0, 5);
+    else
+        setStatsCursor(xval);
+    replot();
+}
+
+void Analyze::timelineMousePress(QMouseEvent *event)
+{
+    processTimelineClick(event, false);
+}
+
+void Analyze::timelineMouseDoubleClick(QMouseEvent *event)
+{
+    processTimelineClick(event, true);
+}
+
+void Analyze::statsMousePress(QMouseEvent *event)
+{
+    processStatsClick(event, false);
+}
+
+void Analyze::statsMouseDoubleClick(QMouseEvent *event)
+{
+    processStatsClick(event, true);
+}
+
+// Allow the user to click and hold, causing the cursor to move in real-time.
+void Analyze::statsMouseMove(QMouseEvent *event)
+{
+    processStatsClick(event, false);
+}
+
+// Called by the scrollbar, to move the current view.
+void Analyze::scroll(int value)
+{
+    double pct = static_cast<double>(value) / MAX_SCROLL_VALUE;
+    plotStart = std::max(0.0, maxXValue * pct - plotWidth / 2.0);
+    // Normally replot adjusts the position of the slider.
+    // If the user has done that, we don't want replot to re-do it.
+    replot(false);
+
+}
+void Analyze::scrollRight()
+{
+    plotStart = std::min(maxXValue - plotWidth, plotStart + plotWidth);
+    fullWidthCB->setChecked(false);
+    replot();
+
+}
+void Analyze::scrollLeft()
+{
+    plotStart = std::max(0.0, plotStart - plotWidth);
+    fullWidthCB->setChecked(false);
+    replot();
+
+}
+void Analyze::replot(bool adjustSlider)
+{
+    adjustTemporarySessions();
+    if (fullWidthCB->isChecked())
+    {
+        plotStart = 0;
+        plotWidth = std::max(10.0, maxXValue);
+    }
+    else if (keepCurrentCB->isChecked())
+    {
+        plotStart = std::max(0.0, maxXValue - plotWidth);
+    }
+    // If we're keeping to the latest values,
+    // set the time display to the latest time.
+    if (keepCurrentCB->isChecked() && statsCursor == nullptr)
+    {
+        cursorTimeOut->setText(QString("%1s").arg(maxXValue));
+        cursorClockTimeOut->setText(QString("%1")
+                                    .arg(clockTime(maxXValue).toString("hh:mm:ss")));
+    }
+    analyzeSB->setPageStep(
+        std::min(MAX_SCROLL_VALUE,
+                 static_cast<int>(MAX_SCROLL_VALUE * plotWidth / maxXValue)));
+    if (adjustSlider)
+    {
+        double sliderCenter = plotStart + plotWidth / 2.0;
+        analyzeSB->setSliderPosition(MAX_SCROLL_VALUE * (sliderCenter / maxXValue));
+    }
+
+    timelinePlot->xAxis->setRange(plotStart, plotStart + plotWidth);
+    timelinePlot->yAxis->setRange(0, LAST_Y);
+
+    statsPlot->xAxis->setRange(plotStart, plotStart + plotWidth);
+
+    // Don't reset the range if the user has changed it.
+    auto yRange = statsPlot->yAxis->range();
+    if ((yRange.lower == 0 || yRange.lower == -2) && (yRange.upper == 5))
+    {
+        // Only need negative numbers on the stats plot if we're plotting RA or DEC
+        if (raCB->isChecked() || decCB->isChecked() || raPulseCB->isChecked() || decPulseCB->isChecked())
+            statsPlot->yAxis->setRange(-2, 5);
+        else
+            statsPlot->yAxis->setRange(0, 5);
+    }
+
+    dateTicker->setOffset(displayStartTime.toMSecsSinceEpoch() / 1000.0);
+
+    timelinePlot->replot();
+    statsPlot->replot();
+    graphicsPlot->replot();
+    updateStatsValues();
+}
+
+namespace
+{
+// Pass in a function that converts the double graph value to a string
+// for the value box.
+template<typename Func>
+void updateStat(double time, QLineEdit *valueBox, QCPGraph *graph, Func func, bool useLastRealVal = false)
+{
+    auto begin = graph->data()->findBegin(time);
+    double timeDiffThreshold = 10000000.0;
+    if ((begin != graph->data()->constEnd()) &&
+            (fabs(begin->mainKey() - time) < timeDiffThreshold))
+    {
+        double foundVal = begin->mainValue();
+        valueBox->setDisabled(false);
+        if (qIsNaN(foundVal))
+        {
+            int index = graph->findBegin(time);
+            const double MAX_TIME_DIFF = 600;
+            while (useLastRealVal && index >= 0)
+            {
+                const double val = graph->data()->at(index)->mainValue();
+                const double t = graph->data()->at(index)->mainKey();
+                if (time - t > MAX_TIME_DIFF)
+                    break;
+                if (!qIsNaN(val))
+                {
+                    valueBox->setText(func(val));
+                    return;
+                }
+                index--;
+            }
+            valueBox->clear();
+        }
+        else
+            valueBox->setText(func(foundVal));
+    }
+    else valueBox->setDisabled(true);
+}
+
+}  // namespace
+
+// This populates the output boxes below the stats plot with the correct statistics.
+void Analyze::updateStatsValues()
+{
+    const double time = statsCursorTime < 0 ? maxXValue : statsCursorTime;
+
+    auto d2Fcn = [](double d) -> QString { return QString::number(d, 'f', 2); };
+    // HFR is the only one to use the last real value, that is, it
+    // keeps the hfr from the last exposure.
+    updateStat(time, hfrOut, statsPlot->graph(HFR_GRAPH), d2Fcn, true);
+    updateStat(time, skyBgOut, statsPlot->graph(SKYBG_GRAPH), d2Fcn);
+    updateStat(time, snrOut, statsPlot->graph(SNR_GRAPH), d2Fcn);
+    updateStat(time, raOut, statsPlot->graph(RA_GRAPH), d2Fcn);
+    updateStat(time, decOut, statsPlot->graph(DEC_GRAPH), d2Fcn);
+    updateStat(time, driftOut, statsPlot->graph(DRIFT_GRAPH), d2Fcn);
+    updateStat(time, rmsOut, statsPlot->graph(RMS_GRAPH), d2Fcn);
+    updateStat(time, azOut, statsPlot->graph(AZ_GRAPH), d2Fcn);
+    updateStat(time, altOut, statsPlot->graph(ALT_GRAPH), d2Fcn);
+
+    auto hmsFcn = [](double d) -> QString { dms ra; ra.setD(d); return ra.toHMSString(); };
+    updateStat(time, mountRaOut, statsPlot->graph(MOUNT_RA_GRAPH), hmsFcn);
+    auto dmsFcn = [](double d) -> QString { dms dec; dec.setD(d); return dec.toDMSString(); };
+    updateStat(time, mountDecOut, statsPlot->graph(MOUNT_DEC_GRAPH), dmsFcn);
+    auto haFcn = [](double d) -> QString
+    {
+        dms ha;
+        QChar z('0');
+        QChar sgn('+');
+        ha.setD(d);
+        if (ha.Hours() > 12.0)
+        {
+            ha.setH(24.0 - ha.Hours());
+            sgn = '-';
+        }
+        return QString("%1%2h %3m").arg(sgn).arg(ha.hour(), 2, 10, z)
+        .arg(ha.minute(), 2, 10, z);
+    };
+    updateStat(time, mountHaOut, statsPlot->graph(MOUNT_HA_GRAPH), haFcn);
+
+    auto intFcn = [](double d) -> QString { return QString::number(d, 'f', 0); };
+    updateStat(time, numStarsOut, statsPlot->graph(NUMSTARS_GRAPH), intFcn);
+    updateStat(time, raPulseOut, statsPlot->graph(RA_PULSE_GRAPH), intFcn);
+    updateStat(time, decPulseOut, statsPlot->graph(DEC_PULSE_GRAPH), intFcn);
+
+    auto pierFcn = [](double d) -> QString
+    {
+        return d == 0.0 ? "W (ptg E)" : d == 1.0 ? "E (ptg W)" : "?";
+    };
+    updateStat(time, pierSideOut, statsPlot->graph(PIER_SIDE_GRAPH), pierFcn);
+}
+
+void Analyze::initStatsCheckboxes()
+{
+    hfrCB->setChecked(Options::analyzeHFR());
+    numStarsCB->setChecked(Options::analyzeNumStars());
+    skyBgCB->setChecked(Options::analyzeSkyBg());
+    snrCB->setChecked(Options::analyzeSNR());
+    raCB->setChecked(Options::analyzeRA());
+    decCB->setChecked(Options::analyzeDEC());
+    raPulseCB->setChecked(Options::analyzeRAp());
+    decPulseCB->setChecked(Options::analyzeDECp());
+    driftCB->setChecked(Options::analyzeDrift());
+    rmsCB->setChecked(Options::analyzeRMS());
+    mountRaCB->setChecked(Options::analyzeMountRA());
+    mountDecCB->setChecked(Options::analyzeMountDEC());
+    mountHaCB->setChecked(Options::analyzeMountHA());
+    azCB->setChecked(Options::analyzeAz());
+    altCB->setChecked(Options::analyzeAlt());
+    pierSideCB->setChecked(Options::analyzePierSide());
+}
+
+void Analyze::zoomIn()
+{
+    if (plotWidth > 0.5)
+    {
+        // Try to keep the center of the plot in the same place.
+        plotStart += plotWidth / 4.0;
+        plotWidth = plotWidth / 2.0;
+    }
+    fullWidthCB->setChecked(false);
+    replot();
+}
+
+void Analyze::zoomOut()
+{
+    if (plotWidth < maxXValue)
+    {
+        plotStart = std::max(0.0, plotStart - plotWidth / 2.0);
+        plotWidth = plotWidth * 2;
+    }
+    fullWidthCB->setChecked(false);
+    replot();
+}
+
+namespace
+{
+// Generic initialization of a plot, applied to all plots in this tab.
+void initQCP(QCustomPlot *plot)
+{
+    plot->setBackground(QBrush(Qt::black));
+    plot->xAxis->setBasePen(QPen(Qt::white, 1));
+    plot->yAxis->setBasePen(QPen(Qt::white, 1));
+    plot->xAxis->grid()->setPen(QPen(QColor(140, 140, 140, 140), 1, Qt::DotLine));
+    plot->yAxis->grid()->setPen(QPen(QColor(140, 140, 140, 140), 1, Qt::DotLine));
+    plot->xAxis->grid()->setSubGridPen(QPen(QColor(40, 40, 40), 1, Qt::DotLine));
+    plot->yAxis->grid()->setSubGridPen(QPen(QColor(40, 40, 40), 1, Qt::DotLine));
+    plot->xAxis->grid()->setZeroLinePen(Qt::NoPen);
+    plot->yAxis->grid()->setZeroLinePen(QPen(Qt::white, 1));
+    plot->xAxis->setBasePen(QPen(Qt::white, 1));
+    plot->yAxis->setBasePen(QPen(Qt::white, 1));
+    plot->xAxis->setTickPen(QPen(Qt::white, 1));
+    plot->yAxis->setTickPen(QPen(Qt::white, 1));
+    plot->xAxis->setSubTickPen(QPen(Qt::white, 1));
+    plot->yAxis->setSubTickPen(QPen(Qt::white, 1));
+    plot->xAxis->setTickLabelColor(Qt::white);
+    plot->yAxis->setTickLabelColor(Qt::white);
+    plot->xAxis->setLabelColor(Qt::white);
+    plot->yAxis->setLabelColor(Qt::white);
+}
+}  // namespace
+
+void Analyze::initTimelinePlot()
+{
+    initQCP(timelinePlot);
+
+    // This places the labels on the left of the timeline.
+    QSharedPointer<QCPAxisTickerText> textTicker(new QCPAxisTickerText);
+    textTicker->addTick(CAPTURE_Y, "Capture");
+    textTicker->addTick(FOCUS_Y, "Focus");
+    textTicker->addTick(ALIGN_Y, "Align");
+    textTicker->addTick(GUIDE_Y, "Guide");
+    textTicker->addTick(MERIDIAN_FLIP_Y, "Flip");
+    textTicker->addTick(MOUNT_Y, "Mount");
+    timelinePlot->yAxis->setTicker(textTicker);
+}
+
+// Turn on and off the various statistics, adding/removing them from the legend.
+void Analyze::toggleGraph(int graph_id, bool show)
+{
+    statsPlot->graph(graph_id)->setVisible(show);
+    if (show)
+        statsPlot->graph(graph_id)->addToLegend();
+    else
+        statsPlot->graph(graph_id)->removeFromLegend();
+    replot();
+}
+
+int Analyze::initGraph(QCustomPlot *plot, QCPAxis *yAxis, QCPGraph::LineStyle lineStyle,
+                       const QColor &color, const QString &name)
+{
+    int num = plot->graphCount();
+    plot->addGraph(plot->xAxis, yAxis);
+    plot->graph(num)->setLineStyle(lineStyle);
+    plot->graph(num)->setPen(QPen(color));
+    plot->graph(num)->setName(name);
+    return num;
+}
+
+template <typename Func>
+int Analyze::initGraphAndCB(QCustomPlot *plot, QCPAxis *yAxis, QCPGraph::LineStyle lineStyle,
+                            const QColor &color, const QString &name, QCheckBox *cb, Func setCb)
+
+{
+    const int num = initGraph(plot, yAxis, lineStyle, color, name);
+    if (cb != nullptr)
+    {
+        // Don't call toggleGraph() here, as it's too early for replot().
+        bool show = cb->isChecked();
+        plot->graph(num)->setVisible(show);
+        if (show)
+            plot->graph(num)->addToLegend();
+        else
+            plot->graph(num)->removeFromLegend();
+
+        connect(cb, &QCheckBox::toggled,
+                [ = ](bool show)
+        {
+            this->toggleGraph(num, show);
+            setCb(show);
+        });
+    }
+    return num;
+}
+
+void Analyze::initStatsPlot()
+{
+    initQCP(statsPlot);
+
+    // Setup the legend
+    statsPlot->legend->setVisible(true);
+    statsPlot->legend->setFont(QFont("Helvetica", 6));
+    statsPlot->legend->setTextColor(Qt::white);
+    // Legend background is black and ~75% opaque.
+    statsPlot->legend->setBrush(QBrush(QColor(0, 0, 0, 190)));
+    // Legend stacks vertically.
+    statsPlot->legend->setFillOrder(QCPLegend::foRowsFirst);
+    // Rows pretty tightly packed.
+    statsPlot->legend->setRowSpacing(-3);
+    statsPlot->axisRect()->insetLayout()->setInsetAlignment(0, Qt::AlignLeft | Qt::AlignTop);
+
+    // Add the graphs.
+
+    HFR_GRAPH = initGraphAndCB(statsPlot, statsPlot->yAxis, QCPGraph::lsStepRight, Qt::cyan, "HFR", hfrCB,
+                               Options::setAnalyzeHFR);
+    connect(hfrCB, &QCheckBox::clicked,
+            [ = ](bool show)
+    {
+        if (show && !Options::autoHFR())
+            KSNotification::info(
+                i18n("The \"Auto Compute HFR\" option in the KStars "
+                     "FITS options menu is not set. You won't get HFR values "
+                     "without it. Once you set it, newly captured images "
+                     "will have their HFRs computed."));
+    });
+
+    numStarsAxis = statsPlot->axisRect()->addAxis(QCPAxis::atLeft, 0);
+    numStarsAxis->setVisible(false);
+    numStarsAxis->setRange(0, 15000);
+    NUMSTARS_GRAPH = initGraphAndCB(statsPlot, numStarsAxis, QCPGraph::lsStepRight, Qt::magenta, "#Stars", numStarsCB,
+                                    Options::setAnalyzeNumStars);
+
+    skyBgAxis = statsPlot->axisRect()->addAxis(QCPAxis::atLeft, 0);
+    skyBgAxis->setVisible(false);
+    skyBgAxis->setRange(0, 1000);
+    SKYBG_GRAPH = initGraphAndCB(statsPlot, skyBgAxis, QCPGraph::lsStepRight, Qt::darkYellow, "SkyBG", skyBgCB,
+                                 Options::setAnalyzeSkyBg);
+
+    snrAxis = statsPlot->axisRect()->addAxis(QCPAxis::atLeft, 0);
+    snrAxis->setVisible(false);
+    snrAxis->setRange(-100, 100);  // this will be reset.
+    SNR_GRAPH = initGraphAndCB(statsPlot, snrAxis, QCPGraph::lsLine, Qt::yellow, "SNR", snrCB, Options::setAnalyzeSNR);
+
+    auto raColor = KStarsData::Instance()->colorScheme()->colorNamed("RAGuideError");
+    RA_GRAPH = initGraphAndCB(statsPlot, statsPlot->yAxis, QCPGraph::lsLine, raColor, "RA", raCB, Options::setAnalyzeRA);
+    auto decColor = KStarsData::Instance()->colorScheme()->colorNamed("DEGuideError");
+    DEC_GRAPH = initGraphAndCB(statsPlot, statsPlot->yAxis, QCPGraph::lsLine, decColor, "DEC", decCB, Options::setAnalyzeDEC);
+
+    QCPAxis *pulseAxis = statsPlot->axisRect()->addAxis(QCPAxis::atLeft, 0);
+    pulseAxis->setVisible(false);
+    // 150 is a typical value for pulse-ms/pixel
+    // This will roughtly co-incide with the -2,5 range for the ra/dec plots.
+    pulseAxis->setRange(-2 * 150, 5 * 150);
+
+    auto raPulseColor = KStarsData::Instance()->colorScheme()->colorNamed("RAGuideError");
+    raPulseColor.setAlpha(75);
+    RA_PULSE_GRAPH = initGraphAndCB(statsPlot, pulseAxis, QCPGraph::lsLine, raPulseColor, "RAp", raPulseCB,
+                                    Options::setAnalyzeRAp);
+    statsPlot->graph(RA_PULSE_GRAPH)->setBrush(QBrush(raPulseColor, Qt::Dense4Pattern));
+
+    auto decPulseColor = KStarsData::Instance()->colorScheme()->colorNamed("DEGuideError");
+    decPulseColor.setAlpha(75);
+    DEC_PULSE_GRAPH = initGraphAndCB(statsPlot, pulseAxis, QCPGraph::lsLine, decPulseColor, "DECp", decPulseCB,
+                                     Options::setAnalyzeDECp);
+    statsPlot->graph(DEC_PULSE_GRAPH)->setBrush(QBrush(decPulseColor, Qt::Dense4Pattern));
+
+    DRIFT_GRAPH = initGraphAndCB(statsPlot, statsPlot->yAxis, QCPGraph::lsLine, Qt::lightGray, "Drift", driftCB,
+                                 Options::setAnalyzeDrift);
+    RMS_GRAPH = initGraphAndCB(statsPlot, statsPlot->yAxis, QCPGraph::lsLine, Qt::red, "RMS", rmsCB, Options::setAnalyzeRMS);
+
+    QCPAxis *mountRaDecAxis = statsPlot->axisRect()->addAxis(QCPAxis::atLeft, 0);
+    mountRaDecAxis->setVisible(false);
+    mountRaDecAxis->setRange(-10, 370);
+    // Colors of these two unimportant--not really plotted.
+    MOUNT_RA_GRAPH = initGraphAndCB(statsPlot, mountRaDecAxis, QCPGraph::lsLine, Qt::red, "MOUNT_RA", mountRaCB,
+                                    Options::setAnalyzeMountRA);
+    MOUNT_DEC_GRAPH = initGraphAndCB(statsPlot, mountRaDecAxis, QCPGraph::lsLine, Qt::red, "MOUNT_DEC", mountDecCB,
+                                     Options::setAnalyzeMountDEC);
+    MOUNT_HA_GRAPH = initGraphAndCB(statsPlot, mountRaDecAxis, QCPGraph::lsLine, Qt::red, "MOUNT_HA", mountHaCB,
+                                    Options::setAnalyzeMountHA);
+
+    QCPAxis *azAxis = statsPlot->axisRect()->addAxis(QCPAxis::atLeft, 0);
+    azAxis->setVisible(false);
+    azAxis->setRange(-10, 370);
+    AZ_GRAPH = initGraphAndCB(statsPlot, azAxis, QCPGraph::lsLine, Qt::darkGray, "AZ", azCB, Options::setAnalyzeAz);
+
+    QCPAxis *altAxis = statsPlot->axisRect()->addAxis(QCPAxis::atLeft, 0);
+    altAxis->setVisible(false);
+    altAxis->setRange(0, 90);
+    ALT_GRAPH = initGraphAndCB(statsPlot, altAxis, QCPGraph::lsLine, Qt::white, "ALT", altCB, Options::setAnalyzeAlt);
+
+    QCPAxis *pierSideAxis = statsPlot->axisRect()->addAxis(QCPAxis::atLeft, 0);
+    pierSideAxis->setVisible(false);
+    pierSideAxis->setRange(-2, 2);
+    PIER_SIDE_GRAPH = initGraphAndCB(statsPlot, pierSideAxis, QCPGraph::lsLine, Qt::darkRed, "PierSide", pierSideCB,
+                                     Options::setAnalyzePierSide);
+
+    // TODO: Should figure out the margin
+    // on the timeline plot, and setting this one accordingly.
+    // doesn't look like that's possible with current code, though.
+    statsPlot->yAxis->setPadding(50);
+
+    // This makes mouseMove only get called when a button is pressed.
+    statsPlot->setMouseTracking(false);
+
+    // Setup the clock-time labels on the x-axis of the stats plot.
+    dateTicker.reset(new OffsetDateTimeTicker);
+    dateTicker->setDateTimeFormat("hh:mm:ss");
+    statsPlot->xAxis->setTicker(dateTicker);
+
+    // Didn't include QCP::iRangeDrag as it  interacts poorly with the curson logic.
+    statsPlot->setInteractions(QCP::iRangeZoom);
+    statsPlot->axisRect()->setRangeZoomAxes(0, statsPlot->yAxis);
+}
+
+// Clear the graphics and state when changing input data.
+void Analyze::reset()
+{
+    maxXValue = 10.0;
+    plotStart = 0.0;
+    plotWidth = 10.0;
+
+    resetRmsFilter();
+
+    unhighlightTimelineItem();
+
+    for (int i = 0; i < statsPlot->graphCount(); ++i)
+        statsPlot->graph(i)->data()->clear();
+    statsPlot->clearItems();
+
+    for (int i = 0; i < timelinePlot->graphCount(); ++i)
+        timelinePlot->graph(i)->data()->clear();
+    timelinePlot->clearItems();
+
+    resetGraphicsPlot();
+
+    infoBox->setText("");
+    inputValue->clear();
+    captureSessions.clear();
+    focusSessions.clear();
+
+    numStarsOut->setText("");
+    skyBgOut->setText("");
+    snrOut->setText("");
+    raOut->setText("");
+    decOut->setText("");
+    driftOut->setText("");
+    rmsOut->setText("");
+
+    removeStatsCursor();
+    removeTemporarySessions();
+
+    resetCaptureState();
+    resetAutofocusState();
+    resetGuideState();
+    resetGuideStats();
+    resetAlignState();
+    resetMountState();
+    resetMountCoords();
+    resetMountFlipState();
+
+    // Note: no replot().
+}
+
+void Analyze::initGraphicsPlot()
+{
+    initQCP(graphicsPlot);
+    FOCUS_GRAPHICS = initGraph(graphicsPlot, graphicsPlot->yAxis,
+                               QCPGraph::lsNone, Qt::cyan, "Focus");
+    graphicsPlot->graph(FOCUS_GRAPHICS)->setScatterStyle(
+        QCPScatterStyle(QCPScatterStyle::ssCircle, Qt::white, Qt::white, 14));
+    FOCUS_GRAPHICS_FINAL = initGraph(graphicsPlot, graphicsPlot->yAxis,
+                                     QCPGraph::lsNone, Qt::cyan, "FocusBest");
+    graphicsPlot->graph(FOCUS_GRAPHICS_FINAL)->setScatterStyle(
+        QCPScatterStyle(QCPScatterStyle::ssCircle, Qt::yellow, Qt::yellow, 14));
+    graphicsPlot->setInteractions(QCP::iRangeZoom);
+    graphicsPlot->setInteraction(QCP::iRangeDrag, true);
+
+
+    GUIDER_GRAPHICS = initGraph(graphicsPlot, graphicsPlot->yAxis,
+                                QCPGraph::lsNone, Qt::cyan, "Guide Error");
+    graphicsPlot->graph(GUIDER_GRAPHICS)->setScatterStyle(
+        QCPScatterStyle(QCPScatterStyle::ssStar, Qt::gray, 5));
+}
+
+void Analyze::displayFocusGraphics(const QVector<double> &positions, const QVector<double> &hfrs, bool success)
+{
+    resetGraphicsPlot();
+    auto graph = graphicsPlot->graph(FOCUS_GRAPHICS);
+    auto finalGraph = graphicsPlot->graph(FOCUS_GRAPHICS_FINAL);
+    double maxHfr = -1e8, maxPosition = -1e8, minHfr = 1e8, minPosition = 1e8;
+    for (int i = 0; i < positions.size(); ++i)
+    {
+        // Yellow circle for the final point.
+        if (success && i == positions.size() - 1)
+            finalGraph->addData(positions[i], hfrs[i]);
+        else
+            graph->addData(positions[i], hfrs[i]);
+        maxHfr = std::max(maxHfr, hfrs[i]);
+        minHfr = std::min(minHfr, hfrs[i]);
+        maxPosition = std::max(maxPosition, positions[i]);
+        minPosition = std::min(minPosition, positions[i]);
+    }
+
+    for (int i = 0; i < positions.size(); ++i)
+    {
+        QCPItemText *textLabel = new QCPItemText(graphicsPlot);
+        textLabel->setPositionAlignment(Qt::AlignCenter | Qt::AlignHCenter);
+        textLabel->position->setType(QCPItemPosition::ptPlotCoords);
+        textLabel->position->setCoords(positions[i], hfrs[i]);
+        textLabel->setText(QString::number(i + 1));
+        textLabel->setFont(QFont(font().family(), 12));
+        textLabel->setPen(Qt::NoPen);
+        textLabel->setColor(Qt::red);
+    }
+    const double xRange = maxPosition - minPosition;
+    const double yRange = maxHfr - minHfr;
+    graphicsPlot->xAxis->setRange(minPosition - xRange * .2, maxPosition + xRange * .2);
+    graphicsPlot->yAxis->setRange(minHfr - yRange * .2, maxHfr + yRange * .2);
+    graphicsPlot->replot();
+}
+
+void Analyze::resetGraphicsPlot()
+{
+    for (int i = 0; i < graphicsPlot->graphCount(); ++i)
+        graphicsPlot->graph(i)->data()->clear();
+    graphicsPlot->clearItems();
+}
+
+void Analyze::displayFITS(const QString &filename)
+{
+    QUrl url = QUrl::fromLocalFile(filename);
+
+    if (fitsViewer.isNull())
+    {
+        if (Options::singleWindowCapturedFITS())
+            fitsViewer = KStars::Instance()->genericFITSViewer();
+        else
+        {
+            fitsViewer = new FITSViewer(Options::independentWindowFITS() ? nullptr : KStars::Instance());
+            KStars::Instance()->addFITSViewer(fitsViewer);
+        }
+
+        fitsViewer->addFITS(url);
+        FITSView *currentView = fitsViewer->getCurrentView();
+        if (currentView)
+            currentView->getImageData()->setAutoRemoveTemporaryFITS(false);
+    }
+    else
+        fitsViewer->updateFITS(url, 0);
+
+    fitsViewer->show();
+}
+
+void Analyze::helpMessage()
+{
+    KHelpClient::invokeHelp(QStringLiteral("tool-ekos.html#ekos-analyze"), QStringLiteral("kstars"));
+}
+
+// This is intended for recording data to file.
+// Don't use this when displaying data read from file, as this is not using the
+// correct analyzeStartTime.
+double Analyze::logTime(const QDateTime &time)
+{
+    if (!startTimeInitialized)
+        return 0;
+    return (time.toMSecsSinceEpoch() - analyzeStartTime.toMSecsSinceEpoch()) / 1000.0;
+}
+
+// The logTime using clock = now.
+// This is intended for recording data to file.
+// Don't use this When displaying data read from file.
+double Analyze::logTime()
+{
+    return logTime(QDateTime::currentDateTime());
+}
+
+// Goes back to clock time from seconds into the log.
+// Appropriate for both displaying data from files as well as when displaying live data.
+QDateTime Analyze::clockTime(double logSeconds)
+{
+    return displayStartTime.addMSecs(logSeconds * 1000.0);
+}
+
+
+// Write the command name, a timestamp and the message with comma separation to a .analyze file.
+void Analyze::saveMessage(const QString &type, const QString &message)
+{
+    QString line(QString("%1,%2%3%4\n")
+                 .arg(type)
+                 .arg(QString::number(logTime(), 'f', 3))
+                 .arg(message.size() > 0 ? "," : "")
+                 .arg(message));
+    appendToLog(line);
+}
+
+// Start writing a .analyze file.
+void Analyze::startLog()
+{
+    analyzeStartTime = QDateTime::currentDateTime();
+    startTimeInitialized = true;
+    if (runtimeDisplay)
+        displayStartTime = analyzeStartTime;
+    if (!logEnabled)
+        return;
+    if (logInitialized)
+        return;
+    QString  dir = KSPaths::writableLocation(QStandardPaths::GenericDataLocation) + "analyze/";
+    if (QDir(dir).exists() == false)
+        QDir().mkpath(dir);
+
+    logFilename = dir + "ekos-" + QDateTime::currentDateTime().toString("yyyy-MM-ddThh-mm-ss") + ".analyze";
+    logFile.setFileName(logFilename);
+    logFile.open(QIODevice::WriteOnly | QIODevice::Text);
+
+    // This must happen before the below appendToLog() call.
+    logInitialized = true;
+
+    appendToLog(QString("#KStars version %1. Analyze log version 1.0.\n\n")
+                .arg(KSTARS_VERSION));
+    appendToLog(QString("%1,%2,%3\n")
+                .arg("AnalyzeStartTime")
+                .arg(analyzeStartTime.toString(timeFormat))
+                .arg(analyzeStartTime.timeZoneAbbreviation()));
+}
+
+void Analyze::appendToLog(const QString &lines)
+{
+    if (!logEnabled)
+        return;
+    if (!logInitialized)
+        startLog();
+    QTextStream out(&logFile);
+    out << lines;
+    out.flush();
+}
+
+// maxXValue is the largest time value we have seen so far for this data.
+void Analyze::updateMaxX(double time)
+{
+    maxXValue = std::max(time, maxXValue);
+}
+
+// Manage temporary sessions displayed on the Timeline.
+// Those are ongoing sessions that will ultimately be replaced when the session is complete.
+// This only happens with live data, not with data read from .analyze files.
+
+// Remove the graphic element.
+void Analyze::removeTemporarySession(Session *session)
+{
+    if (session->rect != nullptr)
+        timelinePlot->removeItem(session->rect);
+    session->rect = nullptr;
+    session->start = 0;
+    session->end = 0;
+}
+
+// Remove all temporary sessions (i.e. from all lines in the Timeline).
+void Analyze::removeTemporarySessions()
+{
+    removeTemporarySession(&temporaryCaptureSession);
+    removeTemporarySession(&temporaryMountFlipSession);
+    removeTemporarySession(&temporaryFocusSession);
+    removeTemporarySession(&temporaryGuideSession);
+    removeTemporarySession(&temporaryMountSession);
+    removeTemporarySession(&temporaryAlignSession);
+}
+
+// Add a new temporary session.
+void Analyze::addTemporarySession(Session *session, double time, double duration,
+                                  int y_offset, const QBrush &brush)
+{
+    removeTemporarySession(session);
+    session->rect = addSession(time, time + duration, y_offset, brush);
+    session->start = time;
+    session->end = time + duration;
+    session->offset = y_offset;
+    session->temporaryBrush = brush;
+    updateMaxX(time + duration);
+}
+
+// Extend a temporary session. That is, we don't know how long the session will last,
+// so when new data arrives (from any module, not necessarily the one with the temporary
+// session) we must extend that temporary session.
+void Analyze::adjustTemporarySession(Session *session)
+{
+    if (session->rect != nullptr && session->end < maxXValue)
+    {
+        QBrush brush = session->temporaryBrush;
+        double start = session->start;
+        int offset = session->offset;
+        addTemporarySession(session, start, maxXValue - start, offset, brush);
+    }
+}
+
+// Extend all temporary sessions.
+void Analyze::adjustTemporarySessions()
+{
+    adjustTemporarySession(&temporaryCaptureSession);
+    adjustTemporarySession(&temporaryMountFlipSession);
+    adjustTemporarySession(&temporaryFocusSession);
+    adjustTemporarySession(&temporaryGuideSession);
+    adjustTemporarySession(&temporaryMountSession);
+    adjustTemporarySession(&temporaryAlignSession);
+}
+
+// Called when the captureStarting slot receives a signal.
+// Saves the message to disk, and calls processCaptureStarting.
+void Analyze::captureStarting(double exposureSeconds, const QString &filter)
+{
+    saveMessage("CaptureStarting",
+                QString("%1,%2")
+                .arg(QString::number(exposureSeconds, 'f', 3))
+                .arg(filter));
+    processCaptureStarting(logTime(), exposureSeconds, filter);
+}
+
+// Called by either the above (when live data is received), or reading from file.
+// BatchMode would be true when reading from file.
+void Analyze::processCaptureStarting(double time, double exposureSeconds, const QString &filter, bool batchMode)
+{
+    captureStartedTime = time;
+    captureStartedFilter = filter;
+    updateMaxX(time);
+
+    if (!batchMode)
+    {
+        addTemporarySession(&temporaryCaptureSession, time, 1, CAPTURE_Y, temporaryBrush);
+        temporaryCaptureSession.duration = exposureSeconds;
+        temporaryCaptureSession.filter = filter;
+    }
+}
+
+// Called when the captureComplete slot receives a signal.
+void Analyze::captureComplete(const QString &filename, double exposureSeconds,
+                              const QString &filter, double hfr)
+{
+    saveMessage("CaptureComplete",
+                QString("%1,%2,%3,%4")
+                .arg(QString::number(exposureSeconds, 'f', 3))
+                .arg(filter)
+                .arg(QString::number(hfr, 'f', 3))
+                .arg(filename));
+    if (runtimeDisplay && captureStartedTime >= 0)
+        processCaptureComplete(logTime(), filename, exposureSeconds, filter, hfr);
+}
+
+void Analyze::processCaptureComplete(double time, const QString &filename,
+                                     double exposureSeconds, const QString &filter,
+                                     double hfr, bool batchMode)
+{
+    removeTemporarySession(&temporaryCaptureSession);
+    QBrush stripe;
+    if (filterStripeBrush(filter, &stripe))
+        addSession(captureStartedTime, time, CAPTURE_Y, successBrush, &stripe);
+    else
+        addSession(captureStartedTime, time, CAPTURE_Y, successBrush, nullptr);
+    captureSessions.add(CaptureSession(captureStartedTime, time, nullptr, false,
+                                       filename, exposureSeconds, filter));
+    addHFR(hfr, time, captureStartedTime);
+    updateMaxX(time);
+    if (!batchMode)
+        replot();
+    captureStartedTime = -1;
+}
+
+void Analyze::captureAborted(double exposureSeconds)
+{
+    saveMessage("CaptureAborted",
+                QString("%1").arg(QString::number(exposureSeconds, 'f', 3)));
+    if (runtimeDisplay && captureStartedTime >= 0)
+        processCaptureAborted(logTime(), exposureSeconds);
+}
+
+void Analyze::processCaptureAborted(double time, double exposureSeconds, bool batchMode)
+{
+    removeTemporarySession(&temporaryCaptureSession);
+    double duration = time - captureStartedTime;
+    if (captureStartedTime >= 0 &&
+            duration < (exposureSeconds + 30) &&
+            duration < 3600)
+    {
+        // You can get a captureAborted without a captureStarting,
+        // so make sure this associates with a real start.
+        addSession(captureStartedTime, time, CAPTURE_Y, failureBrush);
+        captureSessions.add(CaptureSession(captureStartedTime, time, nullptr, true, "",
+                                           exposureSeconds, captureStartedFilter));
+        updateMaxX(time);
+        if (!batchMode)
+            replot();
+        captureStartedTime = -1;
+    }
+}
+
+void Analyze::resetCaptureState()
+{
+    captureStartedTime = -1;
+    captureStartedFilter = "";
+}
+
+void Analyze::autofocusStarting(double temperature, const QString &filter)
+{
+    saveMessage("AutofocusStarting",
+                QString("%1,%2")
+                .arg(filter)
+                .arg(QString::number(temperature, 'f', 1)));
+    processAutofocusStarting(logTime(), temperature, filter);
+}
+
+void Analyze::processAutofocusStarting(double time, double temperature, const QString &filter, bool batchMode)
+{
+    autofocusStartedTime = time;
+    autofocusStartedFilter = filter;
+    autofocusStartedTemperature = temperature;
+    updateMaxX(time);
+    if (!batchMode)
+    {
+        addTemporarySession(&temporaryFocusSession, time, 1, FOCUS_Y, temporaryBrush);
+        temporaryFocusSession.temperature = temperature;
+        temporaryFocusSession.filter = filter;
+    }
+}
+
+void Analyze::autofocusComplete(const QString &filter, const QString &points)
+{
+    saveMessage("AutofocusComplete", QString("%1,%2").arg(filter).arg(points));
+    if (runtimeDisplay && autofocusStartedTime >= 0)
+        processAutofocusComplete(logTime(), filter, points);
+}
+
+void Analyze::processAutofocusComplete(double time, const QString &filter, const QString &points, bool batchMode)
+{
+    removeTemporarySession(&temporaryFocusSession);
+    QBrush stripe;
+    if (filterStripeBrush(filter, &stripe))
+        addSession(autofocusStartedTime, time, FOCUS_Y, successBrush, &stripe);
+    else
+        addSession(autofocusStartedTime, time, FOCUS_Y, successBrush, nullptr);
+    focusSessions.add(FocusSession(autofocusStartedTime, time, nullptr, true,
+                                   autofocusStartedTemperature, filter, points));
+    updateMaxX(time);
+    if (!batchMode)
+        replot();
+    autofocusStartedTime = -1;
+}
+
+void Analyze::autofocusAborted(const QString &filter, const QString &points)
+{
+    saveMessage("AutofocusAborted", QString("%1,%2").arg(filter).arg(points));
+    if (runtimeDisplay && autofocusStartedTime >= 0)
+        processAutofocusAborted(logTime(), filter, points);
+}
+
+void Analyze::processAutofocusAborted(double time, const QString &filter, const QString &points, bool batchMode)
+{
+    removeTemporarySession(&temporaryFocusSession);
+    double duration = time - autofocusStartedTime;
+    if (autofocusStartedTime >= 0 && duration < 1000)
+    {
+        // Just in case..
+        addSession(autofocusStartedTime, time, FOCUS_Y, failureBrush);
+        focusSessions.add(FocusSession(autofocusStartedTime, time, nullptr, false,
+                                       autofocusStartedTemperature, filter, points));
+        updateMaxX(time);
+        if (!batchMode)
+            replot();
+        autofocusStartedTime = -1;
+    }
+}
+
+void Analyze::resetAutofocusState()
+{
+    autofocusStartedTime = -1;
+    autofocusStartedFilter = "";
+    autofocusStartedTemperature = 0;
+}
+
+namespace
+{
+
+// TODO: move to ekos.h/cpp?
+Ekos::GuideState stringToGuideState(const QString &str)
+{
+    if (str == I18N_NOOP("Idle"))
+        return GUIDE_IDLE;
+    else if (str == I18N_NOOP("Aborted"))
+        return GUIDE_ABORTED;
+    else if (str == I18N_NOOP("Connected"))
+        return GUIDE_CONNECTED;
+    else if (str == I18N_NOOP("Disconnected"))
+        return GUIDE_DISCONNECTED;
+    else if (str == I18N_NOOP("Capturing"))
+        return GUIDE_CAPTURE;
+    else if (str == I18N_NOOP("Looping"))
+        return GUIDE_LOOPING;
+    else if (str == I18N_NOOP("Subtracting"))
+        return GUIDE_DARK;
+    else if (str == I18N_NOOP("Subframing"))
+        return GUIDE_SUBFRAME;
+    else if (str == I18N_NOOP("Selecting star"))
+        return GUIDE_STAR_SELECT;
+    else if (str == I18N_NOOP("Calibrating"))
+        return GUIDE_CALIBRATING;
+    else if (str == I18N_NOOP("Calibration error"))
+        return GUIDE_CALIBRATION_ERROR;
+    else if (str == I18N_NOOP("Calibrated"))
+        return GUIDE_CALIBRATION_SUCESS;
+    else if (str == I18N_NOOP("Guiding"))
+        return GUIDE_GUIDING;
+    else if (str == I18N_NOOP("Suspended"))
+        return GUIDE_SUSPENDED;
+    else if (str == I18N_NOOP("Reacquiring"))
+        return GUIDE_REACQUIRE;
+    else if (str == I18N_NOOP("Dithering"))
+        return GUIDE_DITHERING;
+    else if (str == I18N_NOOP("Manual Dithering"))
+        return GUIDE_MANUAL_DITHERING;
+    else if (str == I18N_NOOP("Dithering error"))
+        return GUIDE_DITHERING_ERROR;
+    else if (str == I18N_NOOP("Dithering successful"))
+        return GUIDE_DITHERING_SUCCESS;
+    else if (str == I18N_NOOP("Settling"))
+        return GUIDE_DITHERING_SETTLE;
+    else
+        return GUIDE_IDLE;
+}
+
+Analyze::SimpleGuideState convertGuideState(Ekos::GuideState state)
+{
+    switch (state)
+    {
+        case GUIDE_IDLE:
+        case GUIDE_ABORTED:
+        case GUIDE_CONNECTED:
+        case GUIDE_DISCONNECTED:
+        case GUIDE_LOOPING:
+            return Analyze::G_IDLE;
+        case GUIDE_GUIDING:
+            return Analyze::G_GUIDING;
+        case GUIDE_CAPTURE:
+        case GUIDE_DARK:
+        case GUIDE_SUBFRAME:
+        case GUIDE_STAR_SELECT:
+            return Analyze::G_IGNORE;
+        case GUIDE_CALIBRATING:
+        case GUIDE_CALIBRATION_ERROR:
+        case GUIDE_CALIBRATION_SUCESS:
+            return Analyze::G_CALIBRATING;
+        case GUIDE_SUSPENDED:
+        case GUIDE_REACQUIRE:
+            return Analyze::G_SUSPENDED;
+        case GUIDE_DITHERING:
+        case GUIDE_MANUAL_DITHERING:
+        case GUIDE_DITHERING_ERROR:
+        case GUIDE_DITHERING_SUCCESS:
+        case GUIDE_DITHERING_SETTLE:
+            return Analyze::G_DITHERING;
+    }
+    // Shouldn't get here--would get compile error, I believe with a missing case.
+    return Analyze::G_IDLE;
+}
+
+const QBrush guideBrush(Analyze::SimpleGuideState simpleState)
+{
+    switch (simpleState)
+    {
+        case Analyze::G_IDLE:
+        case Analyze::G_IGNORE:
+            // don't actually render these, so don't care.
+            return offBrush;
+        case Analyze::G_GUIDING:
+            return successBrush;
+        case Analyze::G_CALIBRATING:
+            return progressBrush;
+        case Analyze::G_SUSPENDED:
+            return stoppedBrush;
+        case Analyze::G_DITHERING:
+            return progress2Brush;
+    }
+    // Shouldn't get here.
+    return offBrush;
+}
+
+}  // namespace
+
+void Analyze::guideState(Ekos::GuideState state)
+{
+    QString str = getGuideStatusString(state);
+    saveMessage("GuideState", str);
+    if (runtimeDisplay)
+        processGuideState(logTime(), str);
+}
+
+void Analyze::processGuideState(double time, const QString &stateStr, bool batchMode)
+{
+    Ekos::GuideState gstate = stringToGuideState(stateStr);
+    SimpleGuideState state = convertGuideState(gstate);
+    if (state == G_IGNORE)
+        return;
+    if (state == lastGuideStateStarted)
+        return;
+    // End the previous guide session and start the new one.
+    if (guideStateStartedTime >= 0)
+    {
+        if (lastGuideStateStarted != G_IDLE)
+        {
+            // Don't render the idle guiding
+            addSession(guideStateStartedTime, time, GUIDE_Y, guideBrush(lastGuideStateStarted));
+            guideSessions.add(GuideSession(guideStateStartedTime, time, nullptr, lastGuideStateStarted));
+        }
+    }
+    if (state == G_GUIDING && !batchMode)
+    {
+        addTemporarySession(&temporaryGuideSession, time, 1, GUIDE_Y, successBrush);
+        temporaryGuideSession.simpleState = state;
+    }
+    else
+        removeTemporarySession(&temporaryGuideSession);
+
+    guideStateStartedTime = time;
+    lastGuideStateStarted = state;
+    updateMaxX(time);
+    if (!batchMode)
+        replot();
+}
+
+void Analyze::resetGuideState()
+{
+    lastGuideStateStarted = G_IDLE;
+    guideStateStartedTime = -1;
+}
+
+void Analyze::guideStats(double raError, double decError, int raPulse, int decPulse,
+                         double snr, double skyBg, int numStars)
+{
+    saveMessage("GuideStats", QString("%1,%2,%3,%4,%5,%6,%7")
+                .arg(QString::number(raError, 'f', 3))
+                .arg(QString::number(decError, 'f', 3))
+                .arg(raPulse)
+                .arg(decPulse)
+                .arg(QString::number(snr, 'f', 3))
+                .arg(QString::number(skyBg, 'f', 3))
+                .arg(numStars));
+
+    if (runtimeDisplay)
+        processGuideStats(logTime(), raError, decError, raPulse, decPulse, snr, skyBg, numStars);
+}
+
+void Analyze::processGuideStats(double time, double raError, double decError,
+                                int raPulse, int decPulse, double snr, double skyBg, int numStars, bool batchMode)
+{
+    addGuideStats(raError, decError, raPulse, decPulse, snr, numStars, skyBg, time);
+    updateMaxX(time);
+    if (!batchMode)
+        replot();
+}
+
+void Analyze::resetGuideStats()
+{
+    lastGuideStatsTime = -1;;
+    numStarsMax = 0;
+    snrMax = 0;
+    skyBgMax = 0;
+}
+
+namespace
+{
+
+// TODO: move to ekos.h/cpp
+AlignState convertAlignState(const QString &str)
+{
+    for (int i = 0; i < alignStates.size(); ++i)
+    {
+        if (str == alignStates[i])
+            return static_cast<AlignState>(i);
+    }
+    return ALIGN_IDLE;
+}
+
+const QBrush alignBrush(AlignState state)
+{
+    switch (state)
+    {
+        case ALIGN_IDLE:
+            return offBrush;
+        case ALIGN_COMPLETE:
+            return successBrush;
+        case ALIGN_FAILED:
+            return failureBrush;
+        case ALIGN_PROGRESS:
+            return progress3Brush;
+        case ALIGN_SYNCING:
+            return progress2Brush;
+        case ALIGN_SLEWING:
+            return progressBrush;
+        case ALIGN_ABORTED:
+            return failureBrush;
+    }
+    // Shouldn't get here.
+    return offBrush;
+}
+}  // namespace
+
+void Analyze::alignState(AlignState state)
+{
+    if (state == lastAlignStateReceived)
+        return;
+    lastAlignStateReceived = state;
+
+    QString stateStr = getAlignStatusString(state);
+    saveMessage("AlignState", stateStr);
+    if (runtimeDisplay)
+        processAlignState(logTime(), stateStr);
+}
+
+//ALIGN_IDLE, ALIGN_COMPLETE, ALIGN_FAILED, ALIGN_ABORTED,ALIGN_PROGRESS,ALIGN_SYNCING,ALIGN_SLEWING
+void Analyze::processAlignState(double time, const QString &statusString, bool batchMode)
+{
+    AlignState state = convertAlignState(statusString);
+
+    if (state == lastAlignStateStarted)
+        return;
+
+    bool lastStateInteresting = (lastAlignStateStarted == ALIGN_PROGRESS ||
+                                 lastAlignStateStarted == ALIGN_SYNCING ||
+                                 lastAlignStateStarted == ALIGN_SLEWING);
+    if (lastAlignStateStartedTime >= 0 && lastStateInteresting)
+    {
+        if (state == ALIGN_COMPLETE || state == ALIGN_FAILED || state == ALIGN_ABORTED)
+        {
+            // These states are really commetaries on the previous states.
+            addSession(lastAlignStateStartedTime, time, ALIGN_Y, alignBrush(state));
+            alignSessions.add(AlignSession(lastAlignStateStartedTime, time, nullptr, state));
+        }
+        else
+        {
+            addSession(lastAlignStateStartedTime, time, ALIGN_Y, alignBrush(lastAlignStateStarted));
+            alignSessions.add(AlignSession(lastAlignStateStartedTime, time, nullptr, lastAlignStateStarted));
+        }
+    }
+    bool stateInteresting = (state == ALIGN_PROGRESS || state == ALIGN_SYNCING ||
+                             state == ALIGN_SLEWING);
+    if (stateInteresting && !batchMode)
+    {
+        addTemporarySession(&temporaryAlignSession, time, 1, ALIGN_Y, temporaryBrush);
+        temporaryAlignSession.state = state;
+    }
+    else
+        removeTemporarySession(&temporaryAlignSession);
+
+    lastAlignStateStartedTime = time;
+    lastAlignStateStarted = state;
+    updateMaxX(time);
+    if (!batchMode)
+        replot();
+
+}
+
+void Analyze::resetAlignState()
+{
+    lastAlignStateReceived = ALIGN_IDLE;
+    lastAlignStateStarted = ALIGN_IDLE;
+    lastAlignStateStartedTime = -1;
+}
+
+namespace
+{
+
+const QBrush mountBrush(ISD::Telescope::Status state)
+{
+    switch (state)
+    {
+        case ISD::Telescope::MOUNT_IDLE:
+            return offBrush;
+        case ISD::Telescope::MOUNT_ERROR:
+            return failureBrush;
+        case ISD::Telescope::MOUNT_MOVING:
+        case ISD::Telescope::MOUNT_SLEWING:
+            return progressBrush;
+        case ISD::Telescope::MOUNT_TRACKING:
+            return successBrush;
+        case ISD::Telescope::MOUNT_PARKING:
+            return stoppedBrush;
+        case ISD::Telescope::MOUNT_PARKED:
+            return stopped2Brush;
+    }
+    // Shouldn't get here.
+    return offBrush;
+}
+
+}  // namespace
+
+// Mount status can be:
+// MOUNT_IDLE, MOUNT_MOVING, MOUNT_SLEWING, MOUNT_TRACKING, MOUNT_PARKING, MOUNT_PARKED, MOUNT_ERROR
+void Analyze::mountState(ISD::Telescope::Status state)
+{
+    QString statusString = mountStatusString(state);
+    saveMessage("MountState", statusString);
+    if (runtimeDisplay)
+        processMountState(logTime(), statusString);
+}
+
+void Analyze::processMountState(double time, const QString &statusString, bool batchMode)
+{
+    ISD::Telescope::Status state = toMountStatus(statusString);
+    if (mountStateStartedTime >= 0 && lastMountState != ISD::Telescope::MOUNT_IDLE)
+    {
+        addSession(mountStateStartedTime, time, MOUNT_Y, mountBrush(lastMountState));
+        mountSessions.add(MountSession(mountStateStartedTime, time, nullptr, lastMountState));
+    }
+
+    if (state != ISD::Telescope::MOUNT_IDLE && !batchMode)
+    {
+        addTemporarySession(&temporaryMountSession, time, 1, MOUNT_Y,
+                            (state == ISD::Telescope::MOUNT_TRACKING) ? successBrush : temporaryBrush);
+        temporaryMountSession.state = state;
+    }
+    else
+        removeTemporarySession(&temporaryMountSession);
+
+    mountStateStartedTime = time;
+    lastMountState = state;
+    updateMaxX(time);
+    if (!batchMode)
+        replot();
+}
+
+void Analyze::resetMountState()
+{
+    mountStateStartedTime = -1;
+    lastMountState = ISD::Telescope::Status::MOUNT_IDLE;
+}
+
+// This message comes from the mount module,
+// ra: telescopeCoord.ra().toHMSString()
+// dec: telescopeCoord.dec().toDMSString()
+// az: telescopeCoord.az().toDMSString()
+// alt: telescopeCoord.alt().toDMSString()
+// pierSide: currentTelescope->pierSide()
+//   which is PIER_UNKNOWN = -1, PIER_WEST = 0, PIER_EAST = 1
+void Analyze::mountCoords(const QString &raStr, const QString &decStr,
+                          const QString &azStr, const QString &altStr, int pierSide,
+                          const QString &haStr)
+{
+    double ra = dms(raStr, false).Degrees();
+    double dec = dms(decStr, true).Degrees();
+    double ha = dms(haStr, false).Degrees();
+    double az = dms(azStr, true).Degrees();
+    double alt = dms(altStr, true).Degrees();
+
+    // Only process the message if something's changed by 1/4 degree or more.
+    constexpr double MIN_DEGREES_CHANGE = 0.25;
+    if ((fabs(ra - lastMountRa) > MIN_DEGREES_CHANGE) ||
+            (fabs(dec - lastMountDec) > MIN_DEGREES_CHANGE) ||
+            (fabs(ha - lastMountHa) > MIN_DEGREES_CHANGE) ||
+            (fabs(az - lastMountAz) > MIN_DEGREES_CHANGE) ||
+            (fabs(alt - lastMountAlt) > MIN_DEGREES_CHANGE) ||
+            (pierSide != lastMountPierSide))
+    {
+        saveMessage("MountCoords", QString("%1,%2,%3,%4,%5,%6")
+                    .arg(QString::number(ra, 'f', 4))
+                    .arg(QString::number(dec, 'f', 4))
+                    .arg(QString::number(az, 'f', 4))
+                    .arg(QString::number(alt, 'f', 4))
+                    .arg(pierSide)
+                    .arg(QString::number(ha, 'f', 4)));
+
+        if (runtimeDisplay)
+            processMountCoords(logTime(), ra, dec, az, alt, pierSide, ha);
+
+        lastMountRa = ra;
+        lastMountDec = dec;
+        lastMountHa = ha;
+        lastMountAz = az;
+        lastMountAlt = alt;
+        lastMountPierSide = pierSide;
+    }
+}
+
+void Analyze::processMountCoords(double time, double ra, double dec, double az,
+                                 double alt, int pierSide, double ha, bool batchMode)
+{
+    addMountCoords(ra, dec, az, alt, pierSide, ha, time);
+    updateMaxX(time);
+    if (!batchMode)
+        replot();
+}
+
+void Analyze::resetMountCoords()
+{
+    lastMountRa = -1;
+    lastMountDec = -1;
+    lastMountHa = -1;
+    lastMountAz = -1;
+    lastMountAlt = -1;
+    lastMountPierSide = -1;
+}
+
+namespace
+{
+
+// TODO: Move to mount.h/cpp?
+Mount::MeridianFlipStatus convertMountFlipState(const QString &statusStr)
+{
+    if (statusStr == "FLIP_NONE")
+        return Mount::FLIP_NONE;
+    else if (statusStr == "FLIP_PLANNED")
+        return Mount::FLIP_PLANNED;
+    else if (statusStr == "FLIP_WAITING")
+        return Mount::FLIP_WAITING;
+    else if (statusStr == "FLIP_ACCEPTED")
+        return Mount::FLIP_ACCEPTED;
+    else if (statusStr == "FLIP_RUNNING")
+        return Mount::FLIP_RUNNING;
+    else if (statusStr == "FLIP_COMPLETED")
+        return Mount::FLIP_COMPLETED;
+    else if (statusStr == "FLIP_ERROR")
+        return Mount::FLIP_ERROR;
+    return Mount::FLIP_ERROR;
+}
+
+QBrush mountFlipStateBrush(Mount::MeridianFlipStatus state)
+{
+    switch (state)
+    {
+        case Mount::FLIP_NONE:
+            return offBrush;
+        case Mount::FLIP_PLANNED:
+            return stoppedBrush;
+        case Mount::FLIP_WAITING:
+            return stopped2Brush;
+        case Mount::FLIP_ACCEPTED:
+            return progressBrush;
+        case Mount::FLIP_RUNNING:
+            return progress2Brush;
+        case Mount::FLIP_COMPLETED:
+            return successBrush;
+        case Mount::FLIP_ERROR:
+            return failureBrush;
+    }
+    // Shouldn't get here.
+    return offBrush;
+}
+}  // namespace
+
+void Analyze::mountFlipStatus(Mount::MeridianFlipStatus state)
+{
+    if (state == lastMountFlipStateReceived)
+        return;
+    lastMountFlipStateReceived = state;
+
+    QString stateStr = Mount::meridianFlipStatusString(state);
+    saveMessage("MeridianFlipState", stateStr);
+    if (runtimeDisplay)
+        processMountFlipState(logTime(), stateStr);
+
+}
+
+// FLIP_NONE FLIP_PLANNED FLIP_WAITING FLIP_ACCEPTED FLIP_RUNNING FLIP_COMPLETED FLIP_ERROR
+void Analyze::processMountFlipState(double time, const QString &statusString, bool batchMode)
+{
+    Mount::MeridianFlipStatus state = convertMountFlipState(statusString);
+    if (state == lastMountFlipStateStarted)
+        return;
+
+    bool lastStateInteresting =
+        (lastMountFlipStateStarted == Mount::FLIP_PLANNED ||
+         lastMountFlipStateStarted == Mount::FLIP_WAITING ||
+         lastMountFlipStateStarted == Mount::FLIP_ACCEPTED ||
+         lastMountFlipStateStarted == Mount::FLIP_RUNNING);
+    if (mountFlipStateStartedTime >= 0 && lastStateInteresting)
+    {
+        if (state == Mount::FLIP_COMPLETED || state == Mount::FLIP_ERROR)
+        {
+            // These states are really commetaries on the previous states.
+            addSession(mountFlipStateStartedTime, time, MERIDIAN_FLIP_Y, mountFlipStateBrush(state));
+            mountFlipSessions.add(MountFlipSession(mountFlipStateStartedTime, time, nullptr, state));
+        }
+        else
+        {
+            addSession(mountFlipStateStartedTime, time, MERIDIAN_FLIP_Y, mountFlipStateBrush(lastMountFlipStateStarted));
+            mountFlipSessions.add(MountFlipSession(mountFlipStateStartedTime, time, nullptr, lastMountFlipStateStarted));
+        }
+    }
+    bool stateInteresting =
+        (state == Mount::FLIP_PLANNED ||
+         state == Mount::FLIP_WAITING ||
+         state == Mount::FLIP_ACCEPTED ||
+         state == Mount::FLIP_RUNNING);
+    if (stateInteresting && !batchMode)
+    {
+        addTemporarySession(&temporaryMountFlipSession, time, 1, MERIDIAN_FLIP_Y, temporaryBrush);
+        temporaryMountFlipSession.state = state;
+    }
+    else
+        removeTemporarySession(&temporaryMountFlipSession);
+
+    mountFlipStateStartedTime = time;
+    lastMountFlipStateStarted = state;
+    updateMaxX(time);
+    if (!batchMode)
+        replot();
+}
+
+void Analyze::resetMountFlipState()
+{
+    lastMountFlipStateReceived = Mount::FLIP_NONE;
+    lastMountFlipStateStarted = Mount::FLIP_NONE;
+    mountFlipStateStartedTime = -1;
+}
+}
diff --git a/kstars/ekos/analyze/analyze.h b/kstars/ekos/analyze/analyze.h
new file mode 100644
index 000000000..39322a41b
--- /dev/null
+++ b/kstars/ekos/analyze/analyze.h
@@ -0,0 +1,473 @@
+/*  Ekos Analyze Module
+    Copyright (C) 2020 Hy Murveit <hy at murveit.com>
+
+    This application is free software; you can redistribute it and/or
+    modify it under the terms of the GNU General Public
+    License as published by the Free Software Foundation; either
+    version 2 of the License, or (at your option) any later version.
+ */
+
+#ifndef ANALYZE_H
+#define ANALYZE_H
+
+#include <QtDBus>
+
+#include "ekos/ekos.h"
+#include "ekos/mount/mount.h"
+#include "indi/inditelescope.h"
+#include "ui_analyze.h"
+
+class FITSViewer;
+class OffsetDateTimeTicker;
+
+namespace Ekos
+{
+/**
+ *@class Analyze
+ *@short Analysis tab for Ekos sessions.
+ *@author Hy Murveit
+ *@version 1.0
+ */
+
+class Analyze : public QWidget, public Ui::Analyze
+{
+        Q_OBJECT
+
+    public:
+        Analyze();
+        ~Analyze();
+
+        // Baseclass used to represent a segment of Timeline data.
+        class Session
+        {
+            public:
+                // Start and end time in seconds since start of the log.
+                double start, end;
+                // y-offset for the timeline plot. Each line uses a different integer.
+                int offset;
+                // Variables used in temporary sessions. A temporary session
+                // represents a process that has started but not yet finished.
+                // Those are plotted "for now", but will be replaced by the
+                // finished line when the process completes.
+                // Rect is the temporary graphic on the Timeline, and
+                // temporaryBrush defines its look.
+                QCPItemRect *rect;
+                QBrush temporaryBrush;
+
+                Session(double s, double e, int o, QCPItemRect *r)
+                    : start(s), end(e), offset(o), rect(r) {}
+
+                // These 2 are used to build html tables for the info box display.
+                void startTable(const QString &name, const QString &status,
+                                const QDateTime &startClock, const QDateTime &endClock);
+                void addRow(const QString &key, const QString &value1String, const QString &value2String = "");
+                // Returns the html string to use in the info box.
+                QString html() const;
+
+                // True if this session is temporary.
+                bool isTemporary() const;
+
+            private:
+                QString htmlString;
+        };
+        // Below are subclasses of Session used to represent all the different
+        // lines in the Timeline. Each of those hold differnt types of infomation
+        // abou the process it represents.
+        class CaptureSession : public Session
+        {
+            public:
+                bool aborted;
+                QString filename;
+                double duration;
+                QString filter;
+                double hfr;
+                CaptureSession(double start_, double end_, QCPItemRect *rect,
+                               bool aborted_, const QString &filename_,
+                               double duration_, const QString &filter_)
+                    : Session(start_, end_, CAPTURE_Y, rect),
+                      aborted(aborted_), filename(filename_),
+                      duration(duration_), filter(filter_) {}
+                CaptureSession() : Session(0, 0, CAPTURE_Y, nullptr) {}
+        };
+        // Guide sessions collapse some of the possible guiding states.
+        // SimpleGuideState are those collapsed states.
+        typedef enum
+        {
+            G_IDLE, G_GUIDING, G_CALIBRATING, G_SUSPENDED, G_DITHERING, G_IGNORE
+        } SimpleGuideState;
+        class GuideSession : public Session
+        {
+            public:
+                SimpleGuideState simpleState;
+                GuideSession(double start_, double end_, QCPItemRect *rect, SimpleGuideState state_)
+                    : Session(start_, end_, GUIDE_Y, rect), simpleState(state_) {}
+                GuideSession() : Session(0, 0, GUIDE_Y, nullptr) {}
+        };
+        class AlignSession : public Session
+        {
+            public:
+                AlignState state;
+                AlignSession(double start_, double end_, QCPItemRect *rect, AlignState state_)
+                    : Session(start_, end_, ALIGN_Y, rect), state(state_) {}
+                AlignSession() : Session(0, 0, ALIGN_Y, nullptr) {}
+        };
+        class MountSession : public Session
+        {
+            public:
+                ISD::Telescope::Status state;
+                MountSession(double start_, double end_, QCPItemRect *rect, ISD::Telescope::Status state_)
+                    : Session(start_, end_, MOUNT_Y, rect), state(state_) {}
+                MountSession() : Session(0, 0, MOUNT_Y, nullptr) {}
+        };
+        class MountFlipSession : public Session
+        {
+            public:
+                Mount::MeridianFlipStatus state;
+                MountFlipSession(double start_, double end_, QCPItemRect *rect, Mount::MeridianFlipStatus state_)
+                    : Session(start_, end_, MERIDIAN_FLIP_Y, rect), state(state_) {}
+                MountFlipSession() : Session(0, 0, MERIDIAN_FLIP_Y, nullptr) {}
+        };
+        class FocusSession : public Session
+        {
+            public:
+                bool success;
+                double temperature;
+                QString filter;
+                QString points;
+                QVector<double> positions; // Double to be more friendly to QCustomPlot addData.
+                QVector<double> hfrs;
+                FocusSession() : Session(0, 0, FOCUS_Y, nullptr) {}
+                FocusSession(double start_, double end_, QCPItemRect *rect, bool ok, double temperature_,
+                             const QString &filter_, const QString &points_);
+        };
+
+    public slots:
+        // These slots are messages received from the different Ekos processes
+        // used to gather data about those processes.
+
+        // From Capture
+        void captureComplete(const QString &filename, double exposureSeconds,
+                             const QString &filter, double hfr);
+        void captureStarting(double exposureSeconds, const QString &filter);
+        void captureAborted(double exposureSeconds);
+
+        // From Guide
+        void guideState(Ekos::GuideState status);
+        void guideStats(double raError, double decError, int raPulse, int decPulse,
+                        double snr, double skyBg, int numStars);
+
+        // From Focus
+        void autofocusStarting(double temperature, const QString &filter);
+        void autofocusComplete(const QString &filter, const QString &points);
+        void autofocusAborted(const QString &filter, const QString &points);
+
+        // From Align
+        void alignState(Ekos::AlignState state);
+
+        // From Mount
+        void mountState(ISD::Telescope::Status status);
+        void mountCoords(const QString &ra, const QString &dec, const QString &az,
+                         const QString &alt, int pierSide, const QString &ha);
+        void mountFlipStatus(Ekos::Mount::MeridianFlipStatus status);
+
+    private slots:
+
+    signals:
+
+    private:
+
+        // The file-reading, processInputLine(), and signal-slot codepaths share the methods below
+        // to process their messages. Time is the offset in seconds from the start of the log.
+        // BatchMode is true in the file reading path. It means don't call replot() as there may be
+        // many more messages to come. The rest of the args are specific to the message type.
+        void processCaptureStarting(double time, double exposureSeconds, const QString &filter, bool batchMode = false);
+        void processCaptureComplete(double time, const QString &filename,
+                                    double exposureSeconds, const QString &filter, double hfr, bool batchMode = false);
+        void processCaptureAborted(double time, double exposureSeconds, bool batchMode = false);
+        void processAutofocusStarting(double time, double temperature, const QString &filter, bool batchMode = false);
+        void processAutofocusComplete(double time, const QString &filter, const QString &points, bool batchMode = false);
+        void processAutofocusAborted(double time, const QString &filter, const QString &points, bool batchMode = false);
+        void processGuideState(double time, const QString &state, bool batchMode = false);
+        void processGuideStats(double time, double raError, double decError, int raPulse,
+                               int decPulse, double snr, double skyBg, int numStars, bool batchMode = false);
+        void processMountCoords(double time, double ra, double dec, double az, double alt,
+                                int pierSide, double ha, bool batchMode = false);
+
+        void processMountState(double time, const QString &statusString, bool batchMode = false);
+        void processAlignState(double time, const QString &statusString, bool batchMode = false);
+        void processMountFlipState(double time, const QString &statusString, bool batchMode = false);
+
+        // Plotting primatives.
+        void replot(bool adjustSlider = true);
+        void zoomIn();
+        void zoomOut();
+        void scroll(int value);
+        void scrollRight();
+        void scrollLeft();
+
+        // maxXValue keeps the largest time offset we've received so far.
+        // It represents the extent of the plots (0 -> maxXValue).
+        // This is called each time a message is received in case that message's
+        // time is past the current value of maxXValue.
+        void updateMaxX(double time);
+
+        // Callbacks for when the timeline is clicked. ProcessTimelineClick
+        // will determine which segment on which line was clicked and then
+        // call captureSessionClicked() or focusSessionClicked, etc.
+        void processTimelineClick(QMouseEvent *event, bool doubleClick);
+        void captureSessionClicked(CaptureSession &c, bool doubleClick);
+        void focusSessionClicked(FocusSession &c, bool doubleClick);
+        void guideSessionClicked(GuideSession &c, bool doubleClick);
+        void mountSessionClicked(MountSession &c, bool doubleClick);
+        void alignSessionClicked(AlignSession &c, bool doubleClick);
+        void mountFlipSessionClicked(MountFlipSession &c, bool doubleClick);
+
+        // Low-level callbacks.
+        // These two call processTimelineClick().
+        void timelineMousePress(QMouseEvent *event);
+        void timelineMouseDoubleClick(QMouseEvent *event);
+        // Calls zoomIn or zoomOut.
+        void timelineMouseWheel(QWheelEvent *event);
+
+        void processStatsClick(QMouseEvent *event, bool doubleClick);
+        void statsMousePress(QMouseEvent *event);
+        void statsMouseDoubleClick(QMouseEvent *event);
+        void statsMouseMove(QMouseEvent *event);
+        void setupKeyboardShortcuts(QCustomPlot *plot);
+
+        // (Un)highlights a segment on the timeline after one is clicked.
+        // This indicates which segment's data is displayed in the
+        // graphicsPlot and infoBox.
+        void highlightTimelineItem(double y, double start, double end);
+        void unhighlightTimelineItem();
+
+        // logTime() returns the number of seconds between "now" or "time" and
+        // the start of the log. They are useful for recording signal and storing
+        // them to file. They are not useful when reading data from files.
+        double logTime();
+        // Returns the number of seconds between time and the start of the log.
+        double logTime(const QDateTime &time);
+        // Goes back from logSeconds to human-readable clock time.
+        QDateTime clockTime(double logSeconds);
+
+        // Add a new segment to the Timeline graph.
+        // Returns a rect item, which is only important temporary objects, who
+        // need to erase the item when the temporary session is removed.
+        // This memory is owned by QCustomPlot and shouldn't be freed.
+        // This pointer is stored in Session::rect.
+        QCPItemRect * addSession(double start, double end, double y,
+                                 const QBrush &brush, const QBrush *stripeBrush = nullptr);
+
+        // Manage temporary sesions (only used for live data--file-reading doesn't
+        // need temporary sessions). For example, when an image capture has started
+        // but not yet completed, a temporary session is added to the timeline to
+        // represent the not-yet-completed capture.
+        void addTemporarySession(Session *session, double time, double duration,
+                                 int y_offset, const QBrush &brush);
+        void removeTemporarySession(Session *session);
+        void removeTemporarySessions();
+        void adjustTemporarySession(Session *session);
+        void adjustTemporarySessions();
+
+        // Add new stats to the statsPlot.
+        void addGuideStats(double raDrift, double decDrift, int raPulse, int decPulse,
+                           double snr, int numStars, double skyBackground, double time);
+        void addGuideStatsInternal(double raDrift, double decDrift, double raPulse,
+                                   double decPulse, double snr, double numStars,
+                                   double skyBackground, double drift, double rms, double time);
+        void addMountCoords(double ra, double dec, double az, double alt, int pierSide,
+                            double ha, double time);
+        void addHFR(double hfr, const double time, double startTime);
+
+        // AddGuideStats uses rmsFilter() to compute RMS values of the squared
+        // RA and DEC errors, thus calculating the RMS error.
+        double rmsFilter(double x);
+        void initRmsFilter();
+        void resetRmsFilter();
+
+        // Initialize the graphs (axes, linestyle, pen, name, checkbox callbacks).
+        // Returns the graph index.
+        int initGraph(QCustomPlot *plot, QCPAxis *yAxis, QCPGraph::LineStyle lineStyle,
+                      const QColor &color, const QString &name);
+        template <typename Func>
+        int initGraphAndCB(QCustomPlot *plot, QCPAxis *yAxis, QCPGraph::LineStyle lineStyle,
+                           const QColor &color, const QString &name, QCheckBox *cb, Func setCb);
+
+        // Make graphs visible/invisible & add/delete them from the legend.
+        void toggleGraph(int graph_id, bool show);
+
+        // Initializes the main QCustomPlot windows.
+        void initStatsPlot();
+        void initTimelinePlot();
+        void initGraphicsPlot();
+        void initInputSelection();
+
+        // Displays the focus positions and HFRs on the graphics plot.
+        void displayFocusGraphics(const QVector<double> &positions, const QVector<double> &hfrs, bool success);
+        // Displays the guider ra and dec drift plot, and computes RMS errors.
+        void displayGuideGraphics(double start, double end, double *raRMS,
+                                  double *decRMS, double *totalRMS, int *numSamples);
+
+        // Updates the stats value display boxes next to their checkboxes.
+        void updateStatsValues();
+        // Manages the statsPlot cursor.
+        void setStatsCursor(double time);
+        void removeStatsCursor();
+        void keepCurrent(int state);
+
+        // Restore checkboxs from Options.
+        void initStatsCheckboxes();
+
+        // Clears the data, resets the various plots & displays.
+        void reset();
+        void resetGraphicsPlot();
+
+        // Resets the variables used to process the signals received.
+        void resetCaptureState();
+        void resetAutofocusState();
+        void resetGuideState();
+        void resetGuideStats();
+        void resetAlignState();
+        void resetMountState();
+        void resetMountCoords();
+        void resetMountFlipState();
+
+        // Read and display an input .analyze file.
+        double readDataFromFile(const QString &filename);
+        double processInputLine(const QString &line);
+
+        // Opens a FITS file for viewing.
+        void displayFITS(const QString &filename);
+
+        // Pop up a help-message window.
+        void helpMessage();
+
+        // Write the analyze log file message.
+        void saveMessage(const QString &type, const QString &message);
+        // low level file writing.
+        void startLog();
+        void appendToLog(const QString &lines);
+
+        // The .analyze log file being written.
+        QString logFilename { "" };
+        QFile logFile;
+        bool logInitialized { false };
+        bool logEnabled { true };
+
+        // These define the view for the timeline and stats plots.
+        // The plots start plotStart seconds from the start of the session, and
+        // are plotWidth seconds long. The end of the X-asis is maxXValue.
+        double plotStart { 0.0 };
+        double plotWidth { 10.0 };
+        double maxXValue { 10.0 };
+
+        // Data are displayed in seconds since the session started.
+        // analyzeStartTime is when the session started, used to translate to clock time.
+        QDateTime analyzeStartTime;
+        QString analyzeTimeZone { "" };
+        bool startTimeInitialized { false };
+
+        // displayStartTime is similar to analyzeStartTime, but references the
+        // start of the log being displayed (e.g. if reading from a file).
+        // When displaying the current session it should equal analyzeStartTime.
+        QDateTime displayStartTime;
+
+        // Digital filter values for the RMS filter.
+        double rmsFilterAlpha { 0 };
+        double filteredRMS { 0 };
+
+        // Y-axes for the for several plots where we rescale based on data.
+        // QCustomPlot owns these pointers' memory, don't free it.
+        QCPAxis *snrAxis;
+        QCPAxis *numStarsAxis;
+        QCPAxis *skyBgAxis;
+
+        // Used to display clock-time on the X-axis.
+        QSharedPointer<OffsetDateTimeTicker> dateTicker;
+
+        // The rectangle over the current selection.
+        // Memory owned by QCustomPlot.
+        QCPItemRect *selectionHighlight { nullptr };
+
+        // FITS Viewer to display FITS images.
+        QPointer<FITSViewer> fitsViewer;
+        // When trying to load a FITS file, if the original file path doesn't
+        // work, Analyze tries to find the file under the alternate folder.
+        QString alternateFolder;
+
+        // The vertical line in the stats plot.
+        QCPItemLine *statsCursor { nullptr };
+        double statsCursorTime { -1 };
+
+        // Keeps the directory from the last time the user loaded a .analyze file.
+        QUrl dirPath;
+
+        // True if Analyze is displaying data as it comes in from the other modules.
+        // False if Analyze is displaying data read from a file.
+        bool runtimeDisplay { true };
+
+        // When a module's session is ongoing, we represent it as a "temporary session"
+        // which will be replaced once the session is done.
+        CaptureSession temporaryCaptureSession;
+        FocusSession temporaryFocusSession;
+        GuideSession temporaryGuideSession;
+        AlignSession temporaryAlignSession;
+        MountSession temporaryMountSession;
+        MountFlipSession temporaryMountFlipSession;
+
+        // Capture state-machine variables.
+        double captureStartedTime { -1 };
+        QString captureStartedFilter { "" };
+
+        // Autofocus state-machine variables.
+        double autofocusStartedTime { -1 };
+        QString autofocusStartedFilter { "" };
+        double autofocusStartedTemperature { 0 };
+
+        // GuideState state-machine variables.
+        SimpleGuideState lastGuideStateStarted { G_IDLE };
+        double guideStateStartedTime { -1 };
+
+        // GuideStats state-machine variables.
+        double lastGuideStatsTime { -1 };
+        int numStarsMax { 0 };
+        double snrMax { 0 };
+        double skyBgMax { 0 };
+
+        // AlignState state-machine variables.
+        AlignState lastAlignStateReceived { ALIGN_IDLE };
+        AlignState lastAlignStateStarted { ALIGN_IDLE };
+        double lastAlignStateStartedTime { -1 };
+
+        // MountState state-machine variables.
+        double mountStateStartedTime { -1 };
+        ISD::Telescope::Status lastMountState { ISD::Telescope::Status::MOUNT_IDLE };
+
+        // Mount coords state machine variables.
+        // Used to filter out mount Coords messages--we only process ones
+        // where the values have changed significantly.
+        double lastMountRa { -1 };
+        double lastMountDec { -1 };
+        double lastMountHa { -1 };
+        double lastMountAz { -1 };
+        double lastMountAlt { -1 };
+        int lastMountPierSide { -1 };
+
+        // Flip state machine variables
+        Mount::MeridianFlipStatus lastMountFlipStateReceived { Mount::FLIP_NONE};
+        Mount::MeridianFlipStatus lastMountFlipStateStarted { Mount::FLIP_NONE };
+        double mountFlipStateStartedTime { -1 };
+
+        // Y-offsets for the timeline plot for the various modules.
+        static constexpr int CAPTURE_Y = 1;
+        static constexpr int FOCUS_Y = 2;
+        static constexpr int ALIGN_Y = 3;
+        static constexpr int GUIDE_Y = 4;
+        static constexpr int MERIDIAN_FLIP_Y = 5;
+        static constexpr int MOUNT_Y = 6;
+        static constexpr int LAST_Y = 7;
+};
+}
+
+
+#endif // Analyze
diff --git a/kstars/ekos/analyze/analyze.ui b/kstars/ekos/analyze/analyze.ui
new file mode 100644
index 000000000..68ab632b1
--- /dev/null
+++ b/kstars/ekos/analyze/analyze.ui
@@ -0,0 +1,851 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>Analyze</class>
+ <widget class="QWidget" name="Analyze">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>988</width>
+    <height>824</height>
+   </rect>
+  </property>
+  <layout class="QVBoxLayout" name="AnalyzeLayout">
+   <property name="spacing">
+    <number>3</number>
+   </property>
+   <item>
+    <layout class="QHBoxLayout" name="horizontalLayout_4">
+     <item>
+      <widget class="QLabel" name="timelineLabel">
+       <property name="maximumSize">
+        <size>
+         <width>75</width>
+         <height>16777215</height>
+        </size>
+       </property>
+       <property name="text">
+        <string>Timeline</string>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <spacer name="horizontalSpacer_2">
+       <property name="orientation">
+        <enum>Qt::Horizontal</enum>
+       </property>
+       <property name="sizeType">
+        <enum>QSizePolicy::Fixed</enum>
+       </property>
+       <property name="sizeHint" stdset="0">
+        <size>
+         <width>40</width>
+         <height>20</height>
+        </size>
+       </property>
+      </spacer>
+     </item>
+     <item>
+      <widget class="QLabel" name="inputLabel">
+       <property name="maximumSize">
+        <size>
+         <width>50</width>
+         <height>30</height>
+        </size>
+       </property>
+       <property name="text">
+        <string>Input:</string>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="QComboBox" name="inputCombo">
+       <property name="maximumSize">
+        <size>
+         <width>150</width>
+         <height>30</height>
+        </size>
+       </property>
+       <property name="toolTip">
+        <string><html><head/><body><p>Select the input for the plots. This can be the current Ekos session, or it can be read from a file.</p></body></html></string>
+       </property>
+       <property name="text" stdset="0">
+        <string>--</string>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="QLabel" name="inputValue">
+       <property name="text">
+        <string/>
+       </property>
+       <property name="alignment">
+        <set>Qt::AlignCenter</set>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="QCheckBox" name="fullWidthCB">
+       <property name="maximumSize">
+        <size>
+         <width>100</width>
+         <height>16777215</height>
+        </size>
+       </property>
+       <property name="toolTip">
+        <string><html><head/><body><p>Keep the plot at the full input width.</p></body></html></string>
+       </property>
+       <property name="text">
+        <string>Full Width</string>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="QCheckBox" name="keepCurrentCB">
+       <property name="maximumSize">
+        <size>
+         <width>80</width>
+         <height>16777215</height>
+        </size>
+       </property>
+       <property name="toolTip">
+        <string><html><head/><body><p>While recording live, Keep the plot recent. That is, the most recent actions performed by Ekos are shown on the right side of the plot.</p></body></html></string>
+       </property>
+       <property name="text">
+        <string>Latest</string>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="QPushButton" name="helpB">
+       <property name="maximumSize">
+        <size>
+         <width>50</width>
+         <height>16777215</height>
+        </size>
+       </property>
+       <property name="text">
+        <string>Help</string>
+       </property>
+      </widget>
+     </item>
+    </layout>
+   </item>
+   <item>
+    <widget class="QCustomPlot" name="timelinePlot" native="true">
+     <property name="sizePolicy">
+      <sizepolicy hsizetype="Preferred" vsizetype="MinimumExpanding">
+       <horstretch>0</horstretch>
+       <verstretch>0</verstretch>
+      </sizepolicy>
+     </property>
+     <property name="minimumSize">
+      <size>
+       <width>200</width>
+       <height>150</height>
+      </size>
+     </property>
+     <property name="maximumSize">
+      <size>
+       <width>16777215</width>
+       <height>16777215</height>
+      </size>
+     </property>
+    </widget>
+   </item>
+   <item>
+    <layout class="QHBoxLayout" name="horizontalLayout_5">
+     <item>
+      <widget class="QLabel" name="statsLabel">
+       <property name="text">
+        <string>Statistics</string>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <spacer name="horizontalSpacer_3">
+       <property name="orientation">
+        <enum>Qt::Horizontal</enum>
+       </property>
+       <property name="sizeHint" stdset="0">
+        <size>
+         <width>40</width>
+         <height>20</height>
+        </size>
+       </property>
+      </spacer>
+     </item>
+     <item>
+      <widget class="QPushButton" name="zoomInB">
+       <property name="toolTip">
+        <string><html><head/><body><p>Zoom in to the x-axis on the Timeline and Statistics plots. That is, show a shorter time period.</p></body></html></string>
+       </property>
+       <property name="text">
+        <string>+</string>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="QLineEdit" name="cursorTimeOut">
+       <property name="maximumSize">
+        <size>
+         <width>80</width>
+         <height>16777215</height>
+        </size>
+       </property>
+       <property name="toolTip">
+        <string><html><head/><body><p>The number of seconds from the start of the log to the statistics cursor. If there is no cursor and latest is checked, then it is the time at the right-side of the plot.</p></body></html></string>
+       </property>
+       <property name="alignment">
+        <set>Qt::AlignCenter</set>
+       </property>
+       <property name="readOnly">
+        <bool>true</bool>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="QLineEdit" name="cursorClockTimeOut">
+       <property name="maximumSize">
+        <size>
+         <width>80</width>
+         <height>16777215</height>
+        </size>
+       </property>
+       <property name="toolTip">
+        <string><html><head/><body><p>The clock-time for the statistics plot cursor. If there is no cursor and latest is checked, then it is the clock time at the right-side of the plot.</p></body></html></string>
+       </property>
+       <property name="alignment">
+        <set>Qt::AlignCenter</set>
+       </property>
+       <property name="readOnly">
+        <bool>true</bool>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="QPushButton" name="zoomOutB">
+       <property name="toolTip">
+        <string><html><head/><body><p>Zoom out on the x-axis on the Timeline and Statistics plots. That is, show a longer time period.</p></body></html></string>
+       </property>
+       <property name="text">
+        <string>-</string>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <spacer name="horizontalSpacer">
+       <property name="orientation">
+        <enum>Qt::Horizontal</enum>
+       </property>
+       <property name="sizeHint" stdset="0">
+        <size>
+         <width>40</width>
+         <height>20</height>
+        </size>
+       </property>
+      </spacer>
+     </item>
+    </layout>
+   </item>
+   <item>
+    <widget class="QCustomPlot" name="statsPlot" native="true">
+     <property name="sizePolicy">
+      <sizepolicy hsizetype="Preferred" vsizetype="MinimumExpanding">
+       <horstretch>0</horstretch>
+       <verstretch>0</verstretch>
+      </sizepolicy>
+     </property>
+     <property name="minimumSize">
+      <size>
+       <width>200</width>
+       <height>125</height>
+      </size>
+     </property>
+     <property name="maximumSize">
+      <size>
+       <width>16777215</width>
+       <height>16777215</height>
+      </size>
+     </property>
+     <property name="font">
+      <font>
+       <pointsize>10</pointsize>
+      </font>
+     </property>
+    </widget>
+   </item>
+   <item>
+    <widget class="QScrollBar" name="analyzeSB">
+     <property name="toolTip">
+      <string><html><head/><body><p>If possible display previous (scroll to left) or future (scroll to right) sections of the Timeline and Statistics plot.</p></body></html></string>
+     </property>
+     <property name="orientation">
+      <enum>Qt::Horizontal</enum>
+     </property>
+    </widget>
+   </item>
+   <item>
+    <layout class="QGridLayout" name="gridLayout_5">
+     <item row="0" column="14">
+      <widget class="QCheckBox" name="hfrCB">
+       <property name="toolTip">
+        <string><html><head/><body><p>Plot the Half-Flux Radius (in pixels) of the captured images.</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="text">
+        <string>hfr</string>
+       </property>
+      </widget>
+     </item>
+     <item row="0" column="15">
+      <widget class="QLineEdit" name="hfrOut">
+       <property name="toolTip">
+        <string><html><head/><body><p>The Half-Flux Radius (in pixels) of the captured images.</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="alignment">
+        <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+       </property>
+       <property name="readOnly">
+        <bool>true</bool>
+       </property>
+      </widget>
+     </item>
+     <item row="0" column="8">
+      <widget class="QCheckBox" name="mountRaCB">
+       <property name="toolTip">
+        <string><html><head/><body><p>Plot the Right Ascension (RA) where the telescope is pointing.</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="text">
+        <string>mt ra</string>
+       </property>
+      </widget>
+     </item>
+     <item row="0" column="9">
+      <widget class="QLineEdit" name="mountRaOut">
+       <property name="minimumSize">
+        <size>
+         <width>70</width>
+         <height>0</height>
+        </size>
+       </property>
+       <property name="toolTip">
+        <string><html><head/><body><p>The Right Ascension (RA) where the telescope is pointing.</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 8pt</string>
+       </property>
+       <property name="alignment">
+        <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+       </property>
+       <property name="readOnly">
+        <bool>true</bool>
+       </property>
+      </widget>
+     </item>
+     <item row="1" column="8">
+      <widget class="QCheckBox" name="mountDecCB">
+       <property name="toolTip">
+        <string><html><head/><body><p>Plot the Declination (DEC) where the telescope is pointing.</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="text">
+        <string>mt dec</string>
+       </property>
+      </widget>
+     </item>
+     <item row="1" column="9">
+      <widget class="QLineEdit" name="mountDecOut">
+       <property name="minimumSize">
+        <size>
+         <width>70</width>
+         <height>0</height>
+        </size>
+       </property>
+       <property name="toolTip">
+        <string><html><head/><body><p>The Declination (DEC) where the telescope is pointing.</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 8pt</string>
+       </property>
+       <property name="alignment">
+        <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+       </property>
+       <property name="readOnly">
+        <bool>true</bool>
+       </property>
+      </widget>
+     </item>
+     <item row="0" column="10">
+      <widget class="QCheckBox" name="azCB">
+       <property name="toolTip">
+        <string><html><head/><body><p>Plot the telescope's azimuth (degrees).</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="text">
+        <string>az</string>
+       </property>
+      </widget>
+     </item>
+     <item row="0" column="11">
+      <widget class="QLineEdit" name="azOut">
+       <property name="toolTip">
+        <string><html><head/><body><p>The telescope's azimuth (degrees).</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="alignment">
+        <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+       </property>
+       <property name="readOnly">
+        <bool>true</bool>
+       </property>
+      </widget>
+     </item>
+     <item row="1" column="10">
+      <widget class="QCheckBox" name="altCB">
+       <property name="toolTip">
+        <string><html><head/><body><p>Plot the telescope's altitude (degrees).</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="text">
+        <string>alt</string>
+       </property>
+      </widget>
+     </item>
+     <item row="1" column="11">
+      <widget class="QLineEdit" name="altOut">
+       <property name="toolTip">
+        <string><html><head/><body><p>The telescope's altitude (degrees).</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="alignment">
+        <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+       </property>
+       <property name="readOnly">
+        <bool>true</bool>
+       </property>
+      </widget>
+     </item>
+     <item row="0" column="12">
+      <widget class="QCheckBox" name="pierSideCB">
+       <property name="toolTip">
+        <string><html><head/><body><p>Plot the mount's pier side.</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="text">
+        <string>pier side</string>
+       </property>
+      </widget>
+     </item>
+     <item row="0" column="13">
+      <widget class="QLineEdit" name="pierSideOut">
+       <property name="toolTip">
+        <string><html><head/><body><p>The mount's pier side.</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="alignment">
+        <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+       </property>
+       <property name="readOnly">
+        <bool>true</bool>
+       </property>
+      </widget>
+     </item>
+     <item row="1" column="12">
+      <widget class="QCheckBox" name="mountHaCB">
+       <property name="toolTip">
+        <string><html><head/><body><p>Plot the mount's hour angle value.</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="text">
+        <string>mt ha</string>
+       </property>
+      </widget>
+     </item>
+     <item row="1" column="13">
+      <widget class="QLineEdit" name="mountHaOut">
+       <property name="minimumSize">
+        <size>
+         <width>60</width>
+         <height>0</height>
+        </size>
+       </property>
+       <property name="toolTip">
+        <string><html><head/><body><p>The mount's hour angle value.</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 8pt</string>
+       </property>
+       <property name="alignment">
+        <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+       </property>
+       <property name="readOnly">
+        <bool>true</bool>
+       </property>
+      </widget>
+     </item>
+     <item row="1" column="14">
+      <widget class="QCheckBox" name="skyBgCB">
+       <property name="toolTip">
+        <string><html><head/><body><p>Plot the sky background light (computed by SEP from the guide images).</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="text">
+        <string>skyBg</string>
+       </property>
+      </widget>
+     </item>
+     <item row="1" column="15">
+      <widget class="QLineEdit" name="skyBgOut">
+       <property name="toolTip">
+        <string><html><head/><body><p>The sky background light level (computed by SEP from the guide images).</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="alignment">
+        <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+       </property>
+       <property name="readOnly">
+        <bool>true</bool>
+       </property>
+      </widget>
+     </item>
+     <item row="0" column="6">
+      <widget class="QCheckBox" name="numStarsCB">
+       <property name="toolTip">
+        <string><html><head/><body><p>Plot the number of stars detected in the guide images.</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="text">
+        <string>#stars</string>
+       </property>
+      </widget>
+     </item>
+     <item row="0" column="7">
+      <widget class="QLineEdit" name="numStarsOut">
+       <property name="toolTip">
+        <string><html><head/><body><p>The number of stars detected in the guide images.</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="alignment">
+        <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+       </property>
+       <property name="readOnly">
+        <bool>true</bool>
+       </property>
+      </widget>
+     </item>
+     <item row="1" column="6">
+      <widget class="QCheckBox" name="snrCB">
+       <property name="toolTip">
+        <string><html><head/><body><p>Plot the signal-to-noise ratio (SNR) of the guide star.</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="text">
+        <string>snr</string>
+       </property>
+      </widget>
+     </item>
+     <item row="1" column="7">
+      <widget class="QLineEdit" name="snrOut">
+       <property name="toolTip">
+        <string><html><head/><body><p>The signal-to-noise ratio (SNR) of the guide star.</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="alignment">
+        <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+       </property>
+       <property name="readOnly">
+        <bool>true</bool>
+       </property>
+      </widget>
+     </item>
+     <item row="0" column="0">
+      <widget class="QCheckBox" name="raCB">
+       <property name="toolTip">
+        <string><html><head/><body><p>Plot the right ascension (RA) drift error in arc-seconds.</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="text">
+        <string>ra err</string>
+       </property>
+      </widget>
+     </item>
+     <item row="0" column="1">
+      <widget class="QLineEdit" name="raOut">
+       <property name="toolTip">
+        <string><html><head/><body><p>The right ascension (RA) drift error in arc-seconds.</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="alignment">
+        <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+       </property>
+       <property name="readOnly">
+        <bool>true</bool>
+       </property>
+      </widget>
+     </item>
+     <item row="1" column="0">
+      <widget class="QCheckBox" name="decCB">
+       <property name="toolTip">
+        <string><html><head/><body><p>Plot the declination (DEC) drift error in arc-seconds.</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="text">
+        <string>dec err</string>
+       </property>
+      </widget>
+     </item>
+     <item row="1" column="1">
+      <widget class="QLineEdit" name="decOut">
+       <property name="toolTip">
+        <string><html><head/><body><p>Plot the declination (DEC) drift error in arc-seconds.</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="alignment">
+        <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+       </property>
+       <property name="readOnly">
+        <bool>true</bool>
+       </property>
+      </widget>
+     </item>
+     <item row="0" column="4">
+      <widget class="QCheckBox" name="raPulseCB">
+       <property name="toolTip">
+        <string><html><head/><body><p>Plot the right ascension (RA) guide pulses in milliseconds.</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="text">
+        <string>ra pulse</string>
+       </property>
+      </widget>
+     </item>
+     <item row="0" column="5">
+      <widget class="QLineEdit" name="raPulseOut">
+       <property name="toolTip">
+        <string><html><head/><body><p>The right ascension (RA) guide pulses in milliseconds.</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="alignment">
+        <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+       </property>
+       <property name="readOnly">
+        <bool>true</bool>
+       </property>
+      </widget>
+     </item>
+     <item row="1" column="4">
+      <widget class="QCheckBox" name="decPulseCB">
+       <property name="toolTip">
+        <string><html><head/><body><p>Plot the declination (DEC) guide pulses in milliseconds.</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="text">
+        <string>dec pulse</string>
+       </property>
+      </widget>
+     </item>
+     <item row="1" column="5">
+      <widget class="QLineEdit" name="decPulseOut">
+       <property name="toolTip">
+        <string><html><head/><body><p>The declination (DEC) guide pulses in milliseconds.</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="alignment">
+        <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+       </property>
+       <property name="readOnly">
+        <bool>true</bool>
+       </property>
+      </widget>
+     </item>
+     <item row="0" column="2">
+      <widget class="QCheckBox" name="driftCB">
+       <property name="toolTip">
+        <string><html><head/><body><p>Plot the combined RA and DEC drift error in arc-seconds.</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="text">
+        <string>drift</string>
+       </property>
+      </widget>
+     </item>
+     <item row="0" column="3">
+      <widget class="QLineEdit" name="driftOut">
+       <property name="toolTip">
+        <string><html><head/><body><p>The combined RA and DEC drift error in arc-seconds.</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="alignment">
+        <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+       </property>
+       <property name="readOnly">
+        <bool>true</bool>
+       </property>
+      </widget>
+     </item>
+     <item row="1" column="2">
+      <widget class="QCheckBox" name="rmsCB">
+       <property name="toolTip">
+        <string><html><head/><body><p>Plot the root-mean-squared (RMS) value of the combined RA and DEC drift in arc-seconds, averaged on approximately the past 40 samples.</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="text">
+        <string>rms</string>
+       </property>
+      </widget>
+     </item>
+     <item row="1" column="3">
+      <widget class="QLineEdit" name="rmsOut">
+       <property name="toolTip">
+        <string><html><head/><body><p>The root-mean-squared (RMS) value of the combined RA and DEC drift in arc-seconds, averaged on approximately the past 40 samples.</p></body></html></string>
+       </property>
+       <property name="styleSheet">
+        <string notr="true">font-size: 9pt</string>
+       </property>
+       <property name="alignment">
+        <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+       </property>
+       <property name="readOnly">
+        <bool>true</bool>
+       </property>
+      </widget>
+     </item>
+    </layout>
+   </item>
+   <item>
+    <widget class="QLabel" name="label">
+     <property name="text">
+      <string>Details</string>
+     </property>
+    </widget>
+   </item>
+   <item>
+    <layout class="QHBoxLayout" name="horizontalLayout_3">
+     <item>
+      <widget class="QTextEdit" name="infoBox">
+       <property name="sizePolicy">
+        <sizepolicy hsizetype="Expanding" vsizetype="Preferred">
+         <horstretch>0</horstretch>
+         <verstretch>0</verstretch>
+        </sizepolicy>
+       </property>
+       <property name="minimumSize">
+        <size>
+         <width>0</width>
+         <height>125</height>
+        </size>
+       </property>
+       <property name="maximumSize">
+        <size>
+         <width>16777215</width>
+         <height>175</height>
+        </size>
+       </property>
+       <property name="font">
+        <font>
+         <pointsize>10</pointsize>
+        </font>
+       </property>
+       <property name="text" stdset="0">
+        <string>View information about the selected item</string>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="QCustomPlot" name="graphicsPlot" native="true">
+       <property name="enabled">
+        <bool>true</bool>
+       </property>
+       <property name="minimumSize">
+        <size>
+         <width>400</width>
+         <height>125</height>
+        </size>
+       </property>
+       <property name="maximumSize">
+        <size>
+         <width>16777215</width>
+         <height>175</height>
+        </size>
+       </property>
+       <property name="font">
+        <font>
+         <pointsize>10</pointsize>
+        </font>
+       </property>
+      </widget>
+     </item>
+    </layout>
+   </item>
+  </layout>
+ </widget>
+ <customwidgets>
+  <customwidget>
+   <class>QCustomPlot</class>
+   <extends>QWidget</extends>
+   <header>qcustomplot.h</header>
+   <container>1</container>
+  </customwidget>
+ </customwidgets>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/kstars/ekos/capture/capture.cpp b/kstars/ekos/capture/capture.cpp
index 07c2c9556..8c43709b2 100644
--- a/kstars/ekos/capture/capture.cpp
+++ b/kstars/ekos/capture/capture.cpp
@@ -687,6 +687,7 @@ void Capture::stop(CaptureState targetState)
                     stopText = i18n("CCD capture aborted");
                     break;
             }
+            emit captureAborted(activeJob->getExposure());
             KSNotification::event(QLatin1String("CaptureFailed"), stopText);
             appendLogText(stopText);
             activeJob->abort();
@@ -1777,6 +1778,10 @@ IPState Capture::setCaptureComplete()
 
     appendLogText(i18n("Received image %1 out of %2.", activeJob->getCompleted(), activeJob->getCount()));
 
+    FITSView * currentImage = targetChip->getImageView(FITS_NORMAL);
+    double hfr = currentImage ? currentImage->getImageData()->getHFR(HFR_AVERAGE) : 0;
+    emit captureComplete(blobFilename, activeJob->getExposure(), activeJob->getFilterName(), hfr);
+
     m_State = CAPTURE_IMAGE_RECEIVED;
     emit newStatus(Ekos::CAPTURE_IMAGE_RECEIVED);
 
@@ -2237,6 +2242,7 @@ void Capture::captureImage()
     {
         case SequenceJob::CAPTURE_OK:
         {
+            emit captureStarting(activeJob->getExposure(), activeJob->getFilterName());
             appendLogText(i18n("Capturing %1-second %2 image...", QString("%L1").arg(activeJob->getExposure(), 0, 'f', 3),
                                activeJob->getFilterName()));
             captureTimeout.start(static_cast<int>(activeJob->getExposure()) * 1000 + CAPTURE_TIMEOUT_THRESHOLD);
diff --git a/kstars/ekos/capture/capture.h b/kstars/ekos/capture/capture.h
index e5af0ce5b..798418c34 100644
--- a/kstars/ekos/capture/capture.h
+++ b/kstars/ekos/capture/capture.h
@@ -757,6 +757,12 @@ class Capture : public QWidget, public Ui::Capture
         void dslrInfoRequested(const QString &cameraName);
         void driverTimedout(const QString &deviceName);
 
+        // Signals for the Analyze tab.
+        void captureComplete(const QString &filename, double exposureSeconds,
+                             const QString &filter, double hfr);
+        void captureStarting(double exposureSeconds, const QString &filter);
+        void captureAborted(double exposureSeconds);
+
     private:
         void setBusy(bool enable);
         IPState resumeSequence();
diff --git a/kstars/ekos/focus/focus.cpp b/kstars/ekos/focus/focus.cpp
index de978ebc9..7068ca37f 100644
--- a/kstars/ekos/focus/focus.cpp
+++ b/kstars/ekos/focus/focus.cpp
@@ -749,6 +749,9 @@ void Focus::start()
                                 << " Tolerance: " << toleranceIN->value()
                                 << " Frames: " << 1 /*focusFramesSpin->value()*/ << " Maximum Travel: " << maxTravelIN->value();
 
+    emit autofocusStarting(focuserTemperature != INVALID_VALUE
+                           ? focuserTemperature : observatoryTemperature, filter());
+
     if (useAutoStar->isChecked())
         appendLogText(i18n("Autofocus in progress..."));
     else
@@ -834,6 +837,17 @@ void Focus::checkStopFocus()
 
 void Focus::abort()
 {
+    QString str = "";
+    const int size = hfr_position.size();
+    for (int i = 0; i < size; ++i)
+    {
+        str.append(QString("%1%2|%3")
+                   .arg(i == 0 ? "" : "|" )
+                   .arg(QString::number(hfr_position[i], 'f', 0))
+                   .arg(QString::number(hfr_value[i], 'f', 3)));
+    }
+
+    emit autofocusAborted(filter(), str);
     stop(true);
 }
 
@@ -1260,6 +1274,20 @@ bool Focus::appendHFR(double newHFR)
     return HFRFrames.count() < focusFramesSpin->value();
 }
 
+void Focus::emitComplete()
+{
+    QString str = "";
+    const int size = hfr_position.size();
+    for (int i = 0; i < size; ++i)
+    {
+        str.append(QString("%1%2|%3")
+                   .arg(i == 0 ? "" : "|" )
+                   .arg(QString::number(hfr_position[i], 'f', 0))
+                   .arg(QString::number(hfr_value[i], 'f', 3)));
+    }
+    emit autofocusComplete(filter(), str);
+}
+
 void Focus::setCaptureComplete()
 {
     DarkLibrary::Instance()->disconnect(this);
@@ -1333,6 +1361,7 @@ void Focus::setCaptureComplete()
             if (focusAlgorithm == FOCUS_POLYNOMIAL && polySolutionFound == MINIMUM_POLY_SOLUTIONS)
             {
                 polySolutionFound = 0;
+                emitComplete();
                 appendLogText(i18n("Autofocus complete after %1 iterations.", hfr_position.count()));
                 stop();
                 setAutoFocusResult(true);
@@ -1903,6 +1932,7 @@ void Focus::autoFocusLinear()
     {
         if (linearFocuser->isDone() && linearFocuser->solution() != -1)
         {
+            emitComplete();
             appendLogText(i18np("Autofocus complete after %1 iteration.",
                                 "Autofocus complete after %1 iterations.", hfr_position.count()));
             stop();
@@ -1998,6 +2028,7 @@ void Focus::autoFocusAbs()
                 }
                 else
                 {
+                    emitComplete();
                     appendLogText(i18n("Autofocus complete after %1 iterations.", hfr_position.count()));
                     stop();
                     setAutoFocusResult(true);
@@ -2187,6 +2218,7 @@ void Focus::autoFocusAbs()
             // Ops, we can't go any further, we're done.
             if (targetPosition == currentPosition)
             {
+                emitComplete();
                 appendLogText(i18n("Autofocus complete after %1 iterations.", hfr_position.count()));
                 stop();
                 setAutoFocusResult(true);
@@ -2309,6 +2341,7 @@ void Focus::autoFocusRel()
         case FOCUS_OUT:
             if (fabs(currentHFR - minHFR) < (toleranceIN->value() / 100.0) && HFRInc == 0)
             {
+                emitComplete();
                 appendLogText(i18n("Autofocus complete after %1 iterations.", hfr_position.count()));
                 stop();
                 setAutoFocusResult(true);
diff --git a/kstars/ekos/focus/focus.h b/kstars/ekos/focus/focus.h
index 8de331ced..aefd30c72 100644
--- a/kstars/ekos/focus/focus.h
+++ b/kstars/ekos/focus/focus.h
@@ -387,6 +387,10 @@ class Focus : public QWidget, public Ui::Focus
         void newStarPixmap(QPixmap &);
         void newProfilePixmap(QPixmap &);
 
+        // Signals for Analyze.
+        void autofocusStarting(double temperature, const QString &filter);
+        void autofocusComplete(const QString &filter, const QString &points);
+        void autofocusAborted(const QString &filter, const QString &points);
     private:
 
         ////////////////////////////////////////////////////////////////////
@@ -462,6 +466,12 @@ class Focus : public QWidget, public Ui::Focus
          */
         bool appendHFR(double newHFR);
 
+
+        /**
+         * @brief emitComplete emits the message needed for Analyze when focus completes.
+         */
+        void emitComplete();
+
         void initializeFocuserTemperature();
         void setLastFocusTemperature();
         void updateTemperature(TemperatureSource source, double newTemperature);
diff --git a/kstars/ekos/guide/externalguide/phd2.cpp b/kstars/ekos/guide/externalguide/phd2.cpp
index 68939b06c..ed03658cc 100644
--- a/kstars/ekos/guide/externalguide/phd2.cpp
+++ b/kstars/ekos/guide/externalguide/phd2.cpp
@@ -483,6 +483,10 @@ void PHD2::processPHD2Event(const QJsonObject &jsonEvent, const QByteArray &line
                 emit newAxisDelta(diff_ra_arcsecs, diff_de_arcsecs);
                 emit newAxisPulse(pulse_ra, pulse_dec);
 
+                // Does PHD2 real a sky background or num-stars measure?
+                emit guideStats(diff_ra_arcsecs, diff_de_arcsecs, pulse_ra, pulse_dec,
+                                std::isfinite(snr) ? snr : 0, 0, 0);
+
                 double total_sqr_RA_error = 0.0;
                 double total_sqr_DE_error = 0.0;
 
diff --git a/kstars/ekos/guide/guide.cpp b/kstars/ekos/guide/guide.cpp
index acfc87bf0..ab83595a8 100644
--- a/kstars/ekos/guide/guide.cpp
+++ b/kstars/ekos/guide/guide.cpp
@@ -1842,8 +1842,10 @@ void Guide::setMountStatus(ISD::Telescope::Status newState)
     }
 }
 
-void Guide::setMountCoords(const QString &ra, const QString &dec, const QString &az, const QString &alt, int pierSide)
+void Guide::setMountCoords(const QString &ra, const QString &dec, const QString &az, const QString &alt, int pierSide,
+                           const QString &ha)
 {
+    Q_UNUSED(ha);
     guider->setMountCoords(ra, dec, az, alt, pierSide);
 }
 
@@ -2366,6 +2368,7 @@ bool Guide::setGuiderType(int type)
         connect(guider, &Ekos::GuideInterface::newLog, this, &Ekos::Guide::appendLogText);
         connect(guider, &Ekos::GuideInterface::newStatus, this, &Ekos::Guide::setStatus);
         connect(guider, &Ekos::GuideInterface::newStarPosition, this, &Ekos::Guide::setStarPosition);
+        connect(guider, &Ekos::GuideInterface::guideStats, this, &Ekos::Guide::guideStats);
 
         connect(guider, &Ekos::GuideInterface::newAxisDelta, this, &Ekos::Guide::setAxisDelta);
         connect(guider, &Ekos::GuideInterface::newAxisPulse, this, &Ekos::Guide::setAxisPulse);
diff --git a/kstars/ekos/guide/guide.h b/kstars/ekos/guide/guide.h
index 29fda48aa..312da8c6d 100644
--- a/kstars/ekos/guide/guide.h
+++ b/kstars/ekos/guide/guide.h
@@ -377,7 +377,8 @@ class Guide : public QWidget, public Ui::Guide
         void setCaptureStatus(Ekos::CaptureState newState);
         // Update Mount module status
         void setMountStatus(ISD::Telescope::Status newState);
-        void setMountCoords(const QString &ra, const QString &dec, const QString &az, const QString &alt, int pierSide);
+        void setMountCoords(const QString &ra, const QString &dec, const QString &az, const QString &alt, int pierSide,
+                            const QString &ha);
 
         // Update Pier Side
         void setPierSide(ISD::Telescope::PierSide newSide);
@@ -493,6 +494,9 @@ class Guide : public QWidget, public Ui::Guide
         // Sigma deviations in arcsecs RMS
         void newAxisSigma(double ra, double de);
 
+        void guideStats(double raError, double decError, int raPulse, int decPulse,
+                        double snr, double skyBg, int numStars);
+
         void guideChipUpdated(ISD::CCDChip *);
 
         void driverTimedout(const QString &deviceName);
diff --git a/kstars/ekos/guide/guideinterface.h b/kstars/ekos/guide/guideinterface.h
index d0cef41a1..af9870154 100644
--- a/kstars/ekos/guide/guideinterface.h
+++ b/kstars/ekos/guide/guideinterface.h
@@ -86,7 +86,8 @@ class GuideInterface : public QObject
         void newSNR(double snr);
         void calibrationUpdate(CalibrationUpdateType type, const QString &message = QString(""), double x = 0, double y = 0);
         void frameCaptureRequested();
-
+        void guideStats(double raError, double decError, int raPulse, int decPulse,
+                        double snr, double skyBg, int numStars);
         void guideEquipmentUpdated();
 
     protected:
diff --git a/kstars/ekos/guide/internalguide/gmath.cpp b/kstars/ekos/guide/internalguide/gmath.cpp
index 7982f9613..fa7aa0fd1 100644
--- a/kstars/ekos/guide/internalguide/gmath.cpp
+++ b/kstars/ekos/guide/internalguide/gmath.cpp
@@ -1180,6 +1180,9 @@ void cgmath::performProcessing(GuideLog *logger, bool guiding)
         // process statistics
         calc_square_err();
 
+        if (guiding)
+            emitStats();
+
         // finally process tickers
         do_ticks();
     }
@@ -1218,6 +1221,28 @@ void cgmath::performProcessing(GuideLog *logger, bool guiding)
     qCDebug(KSTARS_EKOS_GUIDE) << "################## FINISH PROCESSING ##################";
 }
 
+void cgmath::emitStats()
+{
+    double pulseRA = 0;
+    if (out_params.pulse_dir[GUIDE_RA] == RA_DEC_DIR)
+        pulseRA = out_params.pulse_length[GUIDE_RA];
+    else if (out_params.pulse_dir[GUIDE_RA] == RA_INC_DIR)
+        pulseRA = -out_params.pulse_length[GUIDE_RA];
+    double pulseDEC = 0;
+    if (out_params.pulse_dir[GUIDE_DEC] == DEC_DEC_DIR)
+        pulseDEC = -out_params.pulse_length[GUIDE_DEC];
+    else if (out_params.pulse_dir[GUIDE_DEC] == DEC_INC_DIR)
+        pulseDEC = out_params.pulse_length[GUIDE_DEC];
+
+    const bool hasGuidestars = (square_alg_idx == SEP_MULTISTAR);
+    const double snr = hasGuidestars ? guideStars.getGuideStarSNR() : 0;
+    const double skyBG = hasGuidestars ? guideStars.skybackground().mean : 0;
+    const int numStars = hasGuidestars ? guideStars.skybackground().starsDetected : 0;  // wait for rob's release
+
+    emit guideStats(-out_params.delta[GUIDE_RA], -out_params.delta[GUIDE_DEC],
+                    pulseRA, pulseDEC, snr, skyBG, numStars);
+}
+
 void cgmath::calc_square_err(void)
 {
     if (!do_statistics)
diff --git a/kstars/ekos/guide/internalguide/gmath.h b/kstars/ekos/guide/internalguide/gmath.h
index 7daaaa3bf..221602563 100644
--- a/kstars/ekos/guide/internalguide/gmath.h
+++ b/kstars/ekos/guide/internalguide/gmath.h
@@ -197,6 +197,10 @@ class cgmath : public QObject
         void newAxisDelta(double delta_ra, double delta_dec);
         void newStarPosition(QVector3D, bool);
 
+        // For Analyze.
+        void guideStats(double raError, double decError, int raPulse, int decPulse,
+                        double snr, double skyBg, int numStars);
+
     private:
         // Templated functions
         template <typename T>
@@ -214,6 +218,9 @@ class cgmath : public QObject
         // Old-stye Logging--deprecate.
         void createGuideLog();
 
+        // For Analyze.
+        void emitStats();
+
         /// Global channel ticker
         uint32_t ticks { 0 };
         /// Pointer to image
diff --git a/kstars/ekos/guide/internalguide/internalguider.cpp b/kstars/ekos/guide/internalguide/internalguider.cpp
index 07a5d08e4..20f21a5bd 100644
--- a/kstars/ekos/guide/internalguide/internalguider.cpp
+++ b/kstars/ekos/guide/internalguide/internalguider.cpp
@@ -34,6 +34,7 @@ InternalGuider::InternalGuider()
     // Create math object
     pmath.reset(new cgmath());
     connect(pmath.get(), &cgmath::newStarPosition, this, &InternalGuider::newStarPosition);
+    connect(pmath.get(), &cgmath::guideStats, this, &InternalGuider::guideStats);
 
     // Do this so that stored calibration will be visible on the
     // guide options menu. Calibration will get restored again when needed.
diff --git a/kstars/ekos/manager.cpp b/kstars/ekos/manager.cpp
index d4ba96cb3..38e779ea3 100644
--- a/kstars/ekos/manager.cpp
+++ b/kstars/ekos/manager.cpp
@@ -120,6 +120,9 @@ Manager::Manager(QWidget * parent) : QDialog(parent)
     // Enable scheduler Tab
     toolsWidget->setTabEnabled(1, false);
 
+    // Enable analyze Tab
+    toolsWidget->setTabEnabled(2, false);
+
     // Start/Stop INDI Server
     connect(processINDIB, &QPushButton::clicked, this, &Ekos::Manager::processINDI);
     processINDIB->setIcon(QIcon::fromTheme("media-playback-start"));
@@ -281,8 +284,8 @@ Manager::Manager(QWidget * parent) : QDialog(parent)
 
     // Initialize Ekos Scheduler Module
     schedulerProcess.reset(new Ekos::Scheduler());
-    toolsWidget->addTab(schedulerProcess.get(), QIcon(":/icons/ekos_scheduler.png"), "");
-    toolsWidget->tabBar()->setTabToolTip(1, i18n("Scheduler"));
+    int index = toolsWidget->addTab(schedulerProcess.get(), QIcon(":/icons/ekos_scheduler.png"), "");
+    toolsWidget->tabBar()->setTabToolTip(index, i18n("Scheduler"));
     connect(schedulerProcess.get(), &Scheduler::newLog, this, &Ekos::Manager::updateLog);
     //connect(schedulerProcess.get(), SIGNAL(newTarget(QString)), mountTarget, SLOT(setText(QString)));
     connect(schedulerProcess.get(), &Ekos::Scheduler::newTarget, [&](const QString & target)
@@ -291,6 +294,13 @@ Manager::Manager(QWidget * parent) : QDialog(parent)
         ekosLiveClient.get()->message()->updateMountStatus(QJsonObject({{"target", target}}));
     });
 
+    // Initialize Ekos Analyze Module
+    analyzeProcess.reset(new Ekos::Analyze());
+    index = toolsWidget->addTab(analyzeProcess.get(), QIcon(":/icons/ekos_analyze.png"), "");
+    toolsWidget->tabBar()->setTabToolTip(index, i18n("Analyze"));
+
+    numPermanentTabs = index + 1;
+
     // Temporary fix. Not sure how to resize Ekos Dialog to fit contents of the various tabs in the QScrollArea which are added
     // dynamically. I used setMinimumSize() but it doesn't appear to make any difference.
     // Also set Layout policy to SetMinAndMaxSize as well. Any idea how to fix this?
@@ -323,15 +333,13 @@ Manager::Manager(QWidget * parent) : QDialog(parent)
         QTransform trans;
         trans.rotate(90);
 
-        QIcon icon  = toolsWidget->tabIcon(0);
-        QPixmap pix = icon.pixmap(QSize(48, 48));
-        icon        = QIcon(pix.transformed(trans));
-        toolsWidget->setTabIcon(0, icon);
-
-        icon = toolsWidget->tabIcon(1);
-        pix  = icon.pixmap(QSize(48, 48));
-        icon = QIcon(pix.transformed(trans));
-        toolsWidget->setTabIcon(1, icon);
+        for (int i = 0; i < numPermanentTabs; ++i)
+        {
+            QIcon icon  = toolsWidget->tabIcon(i);
+            QPixmap pix = icon.pixmap(QSize(48, 48));
+            icon        = QIcon(pix.transformed(trans));
+            toolsWidget->setTabIcon(i, icon);
+        }
     }
 
     //Note:  This is to prevent a button from being called the default button
@@ -2577,7 +2585,7 @@ void Manager::removeTabs()
 {
     disconnect(toolsWidget, &QTabWidget::currentChanged, this, &Ekos::Manager::processTabChange);
 
-    for (int i = 2; i < toolsWidget->count(); i++)
+    for (int i = numPermanentTabs; i < toolsWidget->count(); i++)
         toolsWidget->removeTab(i);
 
     alignProcess.reset();
@@ -2869,8 +2877,11 @@ void Manager::updateMountStatus(ISD::Telescope::Status status)
     ekosLiveClient.get()->message()->updateMountStatus(cStatus);
 }
 
-void Manager::updateMountCoords(const QString &ra, const QString &dec, const QString &az, const QString &alt)
+void Manager::updateMountCoords(const QString &ra, const QString &dec, const QString &az, const QString &alt,
+                                int pierSide, const QString &ha)
 {
+    Q_UNUSED(ha);
+    Q_UNUSED(pierSide);
     raOUT->setText(ra);
     decOUT->setText(dec);
     azOUT->setText(az);
@@ -3483,6 +3494,62 @@ void Manager::connectModules()
         connect(alignProcess.get(), &Ekos::Align::newCorrectionVector, ekosLiveClient.get()->media(),
                 &EkosLive::Media::setCorrectionVector);
     }
+    // Analyze connections.
+    if (analyzeProcess.get())
+    {
+        if (captureProcess.get())
+        {
+            connect(captureProcess.get(), &Ekos::Capture::captureComplete,
+                    analyzeProcess.get(), &Ekos::Analyze::captureComplete, Qt::UniqueConnection);
+            connect(captureProcess.get(), &Ekos::Capture::captureStarting,
+                    analyzeProcess.get(), &Ekos::Analyze::captureStarting, Qt::UniqueConnection);
+            connect(captureProcess.get(), &Ekos::Capture::captureAborted,
+                    analyzeProcess.get(), &Ekos::Analyze::captureAborted, Qt::UniqueConnection);
+#if 0
+            // Meridian Flip
+            connect(captureProcess.get(), &Ekos::Capture::meridianFlipStarted,
+                    analyzeProcess.get(), &Ekos::Analyze::meridianFlipStarted, Qt::UniqueConnection);
+            connect(captureProcess.get(), &Ekos::Capture::meridianFlipCompleted,
+                    analyzeProcess.get(), &Ekos::Analyze::meridianFlipComplete, Qt::UniqueConnection);
+#endif
+        }
+        if (guideProcess.get())
+        {
+            connect(guideProcess.get(), &Ekos::Guide::newStatus,
+                    analyzeProcess.get(), &Ekos::Analyze::guideState, Qt::UniqueConnection);
+
+            connect(guideProcess.get(), &Ekos::Guide::guideStats,
+                    analyzeProcess.get(), &Ekos::Analyze::guideStats, Qt::UniqueConnection);
+        }
+    }
+    if (focusProcess.get())
+    {
+        connect(focusProcess.get(), &Ekos::Focus::autofocusComplete,
+                analyzeProcess.get(), &Ekos::Analyze::autofocusComplete, Qt::UniqueConnection);
+        connect(focusProcess.get(), &Ekos::Focus::autofocusStarting,
+                analyzeProcess.get(), &Ekos::Analyze::autofocusStarting, Qt::UniqueConnection);
+        connect(focusProcess.get(), &Ekos::Focus::autofocusAborted,
+                analyzeProcess.get(), &Ekos::Analyze::autofocusAborted, Qt::UniqueConnection);
+    }
+    if (alignProcess.get())
+    {
+        connect(alignProcess.get(), &Ekos::Align::newStatus,
+                analyzeProcess.get(), &Ekos::Analyze::alignState, Qt::UniqueConnection);
+
+    }
+    if (mountProcess.get())
+    {
+        // void newStatus(ISD::Telescope::Status status);
+        connect(mountProcess.get(), &Ekos::Mount::newStatus,
+                analyzeProcess.get(), &Ekos::Analyze::mountState, Qt::UniqueConnection);
+        //void newCoords(const QString &ra, const QString &dec,
+        //               const QString &az, const QString &alt, int pierSide);
+        connect(mountProcess.get(), &Ekos::Mount::newCoords,
+                analyzeProcess.get(), &Ekos::Analyze::mountCoords, Qt::UniqueConnection);
+        // void newMeridianFlipStatus(MeridianFlipStatus status);
+        connect(mountProcess.get(), &Ekos::Mount::newMeridianFlipStatus,
+                analyzeProcess.get(), &Ekos::Analyze::mountFlipStatus, Qt::UniqueConnection);
+    }
 }
 
 void Manager::setEkosLiveConnected(bool enabled)
@@ -3586,7 +3653,7 @@ void Manager::syncActiveDevices()
     }
 }
 
-bool Manager::checkUniqueBinaryDriver(DriverInfo *primaryDriver, DriverInfo *secondaryDriver)
+bool Manager::checkUniqueBinaryDriver(DriverInfo * primaryDriver, DriverInfo * secondaryDriver)
 {
     if (!primaryDriver || !secondaryDriver)
         return false;
diff --git a/kstars/ekos/manager.h b/kstars/ekos/manager.h
index f4c2dc304..6a7892d06 100644
--- a/kstars/ekos/manager.h
+++ b/kstars/ekos/manager.h
@@ -28,6 +28,7 @@
 #include "indi/indistd.h"
 #include "mount/mount.h"
 #include "scheduler/scheduler.h"
+#include "analyze/analyze.h"
 #include "observatory/observatory.h"
 #include "auxiliary/filtermanager.h"
 #include "auxiliary/serialportassistant.h"
@@ -390,7 +391,8 @@ class Manager : public QDialog, public Ui::Manager
         void wizardProfile();
 
         // Mount Summary
-        void updateMountCoords(const QString &ra, const QString &dec, const QString &az, const QString &alt);
+        void updateMountCoords(const QString &ra, const QString &dec, const QString &az, const QString &alt, int pierSide,
+                               const QString &ha);
         void updateMountStatus(ISD::Telescope::Status status);
         void setTarget(SkyObject *o);
 
@@ -490,6 +492,7 @@ class Manager : public QDialog, public Ui::Manager
         std::unique_ptr<Guide> guideProcess;
         std::unique_ptr<Align> alignProcess;
         std::unique_ptr<Mount> mountProcess;
+        std::unique_ptr<Analyze> analyzeProcess;
         std::unique_ptr<Scheduler> schedulerProcess;
         std::unique_ptr<Observatory> observatoryProcess;
         std::unique_ptr<Dome> domeProcess;
@@ -546,6 +549,9 @@ class Manager : public QDialog, public Ui::Manager
         // Logs
         QPointer<OpsLogs> opsLogs;
 
+        // E.g. Setup, Scheduler, and Analyze.
+        int numPermanentTabs { 0 };
+
         friend class EkosLive::Client;
         friend class EkosLive::Message;
         friend class EkosLive::Media;
diff --git a/kstars/ekos/mount/mount.cpp b/kstars/ekos/mount/mount.cpp
index 5b2885ccd..215126a20 100644
--- a/kstars/ekos/mount/mount.cpp
+++ b/kstars/ekos/mount/mount.cpp
@@ -645,8 +645,9 @@ void Mount::updateTelescopeCoords()
         lastAlt = currentAlt;
         lastHa = hourAngle();
 
+        dms ha2(lst - telescopeCoord.ra());
         emit newCoords(raOUT->text(), decOUT->text(), azOUT->text(), altOUT->text(),
-                       currentTelescope->pierSide());
+                       currentTelescope->pierSide(), ha2.toHMSString());
 
         ISD::Telescope::Status currentStatus = currentTelescope->status();
         if (m_Status != currentStatus)
diff --git a/kstars/ekos/mount/mount.h b/kstars/ekos/mount/mount.h
index aacc3a9b3..481a7b9e9 100644
--- a/kstars/ekos/mount/mount.h
+++ b/kstars/ekos/mount/mount.h
@@ -426,8 +426,8 @@ class Mount : public QWidget, public Ui::Mount
 
     signals:
         void newLog(const QString &text);
-        void newCoords(const QString &ra, const QString &dec,
-                       const QString &az, const QString &alt, int pierSide);
+        void newCoords(const QString &ra, const QString &dec, const QString &az,
+                       const QString &alt, int pierSide, const QString &ha);
         void newTarget(const QString &name);
         void newStatus(ISD::Telescope::Status status);
         void newParkStatus(ISD::ParkStatus status);
diff --git a/kstars/fitsviewer/fitsdata.cpp b/kstars/fitsviewer/fitsdata.cpp
index 4d7aa078a..0a05b8ae1 100644
--- a/kstars/fitsviewer/fitsdata.cpp
+++ b/kstars/fitsviewer/fitsdata.cpp
@@ -985,9 +985,22 @@ int FITSData::findStars(StarAlgorithm algorithm, const QRect &trackingBox)
     switch (algorithm)
     {
         case ALGORITHM_SEP:
-            count = FITSSEPDetector(this)
-                    .findSources(starCenters, trackingBox);
+        {
+            if (m_Mode == FITS_NORMAL && trackingBox.isNull() && Options::quickHFR())
+            {
+                // Just finds stars in the center 25% of the image.
+                const int w = getStatistics().width;
+                const int h = getStatistics().height;
+                QRect middle(static_cast<int>(w * 0.25), static_cast<int>(h * 0.25), w / 2, h / 2);
+                count = FITSSEPDetector(this)
+                        .configure("radiusIsBoundary", "false") // need this with QRect.
+                        .findSources(starCenters, middle);
+            }
+            else
+                count = FITSSEPDetector(this)
+                        .findSources(starCenters, trackingBox);
             break;
+        }
 
         case ALGORITHM_GRADIENT:
             count = FITSGradientDetector(this)
@@ -2504,7 +2517,7 @@ bool FITSData::checkDebayer()
     // Let's search for BAYERPAT keyword, if it's not found we return as there is no bayer pattern in this image
     if (fits_read_keyword(fptr, "BAYERPAT", bayerPattern, nullptr, &status))
         return false;
-        
+
     fits_read_keyword(fptr, "ROWORDER", roworder, nullptr, &status);
 
     if (stats.bitpix != 16 && stats.bitpix != 8)
@@ -2514,11 +2527,12 @@ bool FITSData::checkDebayer()
     }
     QString pattern(bayerPattern);
     pattern = pattern.remove('\'').trimmed();
-    
+
     QString order(roworder);
     order = order.remove('\'').trimmed();
-    
-    if (order == "BOTTOM-UP") {
+
+    if (order == "BOTTOM-UP")
+    {
         if (pattern == "RGGB")
             pattern = "GBRG";
         else if (pattern == "GBRG")
diff --git a/kstars/fitsviewer/fitssepdetector.cpp b/kstars/fitsviewer/fitssepdetector.cpp
index 87b84be19..c089c44fd 100644
--- a/kstars/fitsviewer/fitssepdetector.cpp
+++ b/kstars/fitsviewer/fitssepdetector.cpp
@@ -33,6 +33,8 @@ FITSSEPDetector &FITSSEPDetector::configure(const QString &param, const QVariant
         deblendNThresh = value.toInt();
     else if (param == "deblendMincont")
         deblendMincont = value.toDouble();
+    else if (param == "radiusIsBoundary")
+        radiusIsBoundary = value.toBool();
     else
         qCDebug(KSTARS_FITS) << "Bad SEP Parameter!!!!! " << param;
     return *this;
@@ -76,7 +78,8 @@ int FITSSEPDetector::findSourcesAndBackground(QList<Edge*> &starCenters, QRect c
         y = boundary.y();
         w = boundary.width();
         h = boundary.height();
-        maxRadius = w;
+        if (radiusIsBoundary)
+            maxRadius = w;
     }
 
     auto * data = new float[w * h];
@@ -99,7 +102,10 @@ int FITSSEPDetector::findSourcesAndBackground(QList<Edge*> &starCenters, QRect c
             getFloatBuffer<uint32_t>(data, x, y, w, h, image_data);
             break;
         case TFLOAT:
-            memcpy(data, image_data->getImageBuffer(), sizeof(float)*w * h);
+            if (boundary.isNull())
+                memcpy(data, image_data->getImageBuffer(), sizeof(float)*w * h);
+            else
+                getFloatBuffer<float>(data, x, y, w, h, image_data);
             break;
         case TLONGLONG:
             getFloatBuffer<int64_t>(data, x, y, w, h, image_data);
@@ -148,6 +154,8 @@ int FITSSEPDetector::findSourcesAndBackground(QList<Edge*> &starCenters, QRect c
                          deblendNThresh, deblendMincont, 1, 1.0, &catalog);
     if (status != 0) goto exit;
     qCDebug(KSTARS_FITS) << "SEP detected " << catalog->nobj << " stars.";
+    if (bg != nullptr)
+        bg->setStarsDetected(catalog->nobj);
 
     // Skip the 20% largest stars if we have plenty.
     if (catalog->nobj * (1 - fractionRemoved) > maxNumCenters)
@@ -257,12 +265,14 @@ SkyBackground::SkyBackground(double mean_, double sigma_, double numPixels_)
     initialize(mean_, sigma_, numPixels_);
 }
 
-void SkyBackground::initialize(double mean_, double sigma_, double numPixelsInSkyEstimate_)
+void SkyBackground::initialize(double mean_, double sigma_,
+                               double numPixelsInSkyEstimate_, int numStars_)
 {
     mean = mean_;
     sigma = sigma_;
     numPixelsInSkyEstimate = numPixelsInSkyEstimate_;
     varSky = sigma_ * sigma_;
+    starsDetected = numStars_;
 }
 
 // Taken from: http://www1.phys.vt.edu/~jhs/phys3154/snr20040108.pdf
diff --git a/kstars/fitsviewer/fitssepdetector.h b/kstars/fitsviewer/fitssepdetector.h
index 4aaf6a993..1d8063b36 100644
--- a/kstars/fitsviewer/fitssepdetector.h
+++ b/kstars/fitsviewer/fitssepdetector.h
@@ -26,65 +26,75 @@
 
 class SkyBackground
 {
-public:
-    // Must call initialize() if using the default constructor;
-    SkyBackground() {}
-    SkyBackground(double m, double sig, double np);
-    virtual ~SkyBackground() = default;
-
-    // Mean of the background level (ADUs).
-    double mean {0};
-    // Standard deviation of the background level.
-    double sigma {0};
-    // Number of pixels used to estimate the background level.
-    int numPixelsInSkyEstimate {0};
-
-    // Given a source with flux spread over numPixels, and a CCD with gain = ADU/#electron)
-    // returns an SNR estimate.
-    double SNR(double flux, double numPixels, double gain = 0.5);
-    void initialize(double mean_, double sigma_, double numPixelsInSkyEstimate_);
-private:
-    double varSky;
+    public:
+        // Must call initialize() if using the default constructor;
+        SkyBackground() {}
+        SkyBackground(double m, double sig, double np);
+        virtual ~SkyBackground() = default;
+
+        // Mean of the background level (ADUs).
+        double mean {0};
+        // Standard deviation of the background level.
+        double sigma {0};
+        // Number of pixels used to estimate the background level.
+        int numPixelsInSkyEstimate {0};
+
+        // Number of stars detected in the sky. A relative measure of sky quality
+        // (compared with the same part of the sky at a different time).
+        int starsDetected {0};
+
+        // Given a source with flux spread over numPixels, and a CCD with gain = ADU/#electron)
+        // returns an SNR estimate.
+        double SNR(double flux, double numPixels, double gain = 0.5);
+        void initialize(double mean_, double sigma_, double numPixelsInSkyEstimate_, int numStars_ = 0);
+        void setStarsDetected(int numStars)
+        {
+            starsDetected = numStars;
+        }
+
+    private:
+        double varSky;
 };
 
 class FITSSEPDetector : public FITSStarDetector
 {
-    Q_OBJECT
-
-public:
-    explicit FITSSEPDetector(FITSData *parent): FITSStarDetector(parent) {};
-
-public:
-    /** @brief Find sources in the parent FITS data file.
-     * @see FITSStarDetector::findSources().
-     */
-    int findSources(QList<Edge*> &starCenters, QRect const &boundary = QRect()) override;
-
-    /** @brief Find sources in the parent FITS data file as well as background sky information.
-     */
-    int findSourcesAndBackground(QList<Edge*> &starCenters, QRect const &boundary = QRect(),
-                                 SkyBackground *bg = nullptr);
-
-    /** @brief Configure the detection method.
-     * @see FITSStarDetector::configure().
-     * @note No parameters are currently available for configuration.
-     * @todo Provide parameters for detection configuration.
-     */
-    FITSSEPDetector & configure(const QString &setting, const QVariant &value) override;
-
-protected:
-    /** @internal Consolidate a float data buffer from FITS data.
-     * @param buffer is the destination float block.
-     * @param x, y, w, h define a (x,y)-(x+w,y+h) sub-frame to extract from the FITS data out to block 'buffer'.
-     * @param image_data is the FITS data block to extract from.
-     */
-    template <typename T>
-    void getFloatBuffer(float * buffer, int x, int y, int w, int h, FITSData const * image_data) const;
-
-  int numStars = 100;
-  double fractionRemoved = 0.2;
-  int deblendNThresh = 32;
-  double deblendMincont = 0.005;
+        Q_OBJECT
+
+    public:
+        explicit FITSSEPDetector(FITSData *parent): FITSStarDetector(parent) {};
+
+    public:
+        /** @brief Find sources in the parent FITS data file.
+         * @see FITSStarDetector::findSources().
+         */
+        int findSources(QList<Edge*> &starCenters, QRect const &boundary = QRect()) override;
+
+        /** @brief Find sources in the parent FITS data file as well as background sky information.
+         */
+        int findSourcesAndBackground(QList<Edge*> &starCenters, QRect const &boundary = QRect(),
+                                     SkyBackground *bg = nullptr);
+
+        /** @brief Configure the detection method.
+         * @see FITSStarDetector::configure().
+         * @note No parameters are currently available for configuration.
+         * @todo Provide parameters for detection configuration.
+         */
+        FITSSEPDetector &configure(const QString &setting, const QVariant &value) override;
+
+    protected:
+        /** @internal Consolidate a float data buffer from FITS data.
+         * @param buffer is the destination float block.
+         * @param x, y, w, h define a (x,y)-(x+w,y+h) sub-frame to extract from the FITS data out to block 'buffer'.
+         * @param image_data is the FITS data block to extract from.
+         */
+        template <typename T>
+        void getFloatBuffer(float * buffer, int x, int y, int w, int h, FITSData const * image_data) const;
+
+        int numStars = 100;
+        double fractionRemoved = 0.2;
+        int deblendNThresh = 32;
+        double deblendMincont = 0.005;
+        bool radiusIsBoundary = true;
 };
 
 #endif // FITSSEPDETECTOR_H
diff --git a/kstars/fitsviewer/opsfits.ui b/kstars/fitsviewer/opsfits.ui
index 2f2a2dedc..dd6b926d1 100644
--- a/kstars/fitsviewer/opsfits.ui
+++ b/kstars/fitsviewer/opsfits.ui
@@ -206,6 +206,19 @@
           </property>
          </widget>
         </item>
+        <item>
+         <widget class="QCheckBox" name="kcfg_QuickHFR">
+          <property name="toolTip">
+           <string>When computing the HFR, run it quickly by only looking at 25% of the image.</string>
+          </property>
+          <property name="text">
+           <string>Quick HFR</string>
+          </property>
+          <property name="checked">
+           <bool>true</bool>
+          </property>
+         </widget>
+        </item>
        </layout>
       </widget>
      </item>
diff --git a/kstars/kstars.kcfg b/kstars/kstars.kcfg
index 33ff1b4f8..e4a0e06f0 100644
--- a/kstars/kstars.kcfg
+++ b/kstars/kstars.kcfg
@@ -1441,6 +1441,10 @@
       <label>Automatically compute HFRs of fits images</label>
       <default>false</default>
    </entry>
+   <entry name="QuickHFR" type="Bool">
+      <label>Compute the HFRs of normal images quickly by looking at the center 25% only.</label>
+      <default>true</default>
+   </entry>
    <entry name="AutoWCS" type="Bool">
       <label>Automatically process World-Coordinate-System (WCS) data when loading a FITS file.</label>
       <default>!KSUtils::isHardwareLimited()</default>
@@ -2503,6 +2507,72 @@
          <default>false</default>
       </entry>
    </group>
+   <group name="Analyze">
+    <entry name="AnalyzeHFR" type="Bool">
+      <whatsthis>Display HFR on the Analyze Statistics Plot.</whatsthis>
+      <default>true</default>
+    </entry>
+    <entry name="AnalyzeNumStars" type="Bool">
+      <whatsthis>Display NumStars on the Analyze Statistics Plot.</whatsthis>
+      <default>false</default>
+    </entry>
+    <entry name="AnalyzeSkyBg" type="Bool">
+      <whatsthis>Display SkyBackground on the Analyze Statistics Plot.</whatsthis>
+      <default>false</default>
+    </entry>
+    <entry name="AnalyzeSNR" type="Bool">
+      <whatsthis>Display SNR on the Analyze Statistics Plot.</whatsthis>
+      <default>true</default>
+    </entry>
+    <entry name="AnalyzeRA" type="Bool">
+      <whatsthis>Display RA on the Analyze Statistics Plot.</whatsthis>
+      <default>false</default>
+    </entry>
+    <entry name="AnalyzeDEC" type="Bool">
+      <whatsthis>Display DEC on the Analyze Statistics Plot.</whatsthis>
+      <default>false</default>
+    </entry>
+    <entry name="AnalyzeRAp" type="Bool">
+      <whatsthis>Display RA Pulses on the Analyze Statistics Plot.</whatsthis>
+      <default>false</default>
+    </entry>
+    <entry name="AnalyzeDECp" type="Bool">
+      <whatsthis>Display DEC Pulses on the Analyze Statistics Plot.</whatsthis>
+      <default>false</default>
+    </entry>
+    <entry name="AnalyzeDrift" type="Bool">
+      <whatsthis>Display Drift on the Analyze Statistics Plot.</whatsthis>
+      <default>false</default>
+    </entry>
+    <entry name="AnalyzeRMS" type="Bool">
+      <whatsthis>Display RMS Error on the Analyze Statistics Plot.</whatsthis>
+      <default>true</default>
+    </entry>
+    <entry name="AnalyzeMountRA" type="Bool">
+      <whatsthis>Display Mount RA on the Analyze Statistics Plot.</whatsthis>
+      <default>false</default>
+    </entry>
+    <entry name="AnalyzeMountDEC" type="Bool">
+      <whatsthis>Display Mount DEC on the Analyze Statistics Plot.</whatsthis>
+      <default>false</default>
+    </entry>
+    <entry name="AnalyzeMountHA" type="Bool">
+      <whatsthis>Display Mount Hour Angle on the Analyze Statistics Plot.</whatsthis>
+      <default>false</default>
+    </entry>
+    <entry name="AnalyzeAz" type="Bool">
+      <whatsthis>Display Azimuth on the Analyze Statistics Plot.</whatsthis>
+      <default>false</default>
+    </entry>
+    <entry name="AnalyzeAlt" type="Bool">
+      <whatsthis>Display Altitude on the Analyze Statistics Plot.</whatsthis>
+      <default>false</default>
+    </entry>
+    <entry name="AnalyzePierSide" type="Bool">
+      <whatsthis>Display PierSide on the Analyze Statistics Plot.</whatsthis>
+      <default>false</default>
+    </entry>
+   </group>
    <group name="INDI Lite">
       <entry name="LastServer" type="String">
          <label>The address of last used server</label>


More information about the kde-doc-english mailing list