[education/kstars] kstars/ekos/guide: Ai guider oscillator

Jasem Mutlaq <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit efb2b5317daedcb8a36008794f17da0f4eb48a5d by Jasem Mutlaq, on behalf of Pavan Kumar S G.
Committed on 31/07/2026 at 14:41.
Pushed by mutlaqja into branch 'master'.

Ai guider oscillator

M  +62   -17   kstars/ekos/guide/aiguideprotocol.cpp
M  +3    -0    kstars/ekos/guide/aiguideprotocol.h
M  +8    -0    kstars/ekos/guide/internalguide/direct_drive_guider.cpp
M  +1    -1    kstars/ekos/guide/internalguide/direct_drive_guider.h
M  +6    -1    kstars/ekos/guide/internalguide/gmath.cpp
M  +82   -2    kstars/ekos/guide/internalguide/harmonic_guider.cpp
M  +12   -4    kstars/ekos/guide/internalguide/harmonic_guider.h
M  +12   -0    kstars/ekos/guide/internalguide/mount_guider.h
M  +8    -0    kstars/ekos/guide/internalguide/worm_gear_guider.cpp
M  +1    -1    kstars/ekos/guide/internalguide/worm_gear_guider.h
M  +10   -0    kstars/ekos/guide/offlinetrainer/train_harmonic.py

https://invent.kde.org/education/kstars/-/commit/efb2b5317daedcb8a36008794f17da0f4eb48a5d

diff --git a/kstars/ekos/guide/aiguideprotocol.cpp b/kstars/ekos/guide/aiguideprotocol.cpp
index 0b387709a2..8ce49dd91f 100644
--- a/kstars/ekos/guide/aiguideprotocol.cpp
+++ b/kstars/ekos/guide/aiguideprotocol.cpp
@@ -28,6 +28,64 @@ AIGuideProtocol::AIGuideProtocol(Guide *guide) : QObject(guide), m_Guide(guide)
     setObjectName("AIGuideProtocol");
 }
 
+QJsonObject AIGuideProtocol::buildFingerprint() const
+{
+    QJsonObject fingerprint;
+    fingerprint["guide_exposure_s"] = m_Guide ? m_Guide->exposure() : 0.0;
+    fingerprint["guide_binning"] = Options::guideBinning();
+    fingerprint["ra_proportional_gain"] = Options::rAProportionalGain();
+    fingerprint["dec_proportional_gain"] = Options::dECProportionalGain();
+    fingerprint["ra_integral_gain"] = Options::rAIntegralGain();
+    fingerprint["dec_integral_gain"] = Options::dECIntegralGain();
+    fingerprint["ra_min_pulse_arcsec"] = Options::rAMinimumPulseArcSec();
+    fingerprint["dec_min_pulse_arcsec"] = Options::dECMinimumPulseArcSec();
+    fingerprint["ra_max_pulse_arcsec"] = static_cast<double>(Options::rAMaximumPulseArcSec());
+    fingerprint["dec_max_pulse_arcsec"] = static_cast<double>(Options::dECMaximumPulseArcSec());
+    fingerprint["ra_hysteresis"] = Options::rAHysteresis();
+    fingerprint["dec_hysteresis"] = Options::dECHysteresis();
+    fingerprint["ra_pulse_algorithm"] = 0;
+    fingerprint["dec_pulse_algorithm"] = 0;
+    fingerprint["all_directions_enabled"] = true;
+    return fingerprint;
+}
+
+// Settings can change mid-protocol; the recorded fingerprint must describe the data
+// as collected, so it is rebuilt at every save and divergence is reported once.
+void AIGuideProtocol::refreshFingerprint()
+{
+    const QJsonObject current = buildFingerprint();
+    const QJsonObject recorded = m_SysIdData["model_fingerprint"].toObject();
+
+    QStringList changed;
+    for (auto it = current.begin(); it != current.end(); ++it)
+    {
+        const QJsonValue old = recorded[it.key()];
+        if (old != it.value())
+            changed << QString("%1: %2 -> %3").arg(it.key(), old.toVariant().toString(),
+                                                   it.value().toVariant().toString());
+    }
+    if (!changed.isEmpty())
+    {
+        if (!m_SettingsChangedWarned)
+        {
+            m_SettingsChangedWarned = true;
+            emit protocolLog(QString("WARNING: guide settings changed during the protocol (%1). "
+                                     "Recording the current values — guide with these same settings "
+                                     "or the weights will be rejected.").arg(changed.join(", ")));
+        }
+        m_SysIdData["model_fingerprint"] = current;
+
+        QJsonObject equipment = m_SysIdData["equipment"].toObject();
+        equipment["guide_exposure_ms"] = m_Guide ? static_cast<int>(m_Guide->exposure() * 1000.0) : 0;
+        if (m_Guide && m_Guide->focalLength() > 0)
+        {
+            const int binning = std::max(1, Options::guideBinning().left(1).toInt());
+            equipment["pixel_scale_arcsec_per_px"] = (206.265 * m_Guide->pixelSizeX() * binning) / m_Guide->focalLength();
+        }
+        m_SysIdData["equipment"] = equipment;
+    }
+}
+
 void AIGuideProtocol::enforceSettings()
 {
     if (!m_SettingsEnforced)
@@ -119,23 +177,8 @@ void AIGuideProtocol::start(const QString &mountType)
     }
     m_SysIdData["equipment"] = equipment;
 
-    QJsonObject fingerprint;
-    fingerprint["guide_exposure_s"] = m_Guide ? m_Guide->exposure() : 0.0;
-    fingerprint["guide_binning"] = Options::guideBinning();
-    fingerprint["ra_proportional_gain"] = Options::rAProportionalGain();
-    fingerprint["dec_proportional_gain"] = Options::dECProportionalGain();
-    fingerprint["ra_integral_gain"] = Options::rAIntegralGain();
-    fingerprint["dec_integral_gain"] = Options::dECIntegralGain();
-    fingerprint["ra_min_pulse_arcsec"] = Options::rAMinimumPulseArcSec();
-    fingerprint["dec_min_pulse_arcsec"] = Options::dECMinimumPulseArcSec();
-    fingerprint["ra_max_pulse_arcsec"] = static_cast<double>(Options::rAMaximumPulseArcSec());
-    fingerprint["dec_max_pulse_arcsec"] = static_cast<double>(Options::dECMaximumPulseArcSec());
-    fingerprint["ra_hysteresis"] = Options::rAHysteresis();
-    fingerprint["dec_hysteresis"] = Options::dECHysteresis();
-    fingerprint["ra_pulse_algorithm"] = 0;
-    fingerprint["dec_pulse_algorithm"] = 0;
-    fingerprint["all_directions_enabled"] = true;
-    m_SysIdData["model_fingerprint"] = fingerprint;
+    m_SettingsChangedWarned = false;
+    m_SysIdData["model_fingerprint"] = buildFingerprint();
 
     m_SysIdData["sessions"] = QJsonArray();
 
@@ -561,6 +604,7 @@ void AIGuideProtocol::processProtocol()
                 sessions.append(phaseRecord);
                 m_SysIdData["sessions"] = sessions;
 
+                refreshFingerprint();
                 m_LogFile.setFileName(m_LogFilename);
                 if (m_LogFile.open(QIODevice::WriteOnly | QIODevice::Text))
                 {
@@ -700,6 +744,7 @@ void AIGuideProtocol::processProtocol()
                     sessions.append(pulseSession);
                     m_SysIdData["sessions"] = sessions;
 
+                    refreshFingerprint();
                     m_LogFile.setFileName(m_LogFilename);
                     if (m_LogFile.open(QIODevice::WriteOnly | QIODevice::Text))
                     {
diff --git a/kstars/ekos/guide/aiguideprotocol.h b/kstars/ekos/guide/aiguideprotocol.h
index 251641d261..3a31fb2045 100644
--- a/kstars/ekos/guide/aiguideprotocol.h
+++ b/kstars/ekos/guide/aiguideprotocol.h
@@ -103,6 +103,8 @@ class AIGuideProtocol : public QObject
     private:
         void enforceSettings();
         void restoreSettings();
+        QJsonObject buildFingerprint() const;
+        void refreshFingerprint();
 
         Guide *m_Guide { nullptr };
         int m_TotalPhases { 0 };
@@ -139,6 +141,7 @@ class AIGuideProtocol : public QObject
         QElapsedTimer m_FrameTimer;
 
         bool m_SettingsEnforced { false };
+        bool m_SettingsChangedWarned { false };
         int m_OrigRAAlgorithm { 0 };
         int m_OrigDECAlgorithm { 0 };
         bool m_OrigRAEnabled { true };
diff --git a/kstars/ekos/guide/internalguide/direct_drive_guider.cpp b/kstars/ekos/guide/internalguide/direct_drive_guider.cpp
index ada4f3fdee..a64d4575a1 100644
--- a/kstars/ekos/guide/internalguide/direct_drive_guider.cpp
+++ b/kstars/ekos/guide/internalguide/direct_drive_guider.cpp
@@ -29,9 +29,12 @@ bool fpDoubleClose(double a, double b, double tol = 1e-4)
 // ---------------------------------------------------------------------------
 bool DirectDriveGuider::validateFingerprint(const QJsonObject &fp)
 {
+    m_FingerprintError.clear();
     if (fp.isEmpty())
         return true;
 
+    QStringList mismatches;
+
     const struct
     {
         const char *key;
@@ -51,6 +54,8 @@ bool DirectDriveGuider::validateFingerprint(const QJsonObject &fp)
         {
             qCWarning(KSTARS_EKOS_GUIDE) << "AI weights rejected:" << c.key << "recorded"
                                          << fp[c.key].toDouble() << "current" << c.current;
+            mismatches << QString("%1: weights %2, current %3")
+                       .arg(c.key).arg(fp[c.key].toDouble()).arg(c.current);
             ok = false;
         }
     }
@@ -59,9 +64,12 @@ bool DirectDriveGuider::validateFingerprint(const QJsonObject &fp)
     {
         qCWarning(KSTARS_EKOS_GUIDE) << "AI weights rejected: guide_binning recorded"
                                      << fp["guide_binning"].toString() << "current" << Options::guideBinning();
+        mismatches << QString("guide_binning: weights %1, current %2")
+                   .arg(fp["guide_binning"].toString(), Options::guideBinning());
         ok = false;
     }
 
+    m_FingerprintError = mismatches.join("\n");
     return ok;
 }
 
diff --git a/kstars/ekos/guide/internalguide/direct_drive_guider.h b/kstars/ekos/guide/internalguide/direct_drive_guider.h
index 6f6936ecca..6bf42f0306 100644
--- a/kstars/ekos/guide/internalguide/direct_drive_guider.h
+++ b/kstars/ekos/guide/internalguide/direct_drive_guider.h
@@ -70,5 +70,5 @@ class DirectDriveGuider : public MountSpecificGuider
         // Rejects weights whose recorded equipment fingerprint (exposure, binning, gains, …)
         // does not match the current session, so a model trained at a different binning /
         // pixel-scale is not silently applied. Mirrors WormGearGuider / HarmonicGuider.
-        static bool validateFingerprint(const QJsonObject &fp);
+        bool validateFingerprint(const QJsonObject &fp);
 };
diff --git a/kstars/ekos/guide/internalguide/gmath.cpp b/kstars/ekos/guide/internalguide/gmath.cpp
index 9d2588e28c..61cddd2d8f 100644
--- a/kstars/ekos/guide/internalguide/gmath.cpp
+++ b/kstars/ekos/guide/internalguide/gmath.cpp
@@ -292,9 +292,14 @@ void cgmath::start()
                 qCWarning(KSTARS_EKOS_GUIDE) << ">>> AI GUIDER FAILED TO LOAD WEIGHTS OR FINGERPRINT MISMATCH:" << weightsPath;
                 if (useAIAlgorithm)
                 {
+                    const QString reason = m_AIGuider ? m_AIGuider->fingerprintError() : QString();
+                    const QString detail = reason.isEmpty()
+                                           ? i18n("The weights file could not be read or does not match this mount type.")
+                                           : i18n("Settings that do not match the weights:\n%1", reason);
                     emit newLog(i18n("AI Guider failed to load weights or fingerprint mismatched. Guiding aborted."));
                     KSNotification::error(
-                        i18n("AI Guider failed to load weights or Fingerprint mismatched!\n\nRe-run the Guide AI Assistant to generate new weights for your current settings, or switch the Guide Algorithm back to a standard mode. Guiding has been aborted."),
+                        i18n("AI Guider failed to load weights!\n\n%1\n\nChange the settings back to match, re-run the Guide AI Assistant with your current settings, or switch the Guide Algorithm to a standard mode. Guiding has been aborted.",
+                             detail),
                         i18n("AI Guider Error"));
                 }
                 m_AIGuider.reset();
diff --git a/kstars/ekos/guide/internalguide/harmonic_guider.cpp b/kstars/ekos/guide/internalguide/harmonic_guider.cpp
index 70fd9f4423..ff152ee62b 100644
--- a/kstars/ekos/guide/internalguide/harmonic_guider.cpp
+++ b/kstars/ekos/guide/internalguide/harmonic_guider.cpp
@@ -39,6 +39,7 @@ double HarmonicGuider::m_uncorrPosDEC { 0.0 };
 int HarmonicGuider::m_frameCount { 0 };
 double HarmonicGuider::m_typicalRMS { 0.5 };
 double HarmonicGuider::s_activePePeriod { -1.0 };
+double HarmonicGuider::s_activePe2Period { -1.0 };
 
 HarmonicGuider::HarmonicGuider()
 {
@@ -55,9 +56,12 @@ HarmonicGuider::HarmonicGuider()
 
 bool HarmonicGuider::validateFingerprint(const QJsonObject &fp)
 {
+    m_FingerprintError.clear();
     if (fp.isEmpty())
         return true;
 
+    QStringList mismatches;
+
     const struct
     {
         const char *key;
@@ -85,6 +89,8 @@ bool HarmonicGuider::validateFingerprint(const QJsonObject &fp)
         {
             qCWarning(KSTARS_EKOS_GUIDE) << "AI weights rejected:" << c.key << "recorded"
                                          << fp[c.key].toDouble() << "current" << c.current;
+            mismatches << QString("%1: weights %2, current %3")
+                       .arg(c.key).arg(fp[c.key].toDouble()).arg(c.current);
             ok = false;
         }
     }
@@ -93,9 +99,12 @@ bool HarmonicGuider::validateFingerprint(const QJsonObject &fp)
     {
         qCWarning(KSTARS_EKOS_GUIDE) << "AI weights rejected: guide_binning recorded"
                                      << fp["guide_binning"].toString() << "current" << Options::guideBinning();
+        mismatches << QString("guide_binning: weights %1, current %2")
+                   .arg(fp["guide_binning"].toString(), Options::guideBinning());
         ok = false;
     }
 
+    m_FingerprintError = mismatches.join("\n");
     return ok;
 }
 
@@ -131,6 +140,23 @@ bool HarmonicGuider::loadWeights(const QString &weightsPath)
     m_k_ref_dec = phys["k_ref_dec"].toDouble(0.0);
     m_pe_period = phys["pe_period"].toDouble(0.0);
     m_pe_amplitude = phys["pe_amplitude"].toDouble(0.0);
+
+    // Second oscillator: the strongest secondary line the trainer found. Only accept a
+    // period well separated from the primary so the two rotators cannot fight over one line.
+    m_pe2_period = 0.0;
+    m_pe2_amplitude = 0.0;
+    const QJsonArray peLines = phys["pe_lines"].toArray();
+    if (m_pe_period > 0.0 && peLines.size() >= 2)
+    {
+        const QJsonObject line2 = peLines[1].toObject();
+        const double p2 = line2["period_s"].toDouble(0.0);
+        const double a2 = line2["amplitude_px"].toDouble(0.0);
+        if (p2 > 0.0 && a2 > 0.05 && std::abs(p2 - m_pe_period) / m_pe_period > 0.15)
+        {
+            m_pe2_period = p2;
+            m_pe2_amplitude = a2;
+        }
+    }
     m_fit_alt_min = phys["fit_alt_min"].toDouble(35.0);
     m_fit_alt_max = phys["fit_alt_max"].toDouble(65.0);
 
@@ -200,9 +226,10 @@ void HarmonicGuider::resetSession(bool forceReset)
 
     // If the loaded weights describe a different mount (different PE period), the
     // persisted static Kalman state belongs to the previous mount — discard it entirely.
-    if (m_pe_period != s_activePePeriod)
+    if (m_pe_period != s_activePePeriod || m_pe2_period != s_activePe2Period)
     {
         s_activePePeriod = m_pe_period;
+        s_activePe2Period = m_pe2_period;
         forceReset = true;
     }
 
@@ -272,6 +299,24 @@ void HarmonicGuider::buildF(Eigen::Matrix<double, N_STATES, N_STATES> &F, double
         F(DEC_PE_COS, DEC_PE_SIN) = -sin_wdt;
         F(DEC_PE_COS, DEC_PE_COS) = cos_wdt;
     }
+
+    // Second PE line rotates at its own frequency
+    if (m_pe2_period > 0.0)
+    {
+        const double omega2 = 2.0 * M_PI / m_pe2_period;
+        const double c2 = std::cos(omega2 * dt);
+        const double s2 = std::sin(omega2 * dt);
+
+        F(RA_PE2_SIN, RA_PE2_SIN) = c2;
+        F(RA_PE2_SIN, RA_PE2_COS) = s2;
+        F(RA_PE2_COS, RA_PE2_SIN) = -s2;
+        F(RA_PE2_COS, RA_PE2_COS) = c2;
+
+        F(DEC_PE2_SIN, DEC_PE2_SIN) = c2;
+        F(DEC_PE2_SIN, DEC_PE2_COS) = s2;
+        F(DEC_PE2_COS, DEC_PE2_SIN) = -s2;
+        F(DEC_PE2_COS, DEC_PE2_COS) = c2;
+    }
 }
 
 // ── Q-net: compute adaptive process noise ────────────────────────────────────
@@ -321,6 +366,13 @@ HarmonicGuider::computeQ(double snr, double snr_delta,
         Q(DEC_PE_SIN, DEC_PE_SIN) = 0.001 * dt;
         Q(DEC_PE_COS, DEC_PE_COS) = 0.001 * dt;
     }
+    if (m_pe2_period > 0.0)
+    {
+        Q(RA_PE2_SIN, RA_PE2_SIN) = 0.001 * dt;
+        Q(RA_PE2_COS, RA_PE2_COS) = 0.001 * dt;
+        Q(DEC_PE2_SIN, DEC_PE2_SIN) = 0.001 * dt;
+        Q(DEC_PE2_COS, DEC_PE2_COS) = 0.001 * dt;
+    }
 
     return Q;
 }
@@ -388,6 +440,11 @@ void HarmonicGuider::kalmanUpdate(double ra_meas_px, double dec_meas_px, double
         H(0, RA_PE_SIN) = 1.0;
         H(1, DEC_PE_SIN) = 1.0;
     }
+    if (m_pe2_period > 0.0)
+    {
+        H(0, RA_PE2_SIN) = 1.0;
+        H(1, DEC_PE2_SIN) = 1.0;
+    }
 
     // Measurement noise from the current frame's SNR (~0.5 px at SNR 30)
     const double snr_safe = std::max(snr, 5.0);
@@ -461,6 +518,11 @@ GuideOutput HarmonicGuider::predict(const GuideFrameData &frame)
         post_ra  += m_x(RA_PE_SIN);
         post_dec += m_x(DEC_PE_SIN);
     }
+    if (m_pe2_period > 0.0)
+    {
+        post_ra  += m_x(RA_PE2_SIN);
+        post_dec += m_x(DEC_PE2_SIN);
+    }
 
     kalmanPredict(frame.dt, frame.altitude_deg, frame.parallactic_angle_deg);
 
@@ -471,6 +533,11 @@ GuideOutput HarmonicGuider::predict(const GuideFrameData &frame)
         pred_ra  += m_x(RA_PE_SIN);
         pred_dec += m_x(DEC_PE_SIN);
     }
+    if (m_pe2_period > 0.0)
+    {
+        pred_ra  += m_x(RA_PE2_SIN);
+        pred_dec += m_x(DEC_PE2_SIN);
+    }
 
     // Prediction is the expected uncorrected drift over the next interval
     m_lastPredRA  = pred_ra - post_ra;
@@ -525,10 +592,13 @@ void HarmonicGuider::update(double /*ra_error_px*/, double /*dec_error_px*/,
 
 QString HarmonicGuider::stateString() const
 {
+    QString pe = QString::number(m_pe_period, 'f', 1);
+    if (m_pe2_period > 0.0)
+        pe += QString("+%1").arg(m_pe2_period, 0, 'f', 1);
     return QString("Harmonic κ_ra=%1 τ_ra=%2 PE=%3s conf=%4")
            .arg(m_kappa_ra, 0, 'f', 2)
            .arg(m_tau_ra, 0, 'f', 1)
-           .arg(m_pe_period, 0, 'f', 1)
+           .arg(pe)
            .arg(m_confidence, 0, 'f', 2);
 }
 
@@ -594,6 +664,11 @@ GuideOutput HarmonicGuider::darkPredict(double dt_sec)
         post_ra += x(RA_PE_SIN);
         post_dec += x(DEC_PE_SIN);
     }
+    if (m_pe2_period > 0.0)
+    {
+        post_ra += x(RA_PE2_SIN);
+        post_dec += x(DEC_PE2_SIN);
+    }
 
     const double release_ra  = x(RA_SPRING)  * (1.0 - std::exp(-dt_sec / m_tau_ra));
     const double release_dec = x(DEC_SPRING) * (1.0 - std::exp(-dt_sec / m_tau_dec));
@@ -613,6 +688,11 @@ GuideOutput HarmonicGuider::darkPredict(double dt_sec)
         pred_ra += x(RA_PE_SIN);
         pred_dec += x(DEC_PE_SIN);
     }
+    if (m_pe2_period > 0.0)
+    {
+        pred_ra += x(RA_PE2_SIN);
+        pred_dec += x(DEC_PE2_SIN);
+    }
 
     GuideOutput out;
     out.valid = (m_frameCount > warmupFrames());
diff --git a/kstars/ekos/guide/internalguide/harmonic_guider.h b/kstars/ekos/guide/internalguide/harmonic_guider.h
index 49b4b16dd8..a766783caf 100644
--- a/kstars/ekos/guide/internalguide/harmonic_guider.h
+++ b/kstars/ekos/guide/internalguide/harmonic_guider.h
@@ -2,12 +2,13 @@
 /*
  * harmonic_guider.h — Neural Kalman Filter guider for harmonic drive mounts
  *
- * Architecture: 10-state Kalman filter with spring dynamics and PE tracking,
+ * Architecture: 14-state Kalman filter with spring dynamics and two PE oscillators,
  *               plus a small Q-net MLP (~66 parameters) for adaptive process noise.
  *
  * State vector:
  *   [ra_err, ra_vel, spring_ra, pe_sin_ra, pe_cos_ra,
- *    dec_err, dec_vel, spring_dec, pe_sin_dec, pe_cos_dec]
+ *    dec_err, dec_vel, spring_dec, pe_sin_dec, pe_cos_dec,
+ *    pe2_sin_ra, pe2_cos_ra, pe2_sin_dec, pe2_cos_dec]
  *
  * The spring states model elastic wind-up: the flexspline absorbs a fraction κ
  * of each correction pulse and releases it exponentially with time constant τ.
@@ -52,7 +53,7 @@ class HarmonicGuider : public MountSpecificGuider
 
     private:
         // ── Kalman dimensions ────────────────────────────────────────────────
-        static constexpr int N_STATES = 10;
+        static constexpr int N_STATES = 14;
         static constexpr int N_OBS    = 2;
 
         // State indices
@@ -66,6 +67,10 @@ class HarmonicGuider : public MountSpecificGuider
         static constexpr int DEC_SPRING = 7;
         static constexpr int DEC_PE_SIN = 8;
         static constexpr int DEC_PE_COS = 9;
+        static constexpr int RA_PE2_SIN  = 10;
+        static constexpr int RA_PE2_COS  = 11;
+        static constexpr int DEC_PE2_SIN = 12;
+        static constexpr int DEC_PE2_COS = 13;
 
         // ── Spring parameters (loaded from weights JSON) ─────────────────────
         double m_kappa_ra    { 0.2 };   ///< Fraction of RA pulse absorbed by spring [0, 0.9]
@@ -76,6 +81,8 @@ class HarmonicGuider : public MountSpecificGuider
         // ── PE parameters (loaded from weights JSON) ─────────────────────────
         double m_pe_period   { 0.0 };   ///< PE period in seconds (0 = no PE detected)
         double m_pe_amplitude { 0.0 };  ///< Initial PE amplitude estimate (px)
+        double m_pe2_period  { 0.0 };   ///< Second PE line period (0 = single-line model)
+        double m_pe2_amplitude { 0.0 };
 
         // ── Drift / refraction parameters ────────────────────────────────────
         double m_drift_ra    { 0.0 };   ///< Baseline RA drift rate (px/s)
@@ -96,6 +103,7 @@ class HarmonicGuider : public MountSpecificGuider
         /// PE period the persisted static state was built for; a change means the
         /// loaded weights describe a different mount, so the static state is discarded.
         static double s_activePePeriod;
+        static double s_activePe2Period;
 
         // ── Q-net MLP weights (5 → 8 → 2) ───────────────────────────────────
         // Input: [snr_norm, snr_delta_norm, |innov_ra|, |innov_dec|, dt_norm]
@@ -138,7 +146,7 @@ class HarmonicGuider : public MountSpecificGuider
         static constexpr int INNOV_WINDOW = 20;
 
         // ── Helpers ──────────────────────────────────────────────────────────
-        static bool validateFingerprint(const QJsonObject &fp);
+        bool validateFingerprint(const QJsonObject &fp);
         void buildF(Eigen::Matrix<double, N_STATES, N_STATES> &F, double dt) const;
         Eigen::Matrix<double, N_STATES, N_STATES> computeQ(double snr, double snr_delta,
                 double innov_ra, double innov_dec, double dt) const;
diff --git a/kstars/ekos/guide/internalguide/mount_guider.h b/kstars/ekos/guide/internalguide/mount_guider.h
index 7b75ce1a7e..f51c7f6ccf 100644
--- a/kstars/ekos/guide/internalguide/mount_guider.h
+++ b/kstars/ekos/guide/internalguide/mount_guider.h
@@ -137,4 +137,16 @@ class MountSpecificGuider
         {
             return false;
         }
+
+        /**
+         * @brief Human-readable list of fingerprint mismatches from the last loadWeights()
+         * failure, e.g. "guide_exposure_s: weights 0.5, current 1". Empty when none.
+         */
+        QString fingerprintError() const
+        {
+            return m_FingerprintError;
+        }
+
+    protected:
+        QString m_FingerprintError;
 };
diff --git a/kstars/ekos/guide/internalguide/worm_gear_guider.cpp b/kstars/ekos/guide/internalguide/worm_gear_guider.cpp
index e7b64af198..83f39bd519 100644
--- a/kstars/ekos/guide/internalguide/worm_gear_guider.cpp
+++ b/kstars/ekos/guide/internalguide/worm_gear_guider.cpp
@@ -45,9 +45,12 @@ WormGearGuider::WormGearGuider()
 
 bool WormGearGuider::validateFingerprint(const QJsonObject &fp)
 {
+    m_FingerprintError.clear();
     if (fp.isEmpty())
         return true;
 
+    QStringList mismatches;
+
     const struct
     {
         const char *key;
@@ -75,6 +78,8 @@ bool WormGearGuider::validateFingerprint(const QJsonObject &fp)
         {
             qCWarning(KSTARS_EKOS_GUIDE) << "AI weights rejected:" << c.key << "recorded"
                                          << fp[c.key].toDouble() << "current" << c.current;
+            mismatches << QString("%1: weights %2, current %3")
+                       .arg(c.key).arg(fp[c.key].toDouble()).arg(c.current);
             ok = false;
         }
     }
@@ -83,11 +88,14 @@ bool WormGearGuider::validateFingerprint(const QJsonObject &fp)
     {
         qCWarning(KSTARS_EKOS_GUIDE) << "AI weights rejected: guide_binning recorded"
                                      << fp["guide_binning"].toString() << "current" << Options::guideBinning();
+        mismatches << QString("guide_binning: weights %1, current %2")
+                   .arg(fp["guide_binning"].toString(), Options::guideBinning());
         ok = false;
     }
 
     // Pulse algorithm is Standard (0) in training fingerprint; runtime uses AI — skip.
 
+    m_FingerprintError = mismatches.join("\n");
     return ok;
 }
 
diff --git a/kstars/ekos/guide/internalguide/worm_gear_guider.h b/kstars/ekos/guide/internalguide/worm_gear_guider.h
index 341cf0bfb5..3df147cb97 100644
--- a/kstars/ekos/guide/internalguide/worm_gear_guider.h
+++ b/kstars/ekos/guide/internalguide/worm_gear_guider.h
@@ -109,7 +109,7 @@ class WormGearGuider : public MountSpecificGuider
         static constexpr int INNOV_WINDOW = 20;
 
         // ── Helpers ───────────────────────────────────────────────────────────
-        static bool validateFingerprint(const QJsonObject &fp);
+        bool validateFingerprint(const QJsonObject &fp);
         double physicsRA(double t_sec, double altitude_deg) const;
         double physicsDEC(double altitude_deg, double parallactic_angle_deg) const;
         std::array<float, 2> runMLP(float altitude, float snr, float last_ra_pulse, float last_dec_pulse, float dt,
diff --git a/kstars/ekos/guide/offlinetrainer/train_harmonic.py b/kstars/ekos/guide/offlinetrainer/train_harmonic.py
index 345102e44c..0fb96f0ebe 100644
--- a/kstars/ekos/guide/offlinetrainer/train_harmonic.py
+++ b/kstars/ekos/guide/offlinetrainer/train_harmonic.py
@@ -538,6 +538,16 @@ def _fit_drift_params(sysid: dict, guide_exp: float, verbose: bool):
         if len(frames) < 10:
             continue
 
+        # A truncated drift (stopped by the excursion guard) right after a slew measures
+        # settling motion, not drift — one such point poisons the whole refraction fit.
+        requested = float(s.get("duration_s", 0.0))
+        span = sum(f.get("dt", guide_exp) for f in frames[1:])
+        if requested > 0.0 and span < 0.5 * requested:
+            if verbose:
+                print(f"  [drift] skipping {s.get('session_id', '?')}: only {span:.0f}s of "
+                      f"{requested:.0f}s requested — truncated, not a drift measurement")
+            continue
+
         alt = s.get("altitude_deg", 45.0)
 
         t = 0.0
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.