[education/kstars] kstars/ekos/guide/internalguide: The AI feed-forward block in cgmath::performProcessing() fetches the guide frame's declination with

Jasem Mutlaq <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit 3384173c339d8b136b4af763ce27ca18a38f642f by Jasem Mutlaq, on behalf of Andreas R..
Committed on 30/07/2026 at 19:47.
Pushed by mutlaqja into branch 'master'.

The AI feed-forward block in cgmath::performProcessing() fetches the guide frame's declination with
QVariant::toDouble(), but OBJCTDEC is a sexagesimal string, so the conversion always fails and
the declination silently stays 0.0 on every frame.

The AI feed-forward model needs mount altitude (refraction), declination and site latitude (parallactic
angle) and pier side (to reset state across a meridian flip). cgmath::performProcessing() read all of
these out of the guide frame's FITS header, which has three problems.
1. The keywords only exist when the camera driver snoops the mount.
INDI::CCD::addFITSKeywords() writes them only for LIGHT frames with valid J2000RA/J2000DE and
valid site coordinates, and gates OBJCTAZ/OBJCTALT additionally on a valid Airmass
(indiccd.cpp:2232). When any of that is missing the code silently substituted altitude 45° and
azimuth 180°, so the RA refraction term k_ref / cos^2(alt) was evaluated at a fictitious altitude with
nothing in the log to say so.
2. A missing PIERSIDE left pier_side_east permanently false, so the meridian-flip check never
fired and MountSpecificGuider::resetSession(true) was never called. The persisted Kalman state,
including periodic-error phase, then carried across a flip with inverted axis sense.
3. Inference and training disagreed on the source. OpsAIGuide::slotOnGuideStats() records
altitude, azimuth and declination from mount()->currentCoordinates() while collecting
system-identification data, so the trained coefficients were fitted against mount-derived values and
then evaluated against header-derived ones.

cgmath owns m_AIDebugFile, allocated on demand in performProcessing() when the AI feed-forward
model runs. reset() deletes it so each guiding session gets a fresh log, but the destructor only freed
the two drift buffers, so the QFile allocated for the final session was leaked when cgmath itself
went away.

M  +68   -45   kstars/ekos/guide/internalguide/gmath.cpp
M  +26   -0    kstars/ekos/guide/internalguide/gmath.h
M  +25   -0    kstars/ekos/guide/internalguide/internalguider.cpp
M  +6    -0    kstars/ekos/guide/internalguide/internalguider.h

https://invent.kde.org/education/kstars/-/commit/3384173c339d8b136b4af763ce27ca18a38f642f

diff --git a/kstars/ekos/guide/internalguide/gmath.cpp b/kstars/ekos/guide/internalguide/gmath.cpp
index f1bad9e18b..9d2588e28c 100644
--- a/kstars/ekos/guide/internalguide/gmath.cpp
+++ b/kstars/ekos/guide/internalguide/gmath.cpp
@@ -95,6 +95,11 @@ cgmath::~cgmath()
 {
     delete[] drift[GUIDE_RA];
     delete[] drift[GUIDE_DEC];
+
+    // reset() closes this between sessions, but it is also owned across the object's whole
+    // lifetime, so it has to be released here for the case where the last session is never reset.
+    delete m_AIDebugFile;
+    m_AIDebugFile = nullptr;
 }
 
 bool cgmath::setVideoParameters(int vid_wd, int vid_ht, int binX, int binY)
@@ -919,46 +924,76 @@ void cgmath::performProcessing(Ekos::GuideState state, QSharedPointer<FITSData>
         m_sessionStartTime = current_time_sec;
         frameData.t_session_sec = current_time_sec;
 
-        // Fetch altitude from FITS header if available
-        QVariant altVariant;
-        if (imageData->getRecordValue("OBJCTALT", altVariant))
-        {
-            frameData.altitude_deg = altVariant.toDouble();
-        }
-        else
-        {
-            frameData.altitude_deg = 45.0;
-        }
+        // Mount pointing state. Prefer the live mount coordinates pushed in via setMountState():
+        // they are maintained from the mount's own property updates, independently of the image
+        // pipeline, so they are present for every frame and match the values the data-collection
+        // wizard recorded while training. The FITS-header path below is kept as a fallback for the
+        // case where no mount is connected to the guide module.
+        double dec_target = 0.0;
+        double lat_target = 45.0;
 
-        // Fetch azimuth from FITS header if available
-        QVariant azVariant;
-        if (imageData->getRecordValue("OBJCTAZ", azVariant))
+        if (m_MountState.valid)
         {
-            frameData.azimuth_deg = azVariant.toDouble();
+            frameData.altitude_deg   = m_MountState.altitude_deg;
+            frameData.azimuth_deg    = m_MountState.azimuth_deg;
+            frameData.pier_side_east = m_MountState.pier_side_east;
+            dec_target               = m_MountState.declination_deg;
+            lat_target               = m_MountState.latitude_deg;
         }
         else
         {
-            frameData.azimuth_deg = 180.0;
-        }
+            // Fetch altitude from FITS header if available
+            QVariant altVariant;
+            if (imageData->getRecordValue("OBJCTALT", altVariant))
+            {
+                frameData.altitude_deg = altVariant.toDouble();
+            }
+            else
+            {
+                frameData.altitude_deg = 45.0;
+            }
 
-        // Fetch DEC for parallactic angle computation
-        double dec_target = 0.0;
-        QVariant decVariant;
-        if (imageData->getRecordValue("OBJCTDEC", decVariant))
-        {
-            bool ok;
-            double d = decVariant.toDouble(&ok);
-            if (ok) dec_target = d;
-        }
+            // Fetch azimuth from FITS header if available
+            QVariant azVariant;
+            if (imageData->getRecordValue("OBJCTAZ", azVariant))
+            {
+                frameData.azimuth_deg = azVariant.toDouble();
+            }
+            else
+            {
+                frameData.azimuth_deg = 180.0;
+            }
 
-        // Fetch Latitude
-        double lat_target = 45.0;
-        QVariant latVariant;
-        if (imageData->getRecordValue("SITELAT", latVariant))
-        {
-            bool ok;
-            double l = latVariant.toDouble(&ok);
-            if (ok) lat_target = l;
+            // Fetch DEC for parallactic angle computation.
+            // OBJCTDEC is written by INDI as a sexagesimal string ("dd mm ss", see
+            // INDI::CCD::addFITSKeywords) so it must be parsed with dms::fromString();
+            // QVariant::toDouble() always fails on it and would silently leave the declination
+            // at 0. The numeric DEC card is used as a fallback, mirroring FITSData::parseSolution().
+            QVariant decVariant;
+            if (imageData->getRecordValue("OBJCTDEC", decVariant))
+                dec_target = dms::fromString(decVariant.toString(), true).Degrees();
+            else if (imageData->getRecordValue("DEC", decVariant))
+            {
+                bool ok;
+                const double d = decVariant.toDouble(&ok);
+                if (ok) dec_target = d;
+            }
+
+            // Fetch Latitude
+            QVariant latVariant;
+            if (imageData->getRecordValue("SITELAT", latVariant))
+            {
+                bool ok;
+                const double l = latVariant.toDouble(&ok);
+                if (ok) lat_target = l;
+            }
+
+            // Fetch PierSide from FITS header if available
+            QVariant pierVariant;
+            if (imageData->getRecordValue("PIERSIDE", pierVariant))
+                frameData.pier_side_east = (pierVariant.toString().toUpper() == "EAST");
+            else
+                frameData.pier_side_east = false; // default
         }
 
         // Calculate Parallactic Angle (q)
@@ -977,18 +1012,6 @@ void cgmath::performProcessing(Ekos::GuideState state, QSharedPointer<FITSData>
             frameData.parallactic_angle_deg = 0.0;
         }
 
-        // Fetch PierSide from FITS header if available
-        QVariant pierVariant;
-        if (imageData->getRecordValue("PIERSIDE", pierVariant))
-        {
-            QString pierStr = pierVariant.toString().toUpper();
-            frameData.pier_side_east = (pierStr == "EAST");
-        }
-        else
-        {
-            frameData.pier_side_east = false; // default
-        }
-
         static bool last_pier_side_east = frameData.pier_side_east;
         // If this is the first time we're setting it up, just initialize it without resetting
         static bool pier_initialized = false;
diff --git a/kstars/ekos/guide/internalguide/gmath.h b/kstars/ekos/guide/internalguide/gmath.h
index dce953d646..df6c7a9c2c 100644
--- a/kstars/ekos/guide/internalguide/gmath.h
+++ b/kstars/ekos/guide/internalguide/gmath.h
@@ -156,6 +156,29 @@ class cgmath : public QObject
         {
             return m_aiRequiredButUnavailable;
         }
+
+        /**
+         * @brief Pointing state of the mount, pushed in by the guider whenever the mount reports new
+         *        coordinates. This is the preferred source for the AI feed-forward physics model: it is
+         *        maintained independently of the image pipeline, so it is available for every frame
+         *        regardless of whether the camera driver snoops the mount and populates FITS headers.
+         *        Declination is apparent (JNow), which is what the parallactic angle needs.
+         */
+        struct MountState
+        {
+            double altitude_deg    { 45.0 };
+            double azimuth_deg     { 180.0 };
+            double declination_deg { 0.0 };
+            double latitude_deg    { 45.0 };
+            bool   pier_side_east  { false };
+            /// False until the mount has reported at least once; the FITS-header path is used instead.
+            bool   valid           { false };
+        };
+
+        void setMountState(const MountState &state)
+        {
+            m_MountState = state;
+        }
         const cproc_out_params *getOutputParameters() const
         {
             return &out_params;
@@ -286,6 +309,9 @@ class cgmath : public QObject
         GuideOutput m_lastAIPrediction;
         double m_sessionStartTime { 0.0 };
 
+        /// Latest mount pointing state; see setMountState(). Stays invalid if no mount is connected.
+        MountState m_MountState;
+
         // Accumulate pulses sent between camera frames
         double m_accumulated_pulse_ra { 0.0 };
         double m_accumulated_pulse_dec { 0.0 };
diff --git a/kstars/ekos/guide/internalguide/internalguider.cpp b/kstars/ekos/guide/internalguide/internalguider.cpp
index a3e061fc9c..cf6054fe5f 100644
--- a/kstars/ekos/guide/internalguide/internalguider.cpp
+++ b/kstars/ekos/guide/internalguide/internalguider.cpp
@@ -15,6 +15,7 @@
 #include "fitsviewer/fitsdata.h"
 #include "fitsviewer/fitsview.h"
 #include "guidealgorithms.h"
+#include "kstarsdata.h"
 #include "ksnotification.h"
 #include "ekos/auxiliary/stellarsolverprofileeditor.h"
 #include "fitsviewer/fitsdata.h"
@@ -911,6 +912,30 @@ void InternalGuider::setDECSwap(bool enable)
     pmath->getMutableCalibration()->setDeclinationSwapEnabled(enable);
 }
 
+void InternalGuider::setMountCoords(const SkyPoint &position, ISD::Mount::PierSide side)
+{
+    GuideInterface::setMountCoords(position, side);
+
+    if (!pmath)
+        return;
+
+    // The AI feed-forward physics model needs altitude (refraction), declination and site latitude
+    // (parallactic angle) and the pier side (to reset its state across a meridian flip). Sourcing them
+    // here rather than from FITS header keywords means they are available for every guide frame -- the
+    // header values only exist when the camera driver snoops the mount -- and that they match the
+    // values OpsAIGuide recorded during data collection.
+    cgmath::MountState state;
+    state.altitude_deg    = mountAltitude.Degrees();
+    state.azimuth_deg     = mountAzimuth.Degrees();
+    state.declination_deg = mountDEC.Degrees();
+    state.pier_side_east  = (side == ISD::Mount::PIER_EAST);
+    if (KStarsData::Instance() && KStarsData::Instance()->geo() && KStarsData::Instance()->geo()->lat())
+        state.latitude_deg = KStarsData::Instance()->geo()->lat()->Degrees();
+    state.valid = true;
+
+    pmath->setMountState(state);
+}
+
 void InternalGuider::setStarDetectionAlgorithm(int index)
 {
     if (index == SEP_MULTISTAR && !pmath->usingSEPMultiStar())
diff --git a/kstars/ekos/guide/internalguide/internalguider.h b/kstars/ekos/guide/internalguide/internalguider.h
index 56788cc892..c6c0fd233c 100644
--- a/kstars/ekos/guide/internalguide/internalguider.h
+++ b/kstars/ekos/guide/internalguide/internalguider.h
@@ -74,6 +74,12 @@ class InternalGuider : public GuideInterface
         // Set Star Position
         void setStarPosition(QVector3D &starCenter) override;
 
+        /**
+         * @brief Caches the mount pointing state (base class) and forwards it to the guide math,
+         *        where the AI feed-forward model uses it in preference to FITS header keywords.
+         */
+        void setMountCoords(const SkyPoint &position, ISD::Mount::PierSide side) override;
+
         // Select algorithm
         void setStarDetectionAlgorithm(int index);
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.