[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