[education/kstars] /: Add horizon altitude constraints to the scheduler. Add testing. Improve dbus...

Jasem Mutlaq null at kde.org
Sun Aug 1 12:22:31 BST 2021


Git commit ca5028200dc7a73453566afa91c16812f87e74c4 by Jasem Mutlaq, on behalf of Hy Murveit.
Committed on 01/08/2021 at 11:22.
Pushed by mutlaqja into branch 'master'.

Add horizon altitude constraints to the scheduler. Add testing.  Improve dbus...

M  +6    -0    Tests/kstars_ui/mockmodules.cpp
M  +115  -37   Tests/kstars_ui/test_ekos_scheduler_ops.cpp
M  +5    -0    Tests/kstars_ui/test_ekos_scheduler_ops.h
M  +13   -5    Tests/scheduler/testschedulerunit.cpp
M  +1    -1    doc/ekos-scheduler.docbook
M  +32   -12   kstars/ekos/scheduler/scheduler.cpp
M  +1    -1    kstars/ekos/scheduler/scheduler.h
M  +22   -0    kstars/ekos/scheduler/scheduler.ui
M  +42   -8    kstars/ekos/scheduler/schedulerjob.cpp
M  +34   -1    kstars/ekos/scheduler/schedulerjob.h
M  +9    -0    kstars/skycomponents/artificialhorizoncomponent.cpp
M  +4    -0    kstars/skycomponents/artificialhorizoncomponent.h

https://invent.kde.org/education/kstars/commit/ca5028200dc7a73453566afa91c16812f87e74c4

diff --git a/Tests/kstars_ui/mockmodules.cpp b/Tests/kstars_ui/mockmodules.cpp
index 72c57e05f..94ac3303e 100644
--- a/Tests/kstars_ui/mockmodules.cpp
+++ b/Tests/kstars_ui/mockmodules.cpp
@@ -25,6 +25,7 @@ MockFocus::MockFocus()
     qRegisterMetaType<Ekos::FocusState>("Ekos::FocusState");
     qDBusRegisterMetaType<Ekos::FocusState>();
     new MockFocusAdaptor(this);
+    QDBusConnection::sessionBus().unregisterObject(mockPath);
     QDBusConnection::sessionBus().registerObject(mockPath, this);
 }
 
@@ -36,6 +37,7 @@ MockMount::MockMount()
     qRegisterMetaType<ISD::ParkStatus>("ISD::ParkStatus");
     qDBusRegisterMetaType<ISD::ParkStatus>();
     new MockMountAdaptor(this);
+    QDBusConnection::sessionBus().unregisterObject(mockPath);
     QDBusConnection::sessionBus().registerObject(mockPath, this);
 }
 
@@ -45,6 +47,7 @@ MockCapture::MockCapture()
     qRegisterMetaType<Ekos::CaptureState>("Ekos::CaptureState");
     qDBusRegisterMetaType<Ekos::CaptureState>();
     new MockCaptureAdaptor(this);
+    QDBusConnection::sessionBus().unregisterObject(mockPath);
     QDBusConnection::sessionBus().registerObject(mockPath, this);
 }
 
@@ -54,6 +57,7 @@ MockAlign::MockAlign()
     qRegisterMetaType<Ekos::AlignState>("Ekos::AlignState");
     qDBusRegisterMetaType<Ekos::AlignState>();
     new MockAlignAdaptor(this);
+    QDBusConnection::sessionBus().unregisterObject(mockPath);
     QDBusConnection::sessionBus().registerObject(mockPath, this);
 }
 
@@ -63,6 +67,7 @@ MockGuide::MockGuide()
     qRegisterMetaType<Ekos::GuideState>("Ekos::GuideState");
     qDBusRegisterMetaType<Ekos::GuideState>();
     new MockGuideAdaptor(this);
+    QDBusConnection::sessionBus().unregisterObject(mockPath);
     QDBusConnection::sessionBus().registerObject(mockPath, this);
 }
 
@@ -73,6 +78,7 @@ MockEkos::MockEkos()
     qDBusRegisterMetaType<Ekos::CommunicationStatus>();
 
     new MockEkosAdaptor(this);
+    QDBusConnection::sessionBus().unregisterObject(mockPath);
     QDBusConnection::sessionBus().registerObject(mockPath, this);
 }
 
diff --git a/Tests/kstars_ui/test_ekos_scheduler_ops.cpp b/Tests/kstars_ui/test_ekos_scheduler_ops.cpp
index fd2c53979..157eb19f3 100644
--- a/Tests/kstars_ui/test_ekos_scheduler_ops.cpp
+++ b/Tests/kstars_ui/test_ekos_scheduler_ops.cpp
@@ -16,9 +16,11 @@
 
 #if defined(HAVE_INDI)
 
+#include "artificialhorizoncomponent.h"
 #include "kstars_ui_tests.h"
 #include "test_ekos.h"
 #include "test_ekos_simulator.h"
+#include "linelist.h"
 #include "mockmodules.h"
 #include "Options.h"
 
@@ -188,7 +190,8 @@ bool writeFile(const QString &filename, const QString &contents)
     return false;
 }
 
-QString getSchedulerFile(SkyObject *targetObject, StartupCondition startupCondition, bool enforceTwilight)
+QString getSchedulerFile(const SkyObject *targetObject, StartupCondition startupCondition,
+                         bool enforceTwilight, bool enforceArtificialHorizon)
 {
     QString target = QString("<?xml version=\"1.0\" encoding=\"UTF-8\"?><SchedulerList version='1.4'><Profile>Default</Profile>"
                              "<Job><Name>%1</Name><Priority>10</Priority><Coordinates><J2000RA>%2</J2000RA>"
@@ -206,13 +209,14 @@ QString getSchedulerFile(SkyObject *targetObject, StartupCondition startupCondit
                                   startupCondition.atLocalDateTime.toString(Qt::ISODate));
 
     QString parameters = QString("<StartupCondition>%1</StartupCondition>"
-                                 "<Constraints><Constraint value='30'>MinimumAltitude</Constraint>%2"
+                                 "<Constraints><Constraint value='30'>MinimumAltitude</Constraint>%2%3"
                                  "</Constraints><CompletionCondition><Condition>Sequence</Condition></CompletionCondition>"
                                  "<Steps><Step>Track</Step><Step>Focus</Step><Step>Align</Step><Step>Guide</Step></Steps></Job>"
                                  "<ErrorHandlingStrategy value='1'><delay>0</delay></ErrorHandlingStrategy><StartupProcedure>"
                                  "<Procedure>UnparkMount</Procedure></StartupProcedure><ShutdownProcedure><Procedure>ParkMount</Procedure>"
                                  "</ShutdownProcedure></SchedulerList>")
-                         .arg(startupConditionStr).arg(enforceTwilight ? "<Constraint>EnforceTwilight</Constraint>" : "");
+                         .arg(startupConditionStr).arg(enforceTwilight ? "<Constraint>EnforceTwilight</Constraint>" : "")
+                         .arg(enforceArtificialHorizon ? "<Constraint>EnforceArtificialHorizon</Constraint>" : "");
 
     return (target + sequence + parameters);
 }
@@ -476,25 +480,14 @@ void TestEkosSchedulerOps::initJob(const KStarsDateTime &startUTime, const KStar
 // This tests a simple scheduler job.
 // The job initializes Ekos and Indi, slews, plate-solves, focuses, starts guiding, and
 // captures. Capture completes and the scheduler shuts down.
-void TestEkosSchedulerOps::testSimpleJob()
+void TestEkosSchedulerOps::runSimpleJob(const GeoLocation &geo, const SkyObject *targetObject, const QDateTime &startUTime,
+                                        const QDateTime &wakeupTime, bool enforceArtificialHorizon)
 {
-    // Setup a geo.
-    GeoLocation geo(dms(-122, 10), dms(37, 26, 30), "Silicon Valley", "CA", "USA", -8);
-    // Setup an initial time.
-    // Note that the start time is 3pm local (10pm UTC - 7 TZ).
-    // Altair, the target, should be at about -40 deg altitude at this time,.
-    // The dawn/dusk constraints are 4:03am and 10:12pm (lst=13:43)
-    // At 10:12pm it should have an altitude of about 14 degrees, still below the 30-degree constraint.
-    // It achieves 30-degrees altitude at about 23:35.
-    QDateTime startUTime(QDateTime(QDate(2021, 6, 13), QTime(22, 0, 0), Qt::UTC));
-
     KStarsDateTime testUTime;
     int sleepMs = 0;
 
-    const QDateTime wakeupTime(QDate(2021, 6, 14), QTime(06, 35, 0), Qt::UTC);
-    SkyObject *targetObject = KStars::Instance()->data()->skyComposite()->findByName("Altair");
     QTemporaryDir dir(QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/test-XXXXXX");
-    startupJob(geo, startUTime, &dir, getSchedulerFile(targetObject, m_startupCondition, false),
+    startupJob(geo, startUTime, &dir, getSchedulerFile(targetObject, m_startupCondition, false, enforceArtificialHorizon),
                esqContents1, wakeupTime, &testUTime, &sleepMs);
 
     QVERIFY(iterateScheduler("Wait for Capturing", 30, &sleepMs, &testUTime, [&]() -> bool
@@ -525,6 +518,22 @@ void TestEkosSchedulerOps::testSimpleJob()
     }));
 }
 
+void TestEkosSchedulerOps::testSimpleJob()
+{
+    GeoLocation geo(dms(-122, 10), dms(37, 26, 30), "Silicon Valley", "CA", "USA", -8);
+    SkyObject *targetObject = KStars::Instance()->data()->skyComposite()->findByName("Altair");
+
+    // Setup an initial time.
+    // Note that the start time is 3pm local (10pm UTC - 7 TZ).
+    // Altair, the target, should be at about -40 deg altitude at this time,.
+    // The dawn/dusk constraints are 4:03am and 10:12pm (lst=13:43)
+    // At 10:12pm it should have an altitude of about 14 degrees, still below the 30-degree constraint.
+    // It achieves 30-degrees altitude at about 23:35.
+    QDateTime startUTime(QDateTime(QDate(2021, 6, 13), QTime(22, 0, 0), Qt::UTC));
+    const QDateTime wakeupTime(QDate(2021, 6, 14), QTime(06, 35, 0), Qt::UTC);
+    runSimpleJob(geo, targetObject, startUTime, wakeupTime, true);
+}
+
 // This test has the same start as testSimpleJob, except that it but runs in NYC
 // instead of silicon valley. This makes sure testing doesn't depend on timezone.
 void TestEkosSchedulerOps::testTimeZone()
@@ -541,33 +550,38 @@ void TestEkosSchedulerOps::testTimeZone()
     const QDateTime wakeupTime(QDate(2021, 6, 14), QTime(03, 26, 0), Qt::UTC);
     SkyObject *targetObject = KStars::Instance()->data()->skyComposite()->findByName("Altair");
     QTemporaryDir dir(QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/test-XXXXXX");
-    startupJob(geo, startUTime, &dir, getSchedulerFile(targetObject, m_startupCondition, false),
+    startupJob(geo, startUTime, &dir, getSchedulerFile(targetObject, m_startupCondition, false, true),
                esqContents1, wakeupTime, &testUTime, &sleepMs);
 }
 
-// This test runs a job until dawn, and makes sure the job is interrupted, and restarted
-// the next day.
-// NOTE: This isn't complete. Testing through the shutdown.
-// - it didn't park at the shutdown
-// - it doesn't wakeup and start executing the job at the expected time.
 void TestEkosSchedulerOps::testDawnShutdown()
 {
-    // Setup a geo.
+    // At this geo/date, Dawn is calculated = .1625 of a day = 3:53am local = 10:52 UTC
+    // If we started at 23:35 local time, as before, it's a little over 4 hours
+    // or over 4*3600 iterations. Too many? Instead we start at 3am local.
+
     GeoLocation geo(dms(-122, 10), dms(37, 26, 30), "Silicon Valley", "CA", "USA", -8);
+    SkyObject *targetObject = KStars::Instance()->data()->skyComposite()->findByName("Altair");
+    QDateTime startUTime(QDateTime(QDate(2021, 6, 14), QTime(10, 0, 0), Qt::UTC));
 
+    // The time should be the pre-dawn time, which is about 3:53am
+    QDateTime preDawnUTime(QDateTime(QDate(2021, 6, 14), QTime(10, 53, 0), Qt::UTC));
+    // Consider pre-dawn security range
+    preDawnUTime = preDawnUTime.addSecs(-60.0 * abs(Options::preDawnTime()));
+
+    runDawnShutdown(geo, targetObject, startUTime, preDawnUTime);
+}
+
+void TestEkosSchedulerOps::runDawnShutdown(const GeoLocation &geo, const SkyObject *targetObject,
+        const QDateTime &startUTime, const QDateTime &preDawnUTime)
+{
     scheduler->setUpdateInterval(40000);
     KStarsDateTime testUTime;
     int sleepMs = 0;
 
-    // At this geo/date, Dawn is calculated = .1625 of a day = 3:53am local = 10:52 UTC
-    // If we started at 23:35 local time, as before, it's a little over 4 hours
-    // or over 4*3600 iterations. Too many? Instead we start at 3am local.
-    QDateTime startUTime(QDateTime(QDate(2021, 6, 14), QTime(10, 0, 0), Qt::UTC));
-
     const QDateTime wakeupTime;  // Not valid--it starts up right away.
-    SkyObject *targetObject = KStars::Instance()->data()->skyComposite()->findByName("Altair");
     QTemporaryDir dir(QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/test-XXXXXX");
-    startupJob(geo, startUTime, &dir, getSchedulerFile(targetObject, m_startupCondition, true),
+    startupJob(geo, startUTime, &dir, getSchedulerFile(targetObject, m_startupCondition, true, true),
                esqContents1, wakeupTime, &testUTime, &sleepMs);
 
     QVERIFY(iterateScheduler("Wait for Job Startup", 10, &sleepMs, &testUTime, [&]() -> bool
@@ -584,10 +598,7 @@ void TestEkosSchedulerOps::testDawnShutdown()
     {
         return (scheduler->timerState == Scheduler::RUN_SCHEDULER);
     }));
-    // The time should be the pre-dawn time, which is about 3:53am
-    QDateTime preDawnUTime(QDateTime(QDate(2021, 6, 14), QTime(10, 53, 0), Qt::UTC));
-    // Consider pre-dawn security range
-    preDawnUTime = preDawnUTime.addSecs(-60.0 * abs(Options::preDawnTime()));
+
     double delta = KStarsData::Instance()->ut().secsTo(preDawnUTime);
     QVERIFY2(std::abs(delta) < 60, QString("Unexpected difference to dawn: %1 secs").arg(delta).toLocal8Bit());
 
@@ -664,7 +675,7 @@ void TestEkosSchedulerOps::testCulminationStartup()
     KStarsDateTime const jobStartUTime = transitUT.addSecs(60 * offset);
     // initialize the the scheduler
     QTemporaryDir dir(QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/test-XXXXXX");
-    initScheduler(*geo, startUTime, &dir, getSchedulerFile(targetObject, m_startupCondition, false), esqContents1);
+    initScheduler(*geo, startUTime, &dir, getSchedulerFile(targetObject, m_startupCondition, false, true), esqContents1);
     // verify if the job starts at the expected time
     initJob(startUTime, jobStartUTime);
 }
@@ -685,11 +696,78 @@ void TestEkosSchedulerOps::testFixedDateStartup()
 
     // initialize the the scheduler
     QTemporaryDir dir(QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/test-XXXXXX");
-    initScheduler(*geo, startUTime, &dir, getSchedulerFile(targetObject, m_startupCondition, false), esqContents1);
+    initScheduler(*geo, startUTime, &dir, getSchedulerFile(targetObject, m_startupCondition, false, true), esqContents1);
     // verify if the job starts at the expected time
     initJob(startUTime, jobStartUTime);
 }
 
+void addHorizonConstraint(ArtificialHorizon *horizon, const QString &name, bool enabled,
+                          const QVector<double> &azimuths, const QVector<double> &altitudes)
+{
+    std::shared_ptr<LineList> pointList(new LineList);
+    for (int i = 0; i < azimuths.size(); ++i)
+    {
+        std::shared_ptr<SkyPoint> skyp1(new SkyPoint);
+        skyp1->setAlt(altitudes[i]);
+        skyp1->setAz(azimuths[i]);
+        pointList->append(skyp1);
+    }
+    horizon->addRegion(name, enabled, pointList, false);
+}
+
+void TestEkosSchedulerOps::testArtificialHorizonConstraints()
+{
+    // In testSimpleJob, above, the wakeup time for the job was 11:35pm local time, and it used a 30-degrees min altitude.
+    // Now let's add an artificial horizon constraint for 40-degrees at the azimuths where the object will be.
+    // It should now wakeup and start processing at about 00:27am
+
+    ArtificialHorizon horizon;
+    addHorizonConstraint(&horizon, "r1", true, QVector<double>({100, 120}), QVector<double>({40, 40}));
+    SchedulerJob::setHorizon(&horizon);
+
+    GeoLocation geo(dms(-122, 10), dms(37, 26, 30), "Silicon Valley", "CA", "USA", -8);
+    SkyObject *targetObject = KStars::Instance()->data()->skyComposite()->findByName("Altair");
+    QDateTime startUTime(QDateTime(QDate(2021, 6, 13), QTime(22, 0, 0), Qt::UTC));
+
+    const QDateTime wakeupTime(QDate(2021, 6, 14), QTime(07, 27, 0), Qt::UTC);
+    runSimpleJob(geo, targetObject, startUTime, wakeupTime, true);
+
+    // Uncheck enforce artificial horizon and the wakeup time should go back to it's original time,
+    // even though the artificial horizon is still there and enabled.
+    init(); // Reset the scheduler.
+    const QDateTime originalWakeupTime(QDate(2021, 6, 14), QTime(06, 35, 0), Qt::UTC);
+    runSimpleJob(geo, targetObject, startUTime, originalWakeupTime, /* enforce artificial horizon */false);
+
+    // Re-check enforce artificial horizon, but remove the constraint, and the wakeup time also goes back to it's original time.
+    init(); // Reset the scheduler.
+    ArtificialHorizon emptyHorizon;
+    SchedulerJob::setHorizon(&emptyHorizon);
+    runSimpleJob(geo, targetObject, startUTime, originalWakeupTime, /* enforce artificial horizon */ true);
+
+    // Testing that the artificial horizon constraint will end a job
+    // when the altitude of the running job is below the artificial horizon at the
+    // target's azimuth.
+    //
+    // This repeats testDawnShutdown() above, except that an artifical horizon
+    // constraint is added so that the job doesn't reach dawn but rather is interrupted
+    // at 3:19 local time. That's the time the azimuth reaches 175.
+
+    init(); // Reset the scheduler.
+    ArtificialHorizon shutdownHorizon;
+    // Note, just putting a constraint at 175->180 will fail this test because Altair will
+    // cross past 180 and the scheduler will want to restart it before dawn.
+    addHorizonConstraint(&shutdownHorizon, "h", true,
+                         QVector<double>({175, 200}), QVector<double>({70, 70}));
+    SchedulerJob::setHorizon(&shutdownHorizon);
+
+    startUTime = QDateTime(QDate(2021, 6, 14), QTime(10, 0, 0), Qt::UTC);
+
+    // Set the stop interrupt time at 3:19am local.
+    QDateTime horizonStopUTime(QDateTime(QDate(2021, 6, 14), QTime(10, 19, 0), Qt::UTC));
+
+    runDawnShutdown(geo, targetObject, startUTime, horizonStopUTime);
+}
+
 void TestEkosSchedulerOps::prepareTestData(QList<QString> locationList, QList<QString> targetList)
 {
 #if QT_VERSION < QT_VERSION_CHECK(5,9,0)
diff --git a/Tests/kstars_ui/test_ekos_scheduler_ops.h b/Tests/kstars_ui/test_ekos_scheduler_ops.h
index a5ef15526..87e2a2ba3 100644
--- a/Tests/kstars_ui/test_ekos_scheduler_ops.h
+++ b/Tests/kstars_ui/test_ekos_scheduler_ops.h
@@ -64,12 +64,17 @@ class TestEkosSchedulerOps : public QObject
         void testDawnShutdown();
         void testCulminationStartup();
         void testFixedDateStartup();
+        void testArtificialHorizonConstraints();
 
         // test data
         void testCulminationStartup_data();
 
     protected:
         void prepareTestData(QList<QString> locationList, QList<QString> targetList);
+        void runSimpleJob(const GeoLocation &geo, const SkyObject *targetObject, const QDateTime &startUTime,
+                          const QDateTime &wakeupTime, bool enforceArtificialHorizon);
+        void runDawnShutdown(const GeoLocation &geo, const SkyObject *targetObject,
+                             const QDateTime &startUTime, const QDateTime &preDawnUTime);
 
     private:
         bool iterateScheduler(const QString &label, int iterations, int *sleepMs,
diff --git a/Tests/scheduler/testschedulerunit.cpp b/Tests/scheduler/testschedulerunit.cpp
index fb9758095..1d58164dc 100644
--- a/Tests/scheduler/testschedulerunit.cpp
+++ b/Tests/scheduler/testschedulerunit.cpp
@@ -54,7 +54,7 @@ class TestSchedulerUnit : public QObject
                          const QUrl &fitsUrl, SchedulerJob::StartupCondition sCond, const QDateTime &sTime,
                          int16_t sOffset, SchedulerJob::CompletionCondition eCond, const QDateTime &eTime, int eReps,
                          double minAlt, double minMoonSep = 0, bool enforceWeather = false, bool enforceTwilight = true,
-                         bool track = true, bool focus = true, bool align = true, bool guide = true);
+                         bool enforceArtificialHorizon = true, bool track = true, bool focus = true, bool align = true, bool guide = true);
 };
 
 #include "testschedulerunit.moc"
@@ -130,7 +130,8 @@ bool compareTimes(const QDateTime &t1, const QDateTime &t2, int toleranceSecs =
     int toleranceMsecs = toleranceSecs * 1000;
     if (std::abs(t1.msecsTo(t2)) >= toleranceMsecs)
     {
-        QWARN(qPrintable(QString("Comparison of %1 with %2 is out of %3s tolerance.").arg(t1.toString()).arg(t2.toString()).arg(toleranceSecs)));
+        QWARN(qPrintable(QString("Comparison of %1 with %2 is out of %3s tolerance.").arg(t1.toString()).arg(t2.toString()).arg(
+                             toleranceSecs)));
         return false;
     }
     else return true;
@@ -167,7 +168,7 @@ void TestSchedulerUnit::runSetupJob(
     const QUrl &fitsUrl, SchedulerJob::StartupCondition sCond, const QDateTime &sTime,
     int16_t sOffset, SchedulerJob::CompletionCondition eCond, const QDateTime &eTime, int eReps,
     double minAlt, double minMoonSep, bool enforceWeather, bool enforceTwilight,
-    bool track, bool focus, bool align, bool guide)
+    bool enforceArtificialHorizon, bool track, bool focus, bool align, bool guide)
 {
     // Setup the time and geo.
     KStarsDateTime ut = geo->LTtoUT(*localTime);
@@ -180,7 +181,7 @@ void TestSchedulerUnit::runSetupJob(
                         sCond, sTime, sOffset,
                         eCond, eTime, eReps,
                         minAlt, minMoonSep,
-                        enforceWeather, enforceTwilight,
+                        enforceWeather, enforceTwilight, enforceArtificialHorizon,
                         track, focus, align, guide);
     QVERIFY(name == job.getName());
     QVERIFY(priority == job.getPriority());
@@ -193,6 +194,7 @@ void TestSchedulerUnit::runSetupJob(
     QVERIFY(minMoonSep == job.getMinMoonSeparation());
     QVERIFY(enforceWeather == job.getEnforceWeather());
     QVERIFY(enforceTwilight == job.getEnforceTwilight());
+    QVERIFY(enforceArtificialHorizon == job.getEnforceArtificialHorizon());
 
     QVERIFY(sCond == job.getStartupCondition());
     switch (sCond)
@@ -271,6 +273,7 @@ void TestSchedulerUnit::setupJobTest_data()
     QTest::addColumn<int>("REPEATS");
     QTest::addColumn<bool>("ENFORCE_WEATHER");
     QTest::addColumn<bool>("ENFORCE_TWILIGHT");
+    QTest::addColumn<bool>("ENFORCE_ARTIFICIAL_HORIZON");
     QTest::addColumn<bool>("TRACK");
     QTest::addColumn<bool>("FOCUS");
     QTest::addColumn<bool>("ALIGN");
@@ -281,6 +284,7 @@ void TestSchedulerUnit::setupJobTest_data()
             << SchedulerJob::FINISH_SEQUENCE << QDateTime() << 1 // end conditions
             << false  // enforce weather
             << true   // enforce twilight
+            << true   // enforce artificial horizon
             << false  // track
             << true   // focus
             << true   // align
@@ -295,6 +299,7 @@ void TestSchedulerUnit::setupJobTest_data()
             << 1
             << true   // enforce weather
             << false  // enforce twilight
+            << true   // enforce artificial horizon
             << true   // track
             << false  // focus
             << true   // align
@@ -305,6 +310,7 @@ void TestSchedulerUnit::setupJobTest_data()
             << SchedulerJob::FINISH_REPEAT << QDateTime() << 3 // end conditions
             << true   // enforce weather
             << true   // enforce twilight
+            << true   // enforce artificial horizon
             << true   // track
             << true   // focus
             << false  // align
@@ -315,6 +321,7 @@ void TestSchedulerUnit::setupJobTest_data()
             << SchedulerJob::FINISH_SEQUENCE << QDateTime() << 1 // end conditions
             << false  // enforce weather
             << false  // enforce twilight
+            << true   // enforce artificial horizon
             << true   // track
             << true   // focus
             << true   // align
@@ -331,6 +338,7 @@ void TestSchedulerUnit::setupJobTest()
     QFETCH(int, REPEATS);
     QFETCH(bool, ENFORCE_WEATHER);
     QFETCH(bool, ENFORCE_TWILIGHT);
+    QFETCH(bool, ENFORCE_ARTIFICIAL_HORIZON);
     QFETCH(bool, TRACK);
     QFETCH(bool, FOCUS);
     QFETCH(bool, ALIGN);
@@ -342,7 +350,7 @@ void TestSchedulerUnit::setupJobTest()
                 START_CONDITION, START_TIME, START_OFFSET,
                 END_CONDITION, END_TIME, REPEATS,
                 30.0, 5.0, ENFORCE_WEATHER, ENFORCE_TWILIGHT,
-                TRACK, FOCUS, ALIGN, GUIDE);
+                ENFORCE_ARTIFICIAL_HORIZON, TRACK, FOCUS, ALIGN, GUIDE);
 }
 
 namespace
diff --git a/doc/ekos-scheduler.docbook b/doc/ekos-scheduler.docbook
index 334186ad7..c21223b66 100644
--- a/doc/ekos-scheduler.docbook
+++ b/doc/ekos-scheduler.docbook
@@ -71,7 +71,7 @@
             </listitem>
             <listitem>
                 <para>
-                    <guilabel>Constraints</guilabel>: Constraints are conditions that must be met <emphasis role="bold">at all times</emphasis> during the observation job execution process. These include minimum target altitude, minimum moon separation, twilight observation, and weather monitoring.
+                    <guilabel>Constraints</guilabel>: Constraints are conditions that must be met <emphasis role="bold">at all times</emphasis> during the observation job execution process. These include minimum target altitude, minimum moon separation, twilight observation, artificial horizon altitude constraints, and weather monitoring.
                 </para>
             </listitem>
             <listitem>
diff --git a/kstars/ekos/scheduler/scheduler.cpp b/kstars/ekos/scheduler/scheduler.cpp
index 09afeef00..1efee5e79 100644
--- a/kstars/ekos/scheduler/scheduler.cpp
+++ b/kstars/ekos/scheduler/scheduler.cpp
@@ -442,9 +442,6 @@ void Scheduler::setupScheduler(const QString &ekosPathStr, const QString &ekosIn
     qRegisterMetaType<Ekos::SchedulerState>("Ekos::SchedulerState");
     qDBusRegisterMetaType<Ekos::SchedulerState>();
 
-    new SchedulerAdaptor(this);
-    QDBusConnection::sessionBus().registerObject("/KStars/Ekos/Scheduler", this);
-
     dirPath = QUrl::fromLocalFile(QDir::homePath());
 
     // Get current KStars time and set seconds to zero
@@ -458,7 +455,10 @@ void Scheduler::setupScheduler(const QString &ekosPathStr, const QString &ekosIn
     completionTimeEdit->setDateTime(currentDateTime);
 
     // Set up DBus interfaces
-    QDBusConnection::sessionBus().registerObject("/KStars/Ekos/Scheduler", this);
+    new SchedulerAdaptor(this);
+    QDBusConnection::sessionBus().unregisterObject("/KStars/Ekos/Scheduler");
+    if (!QDBusConnection::sessionBus().registerObject("/KStars/Ekos/Scheduler", this))
+        qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Scheduler failed to register with dbus");
     ekosInterface = new QDBusInterface("org.kde.kstars", ekosPathStr, ekosInterfaceStr,
                                        QDBusConnection::sessionBus(), this);
 
@@ -843,7 +843,8 @@ void Scheduler::addObject(SkyObject *object)
 
 void Scheduler::selectFITS()
 {
-    fitsURL = QFileDialog::getOpenFileUrl(Ekos::Manager::Instance(), i18nc("@title:window", "Select FITS Image"), dirPath, "FITS (*.fits *.fit)");
+    fitsURL = QFileDialog::getOpenFileUrl(Ekos::Manager::Instance(), i18nc("@title:window", "Select FITS Image"), dirPath,
+                                          "FITS (*.fits *.fit)");
     if (fitsURL.isEmpty())
         return;
 
@@ -965,7 +966,8 @@ void Scheduler::setSequence(const QString &sequenceFileURL)
 
 void Scheduler::selectSequence()
 {
-    QString file = QFileDialog::getOpenFileName(Ekos::Manager::Instance(), i18nc("@title:window", "Select Sequence Queue"), dirPath.toLocalFile(),
+    QString file = QFileDialog::getOpenFileName(Ekos::Manager::Instance(), i18nc("@title:window", "Select Sequence Queue"),
+                   dirPath.toLocalFile(),
                    i18n("Ekos Sequence Queue (*.esq)"));
 
     setSequence(file);
@@ -973,7 +975,8 @@ void Scheduler::selectSequence()
 
 void Scheduler::selectStartupScript()
 {
-    startupScriptURL = QFileDialog::getOpenFileUrl(Ekos::Manager::Instance(), i18nc("@title:window", "Select Startup Script"), dirPath,
+    startupScriptURL = QFileDialog::getOpenFileUrl(Ekos::Manager::Instance(), i18nc("@title:window", "Select Startup Script"),
+                       dirPath,
                        i18n("Script (*)"));
     if (startupScriptURL.isEmpty())
         return;
@@ -986,7 +989,8 @@ void Scheduler::selectStartupScript()
 
 void Scheduler::selectShutdownScript()
 {
-    shutdownScriptURL = QFileDialog::getOpenFileUrl(Ekos::Manager::Instance(), i18nc("@title:window", "Select Shutdown Script"), dirPath,
+    shutdownScriptURL = QFileDialog::getOpenFileUrl(Ekos::Manager::Instance(), i18nc("@title:window", "Select Shutdown Script"),
+                        dirPath,
                         i18n("Script (*)"));
     if (shutdownScriptURL.isEmpty())
         return;
@@ -1022,7 +1026,7 @@ void Scheduler::setupJob(
     SchedulerJob::CompletionCondition completion,
     const QDateTime &completionTime, int completionRepeats,
     double minimumAltitude, double minimumMoonSeparation,
-    bool enforceWeather, bool enforceTwilight,
+    bool enforceWeather, bool enforceTwilight, bool enforceArtificialHorizon,
     bool track, bool focus, bool align, bool guide)
 {
     /* Configure or reconfigure the observation job */
@@ -1061,6 +1065,7 @@ void Scheduler::setupJob(
     job.setEnforceWeather(enforceWeather);
     // twilight constraints
     job.setEnforceTwilight(enforceTwilight);
+    job.setEnforceArtificialHorizon(enforceArtificialHorizon);
 
     job.setCompletionCondition(completion);
     if (completion == SchedulerJob::FINISH_AT)
@@ -1205,6 +1210,7 @@ void Scheduler::saveJob()
         moonConstraint,
         weatherCheck->isChecked(),
         twilightCheck->isChecked(),
+        artificialHorizonCheck->isChecked(),
 
         trackStepCheck->isChecked(),
         focusStepCheck->isChecked(),
@@ -1394,6 +1400,10 @@ void Scheduler::syncGUIToJob(SchedulerJob *job)
     twilightCheck->setChecked(job->getEnforceTwilight());
     twilightCheck->blockSignals(false);
 
+    artificialHorizonCheck->blockSignals(true);
+    artificialHorizonCheck->setChecked(job->getEnforceArtificialHorizon());
+    artificialHorizonCheck->blockSignals(false);
+
     switch (job->getCompletionCondition())
     {
         case SchedulerJob::FINISH_SEQUENCE:
@@ -3724,7 +3734,8 @@ void Scheduler::checkJobStage()
         p.EquatorialToHorizontal(KStarsData::Instance()->lst(), geo->lat());
 
         /* FIXME: find a way to use altitude cutoff here, because the job can be scheduled when evaluating, then aborted when running */
-        if (p.alt().Degrees() < currentJob->getMinAltitude())
+        const double altitudeConstraint = currentJob->getMinAltitudeConstraint(p.az().Degrees());
+        if (p.alt().Degrees() < altitudeConstraint)
         {
             // Only terminate job due to altitude limitation if mount is NOT parked.
             if (isMountParked() == false)
@@ -3732,7 +3743,7 @@ void Scheduler::checkJobStage()
                 appendLogText(i18n("Job '%1' current altitude (%2 degrees) crossed minimum constraint altitude (%3 degrees), "
                                    "marking idle.", currentJob->getName(),
                                    QString("%L1").arg(p.alt().Degrees(), 0, 'f', minAltitude->decimals()),
-                                   QString("%L1").arg(currentJob->getMinAltitude(), 0, 'f', minAltitude->decimals())));
+                                   QString("%L1").arg(altitudeConstraint, 0, 'f', minAltitude->decimals())));
 
                 currentJob->setState(SchedulerJob::JOB_IDLE);
                 stopCurrentJobAction();
@@ -4160,7 +4171,8 @@ void Scheduler::load(bool clearQueue, const QString &filename)
     QUrl fileURL;
 
     if (filename.isEmpty())
-        fileURL = QFileDialog::getOpenFileUrl(Ekos::Manager::Instance(), i18nc("@title:window", "Open Ekos Scheduler List"), dirPath,
+        fileURL = QFileDialog::getOpenFileUrl(Ekos::Manager::Instance(), i18nc("@title:window", "Open Ekos Scheduler List"),
+                                              dirPath,
                                               "Ekos Scheduler List (*.esl)");
     else fileURL.setUrl(filename);
 
@@ -4345,6 +4357,10 @@ bool Scheduler::processJobInfo(XMLEle *root)
     twilightCheck->setChecked(false);
     twilightCheck->blockSignals(false);
 
+    artificialHorizonCheck->blockSignals(true);
+    artificialHorizonCheck->setChecked(false);
+    artificialHorizonCheck->blockSignals(false);
+
     minAltitude->setValue(minAltitude->minimum());
     minMoonSeparation->setValue(minMoonSeparation->minimum());
     rotationSpin->setValue(0);
@@ -4425,6 +4441,8 @@ bool Scheduler::processJobInfo(XMLEle *root)
                     weatherCheck->setChecked(true);
                 else if (!strcmp("EnforceTwilight", pcdataXMLEle(subEP)))
                     twilightCheck->setChecked(true);
+                else if (!strcmp("EnforceArtificialHorizon", pcdataXMLEle(subEP)))
+                    artificialHorizonCheck->setChecked(true);
             }
         }
         else if (!strcmp(tagXMLEle(ep), "CompletionCondition"))
@@ -4590,6 +4608,8 @@ bool Scheduler::saveScheduler(const QUrl &fileURL)
             outstream << "<Constraint>EnforceWeather</Constraint>" << endl;
         if (job->getEnforceTwilight())
             outstream << "<Constraint>EnforceTwilight</Constraint>" << endl;
+        if (job->getEnforceArtificialHorizon())
+            outstream << "<Constraint>EnforceArtificialHorizon</Constraint>" << endl;
         outstream << "</Constraints>" << endl;
 
         outstream << "<CompletionCondition>" << endl;
diff --git a/kstars/ekos/scheduler/scheduler.h b/kstars/ekos/scheduler/scheduler.h
index 7784d6659..b6ef0ba7d 100644
--- a/kstars/ekos/scheduler/scheduler.h
+++ b/kstars/ekos/scheduler/scheduler.h
@@ -294,7 +294,7 @@ class Scheduler : public QWidget, public Ui::Scheduler
             SchedulerJob::StartupCondition startup, const QDateTime &startupTime, int16_t startupOffset,
             SchedulerJob::CompletionCondition completion, const QDateTime &completionTime, int completionRepeats,
             double minimumAltitude, double minimumMoonSeparation, bool enforceWeather, bool enforceTwilight,
-            bool track, bool focus, bool align, bool guide);
+            bool enforceArtificialHorizon, bool track, bool focus, bool align, bool guide);
 
         /**
              * @brief evaluateJobs Computes estimated start and end times for the SchedulerJobs passed in. Returns a proposed schedule.
diff --git a/kstars/ekos/scheduler/scheduler.ui b/kstars/ekos/scheduler/scheduler.ui
index 5175d585e..545268e16 100644
--- a/kstars/ekos/scheduler/scheduler.ui
+++ b/kstars/ekos/scheduler/scheduler.ui
@@ -1695,6 +1695,28 @@ font-weight:bold;
             </attribute>
            </widget>
           </item>
+          <item row="4" column="0">
+           <widget class="QCheckBox" name="artificialHorizonCheck">
+            <property name="sizePolicy">
+             <sizepolicy hsizetype="Preferred" vsizetype="Preferred">
+              <horstretch>0</horstretch>
+              <verstretch>0</verstretch>
+             </sizepolicy>
+            </property>
+            <property name="toolTip">
+             <string><html><body><p>The artificial horizon restriction constrains the altitude of the target to be above the artificial horizon, if any are defined and enabled. See the artificial horizon item in the KStars Settings menu.</p></body></html></string>
+            </property>
+            <property name="text">
+             <string>Use Artificial Horizon</string>
+            </property>
+            <property name="checked">
+             <bool>true</bool>
+            </property>
+            <attribute name="buttonGroup">
+             <string notr="true">constraintButtonGroup</string>
+            </attribute>
+           </widget>
+          </item>
           <item row="2" column="1">
            <widget class="QLabel" name="weatherLabel">
             <property name="text">
diff --git a/kstars/ekos/scheduler/schedulerjob.cpp b/kstars/ekos/scheduler/schedulerjob.cpp
index 1afd5ec92..180233cc8 100644
--- a/kstars/ekos/scheduler/schedulerjob.cpp
+++ b/kstars/ekos/scheduler/schedulerjob.cpp
@@ -10,6 +10,7 @@
 #include "schedulerjob.h"
 
 #include "dms.h"
+#include "artificialhorizoncomponent.h"
 #include "kstarsdata.h"
 #include "skymapcomposite.h"
 #include "Options.h"
@@ -28,6 +29,7 @@
 
 GeoLocation *SchedulerJob::storedGeo = nullptr;
 KStarsDateTime *SchedulerJob::storedLocalTime = nullptr;
+ArtificialHorizon *SchedulerJob::storedHorizon = nullptr;
 
 SchedulerJob::SchedulerJob()
 {
@@ -60,6 +62,16 @@ GeoLocation const *SchedulerJob::getGeo()
     return KStarsData::Instance()->geo();
 }
 
+ArtificialHorizon const *SchedulerJob::getHorizon()
+{
+    if (hasHorizon())
+        return storedHorizon;
+    if (KStarsData::Instance() == nullptr || KStarsData::Instance()->skyComposite() == nullptr
+            || KStarsData::Instance()->skyComposite()->artificialHorizon() == nullptr)
+        return nullptr;
+    return &KStarsData::Instance()->skyComposite()->artificialHorizon()->getHorizon();
+}
+
 void SchedulerJob::setStartupCondition(const StartupCondition &value)
 {
     startupCondition = value;
@@ -112,7 +124,8 @@ void SchedulerJob::setMinAltitude(const double &value)
 
 bool SchedulerJob::hasAltitudeConstraint() const
 {
-    return hasMinAltitude();
+    return hasMinAltitude() ||
+           (enforceArtificialHorizon && (getHorizon() != nullptr) && getHorizon()->altitudeConstraintsExist());
 }
 
 void SchedulerJob::setMinMoonSeparation(const double &value)
@@ -404,6 +417,11 @@ void SchedulerJob::setEnforceTwilight(bool value)
     calculateDawnDusk(startupTime, nextDawn, nextDusk);
 }
 
+void SchedulerJob::setEnforceArtificialHorizon(bool value)
+{
+    enforceArtificialHorizon = value;
+}
+
 void SchedulerJob::setEstimatedTimeCell(QTableWidgetItem *value)
 {
     estimatedTimeCell = value;
@@ -764,6 +782,15 @@ bool SchedulerJob::increasingStartupTimeOrder(SchedulerJob const *job1, Schedule
     return job1->getStartupTime() < job2->getStartupTime();
 }
 
+// This uses both the user-setting minAltitude, as well as any artificial horizon
+// constraints the user might have setup.
+double SchedulerJob::getMinAltitudeConstraint(double azimuth) const
+{
+    double constraint = getMinAltitude();
+    if (getHorizon() != nullptr && enforceArtificialHorizon)
+        constraint = std::max(constraint, getHorizon()->altitudeConstraint(azimuth));
+    return constraint;
+}
 
 int16_t SchedulerJob::getAltitudeScore(QDateTime const &when, double *altPtr) const
 {
@@ -788,12 +815,15 @@ int16_t SchedulerJob::getAltitudeScore(QDateTime const &when, double *altPtr) co
     CachingDms const LST = getGeo()->GSTtoLST(getGeo()->LTtoUT(ltWhen).gst());
     o.EquatorialToHorizontal(&LST, getGeo()->lat());
     double const altitude = o.alt().Degrees();
+    double const azimuth = o.az().Degrees();
     if (altPtr != nullptr)
         *altPtr = altitude;
 
     double const SETTING_ALTITUDE_CUTOFF = Options::settingAltitudeCutoff();
     int16_t score = BAD_SCORE - 1;
 
+    const double minAlt = getMinAltitudeConstraint(azimuth);
+
     // If altitude is negative, bad score
     // FIXME: some locations may allow negative altitudes
     if (altitude < 0)
@@ -803,7 +833,7 @@ int16_t SchedulerJob::getAltitudeScore(QDateTime const &when, double *altPtr) co
     else if (hasAltitudeConstraint())
     {
         // If under altitude constraint, bad score
-        if (altitude < getMinAltitude())
+        if (altitude < minAlt)
             score = BAD_SCORE;
         // Else if setting and under altitude cutoff, job would end soon after starting, bad score
         // FIXME: half bad score when under altitude cutoff risk getting positive again
@@ -815,7 +845,7 @@ int16_t SchedulerJob::getAltitudeScore(QDateTime const &when, double *altPtr) co
             else if (offset < 0.0)
                 offset += 24.0;
             if (0.0 <= offset && offset < 12.0)
-                if (altitude - SETTING_ALTITUDE_CUTOFF < getMinAltitude())
+                if (altitude - SETTING_ALTITUDE_CUTOFF < minAlt)
                     score = BAD_SCORE / 2;
         }
     }
@@ -978,8 +1008,10 @@ QDateTime SchedulerJob::calculateAltitudeTime(QDateTime const &when) const
         CachingDms const LST = getGeo()->GSTtoLST(getGeo()->LTtoUT(ltOffset).gst());
         o.EquatorialToHorizontal(&LST, getGeo()->lat());
         double const altitude = o.alt().Degrees();
+        double const azimuth = o.az().Degrees();
+        double const minAlt = getMinAltitudeConstraint(azimuth);
 
-        if (getMinAltitude() <= altitude)
+        if (minAlt <= altitude)
         {
             // Don't test proximity to dawn in this situation, we only cater for altitude here
 
@@ -994,7 +1026,7 @@ QDateTime SchedulerJob::calculateAltitudeTime(QDateTime const &when) const
             else if (offset < 0.0)
                 offset += 24.0;
             if (0.0 <= offset && offset < 12.0)
-                if (altitude - SETTING_ALTITUDE_CUTOFF < getMinAltitude())
+                if (altitude - SETTING_ALTITUDE_CUTOFF < minAlt)
                     continue;
 
             return ltOffset;
@@ -1111,7 +1143,7 @@ void SchedulerJob::calculateDawnDusk(QDateTime const &when, QDateTime &nextDawn,
         startup = getLocalTime();
 
     // Our local midnight - the KStarsDateTime date+time constructor is safe for local times
-    KStarsDateTime midnight(startup.date(), QTime(0,0), Qt::LocalTime);
+    KStarsDateTime midnight(startup.date(), QTime(0, 0), Qt::LocalTime);
 
     QDateTime dawn = startup, dusk = startup;
 
@@ -1123,11 +1155,13 @@ void SchedulerJob::calculateDawnDusk(QDateTime const &when, QDateTime &nextDawn,
 
         // If dawn is in the past compared to this observation, fetch the next dawn
         if (dawn <= startup)
-            dawn = getGeo()->UTtoLT(ksal.getDate().addSecs((ksal.getDawnAstronomicalTwilight() * 24.0 + Options::dawnOffset()) * 3600.0));
+            dawn = getGeo()->UTtoLT(ksal.getDate().addSecs((ksal.getDawnAstronomicalTwilight() * 24.0 + Options::dawnOffset()) *
+                                    3600.0));
 
         // If dusk is in the past compared to this observation, fetch the next dusk
         if (dusk <= startup)
-            dusk = getGeo()->UTtoLT(ksal.getDate().addSecs((ksal.getDuskAstronomicalTwilight() * 24.0 + Options::duskOffset()) * 3600.0));
+            dusk = getGeo()->UTtoLT(ksal.getDate().addSecs((ksal.getDuskAstronomicalTwilight() * 24.0 + Options::duskOffset()) *
+                                    3600.0));
     }
 
     // Now we have the next events:
diff --git a/kstars/ekos/scheduler/schedulerjob.h b/kstars/ekos/scheduler/schedulerjob.h
index 28929de00..9fc13c9f3 100644
--- a/kstars/ekos/scheduler/schedulerjob.h
+++ b/kstars/ekos/scheduler/schedulerjob.h
@@ -16,11 +16,12 @@
 #include "ksmoon.h"
 #include "kstarsdatetime.h"
 
+class ArtificialHorizon;
 class QTableWidgetItem;
 class QLabel;
 class KSMoon;
 class TestSchedulerUnit;
-
+class TestEkosSchedulerOps;
 class dms;
 
 class SchedulerJob
@@ -254,6 +255,15 @@ class SchedulerJob
         void setEnforceTwilight(bool value);
         /** @} */
 
+        /** @brief Whether to restrict job to night time. */
+        /** @{ */
+        bool getEnforceArtificialHorizon() const
+        {
+            return enforceArtificialHorizon;
+        }
+        void setEnforceArtificialHorizon(bool value);
+        /** @} */
+
         /** @brief Current name of the scheduler job. */
         /** @{ */
         QString getName() const
@@ -618,10 +628,17 @@ class SchedulerJob
              */
         static double findAltitude(const SkyPoint &target, const QDateTime &when, bool *is_setting = nullptr, bool debug = false);
 
+        /**
+             * @brief getMinAltitudeConstraint Find minimum allowed altitude for this job at the given azimuth.
+             * @param azimuth Azimuth
+             * @return Minimum allowed altitude of the target at the specific azimuth.
+             */
+        double getMinAltitudeConstraint(double azimuth) const;
     private:
         // Private constructor for unit testing.
         SchedulerJob(KSMoon *moonPtr);
         friend TestSchedulerUnit;
+        friend TestEkosSchedulerOps;
 
         /** @brief Setter used in the unit test to fix the local time. Otherwise getter gets from KStars instance. */
         /** @{ */
@@ -649,6 +666,20 @@ class SchedulerJob
         }
         /** @} */
 
+        /** @brief Setter used in testing to fix the artificial horizon. Otherwise getter gets from KStars instance. */
+        /** @{ */
+        static const ArtificialHorizon *getHorizon();
+        static void setHorizon(ArtificialHorizon *horizon)
+        {
+            storedHorizon = horizon;
+        }
+        static bool hasHorizon()
+        {
+            return storedHorizon != nullptr;
+        }
+
+        /** @} */
+
         QString name;
         SkyPoint targetCoords;
         double rotation { -1 };
@@ -682,6 +713,7 @@ class SchedulerJob
 
         bool enforceWeather { false };
         bool enforceTwilight { false };
+        bool enforceArtificialHorizon { false };
 
         QDateTime nextDawn;
         QDateTime nextDusk;
@@ -725,4 +757,5 @@ class SchedulerJob
         // These are used in testing, instead of KStars::Instance() resources
         static KStarsDateTime *storedLocalTime;
         static GeoLocation *storedGeo;
+        static ArtificialHorizon *storedHorizon;
 };
diff --git a/kstars/skycomponents/artificialhorizoncomponent.cpp b/kstars/skycomponents/artificialhorizoncomponent.cpp
index f34356a3b..22bd7ad37 100644
--- a/kstars/skycomponents/artificialhorizoncomponent.cpp
+++ b/kstars/skycomponents/artificialhorizoncomponent.cpp
@@ -612,6 +612,15 @@ const ArtificialHorizonEntity *ArtificialHorizon::getConstraintAbove(double azim
     return entity;
 }
 
+double ArtificialHorizon::altitudeConstraint(double azimuthDegrees) const
+{
+    const ArtificialHorizonEntity *horizonBelow = getConstraintBelow(azimuthDegrees, 90.0, nullptr);
+    if (horizonBelow == nullptr)
+        return UNDEFINED_ALTITUDE;
+    bool ignore = false;
+    return horizonBelow->altitudeConstraint(azimuthDegrees, &ignore);
+}
+
 const ArtificialHorizonEntity *ArtificialHorizon::getConstraintBelow(double azimuthDegrees, double altitudeDegrees,
         const ArtificialHorizonEntity *ignore) const
 {
diff --git a/kstars/skycomponents/artificialhorizoncomponent.h b/kstars/skycomponents/artificialhorizoncomponent.h
index 151cbc28e..6295aa766 100644
--- a/kstars/skycomponents/artificialhorizoncomponent.h
+++ b/kstars/skycomponents/artificialhorizoncomponent.h
@@ -84,6 +84,10 @@ class ArtificialHorizon
         // Returns true if the azimuth/altitude point is not blocked by the artificial horzon entities.
         bool isVisible(double azimuthDegrees, double altitudeDegrees) const;
 
+        // returns the (highest) altitude constraint at the given azimuth.
+        // If there are no constraints, then it returns -90.
+        double altitudeConstraint(double azimuthDegrees) const;
+
         // Finds the nearest enabled constraint at the azimuth and above or below (not not exactly at)
         // the altitude given.
         const ArtificialHorizonEntity *getConstraintAbove(double azimuthDegrees, double altitudeDegrees,


More information about the kde-doc-english mailing list