[education/kstars] kstars/ekos/guide: AI Guide: fix PID auto tune DEC refusal and wizard restart behavior

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

AI Guide: fix PID auto tune DEC refusal and wizard restart behavior

M  +65   -8    kstars/ekos/guide/aiguideprotocol.cpp
M  +83   -21   kstars/ekos/guide/aiguidewizard.cpp
M  +4    -0    kstars/ekos/guide/aiguidewizard.h
M  +31   -4    kstars/ekos/guide/offlinetrainer/pid_autotune.py

https://invent.kde.org/education/kstars/-/commit/665a5f38858b696e377738bcc57318f24dd0ff18

diff --git a/kstars/ekos/guide/aiguideprotocol.cpp b/kstars/ekos/guide/aiguideprotocol.cpp
index 5678ca2a42..ff9c706aa4 100644
--- a/kstars/ekos/guide/aiguideprotocol.cpp
+++ b/kstars/ekos/guide/aiguideprotocol.cpp
@@ -1527,12 +1527,56 @@ bool AIGuideProtocol::computeAndApplyAxisGain(const QString &axis, double msPerA
                          .arg(axis, perMag.join(", ")));
         return false;
     }
+    bool lossModeled = false;
+    double kOverride = 0.0, lossMs = 0.0;
     if (kMin <= 0.0 || (kMax - kMin) / kMin > K_MAGNITUDE_CONSISTENCY_TOLERANCE)
     {
-        emit protocolLog(QString("PID Auto-Tune [%1]: process gain disagrees across pulse magnitudes "
-                                 "(%2) -- responses are contaminated, keeping current gain.")
-                         .arg(axis, perMag.join(", ")));
-        return false;
+        // Short pulses delivering LESS per ms is the signature of a fixed per-pulse loss
+        // (motor ramp / stiction on an axis that starts each pulse at rest):
+        // K(mag) = K_inf * (1 - L/mag). Two magnitudes give two equations -- solve them
+        // instead of refusing, but only inside strict sanity bounds.
+        const double magLo = kByMag.firstKey();
+        const double magHi = kByMag.lastKey();
+        const double kLo = medianOf(kByMag.value(magLo));
+        const double kHi = medianOf(kByMag.value(magHi));
+        double hiSpread = 1.0;
+        {
+            const QVector<double> &hi = kByMag.value(magHi);
+            const double hiMin = *std::min_element(hi.begin(), hi.end());
+            const double hiMax = *std::max_element(hi.begin(), hi.end());
+            const double hiMed = medianOf(hi);
+            if (hiMed > 0.0)
+                hiSpread = (hiMax - hiMin) / hiMed;
+        }
+        // Range-based spread over n~6 samples runs wide; 0.25 keeps a real reliability
+        // bar without rejecting a group whose median is well determined.
+        if (kLo > 0.0 && kLo < kHi && hiSpread <= 0.25)
+        {
+            const double r = kLo / kHi;
+            const double L = (1.0 - r) / (1.0 / magLo - r / magHi);
+            if (L > 0.0 && L < magLo)
+            {
+                const double kInf = kHi / (1.0 - L / magHi);
+                // K_inf must land near what the guider's own calibration implies
+                const double calRatio = kInf * msPerArcsec;
+                if (calRatio > 0.5 && calRatio < 2.0)
+                {
+                    lossModeled = true;
+                    kOverride = kInf;
+                    lossMs = L;
+                }
+            }
+        }
+        if (!lossModeled)
+        {
+            emit protocolLog(QString("PID Auto-Tune [%1]: process gain disagrees across pulse magnitudes "
+                                     "(%2) -- responses are contaminated, keeping current gain.")
+                             .arg(axis, perMag.join(", ")));
+            return false;
+        }
+        emit protocolLog(QString("PID Auto-Tune [%1]: short pulses under-deliver (%2) -- modeled as a "
+                                 "fixed %3ms start-up loss per pulse, true gain %4\"/ms.")
+                         .arg(axis, perMag.join(", ")).arg(lossMs, 0, 'f', 0).arg(kOverride, 0, 'f', 5));
     }
 
     // The largest pulse has the best signal-to-contamination ratio, so prefer its estimate.
@@ -1544,7 +1588,7 @@ bool AIGuideProtocol::computeAndApplyAxisGain(const QString &axis, double msPerA
                          .arg(axis).arg(kByMag.value(bestMag).size()).arg(bestMag, 0, 'f', 0).arg(MIN_FITS_TO_APPLY));
         return false;
     }
-    const double K   = medianOf(kByMag.value(bestMag));
+    const double K   = lossModeled ? kOverride : medianOf(kByMag.value(bestMag));
     const double L   = medianOf(lByMag.value(bestMag));
     const double tau = std::max(medianOf(tFirstByMag.value(bestMag)), L);
 
@@ -1571,9 +1615,22 @@ bool AIGuideProtocol::computeAndApplyAxisGain(const QString &axis, double msPerA
         Options::setDECIntegralGain(integralGain);
     }
 
-    emit protocolLog(QString("PID Auto-Tune [%1]: K=%2\"/ms  L=%3s  tau=%4s  (n=%5 fits) "
-                             "-- gain %6 -> %7 (locked for the rest of this session)")
-                     .arg(axis).arg(K, 0, 'f', 5).arg(L, 0, 'f', 2).arg(tau, 0, 'f', 2)
+    QJsonObject live = m_SysIdData.value("pid_autotune_live").toObject();
+    QJsonObject entry;
+    entry["method"] = lossModeled ? "startup_loss_model" : "linear";
+    entry["process_gain_arcsec_per_ms"] = K;
+    if (lossModeled)
+        entry["startup_loss_ms"] = lossMs;
+    entry["per_magnitude"] = perMag.join("; ");
+    entry["applied_proportional_gain"] = proportionalGain;
+    entry["applied_integral_gain"] = integralGain;
+    live[axis.toLower()] = entry;
+    m_SysIdData["pid_autotune_live"] = live;
+
+    emit protocolLog(QString("PID Auto-Tune [%1]: K=%2\"/ms%3 (n=%4 fits; settle within one frame, "
+                             "conservative 0.25/K rule) -- gain %5 -> %6 (locked for the rest of this session)")
+                     .arg(axis).arg(K, 0, 'f', 5)
+                     .arg(lossModeled ? QString(" after removing %1ms/pulse start-up loss").arg(lossMs, 0, 'f', 0) : QString())
                      .arg(kByMag.value(bestMag).size()).arg(oldGain, 0, 'f', 3).arg(proportionalGain, 0, 'f', 3));
     return true;
 }
diff --git a/kstars/ekos/guide/aiguidewizard.cpp b/kstars/ekos/guide/aiguidewizard.cpp
index ad74ebc5c4..78b7cc475d 100644
--- a/kstars/ekos/guide/aiguidewizard.cpp
+++ b/kstars/ekos/guide/aiguidewizard.cpp
@@ -252,10 +252,16 @@ AIGuideWizard::AIGuideWizard(AIGuideProtocol *protocol, QWidget *parent) : QWiza
 
     setButtonText(QWizard::CustomButton1, "Export Logs");
     setOption(QWizard::HaveCustomButton1, true);
+    setButtonText(QWizard::CustomButton2, i18n("Start New Session"));
+    if (auto *b = button(QWizard::CustomButton2))
+        b->setToolTip(i18n("Return to the first page and run the protocol again. "
+                           "Existing weights stay active until new training completes."));
     connect(this, &QWizard::customButtonClicked, this, [this](int which)
     {
         if (which == QWizard::CustomButton1)
             slotExportLogs();
+        else if (which == QWizard::CustomButton2)
+            slotStartNewSession();
     });
 
     progressBar->setValue(0);
@@ -275,17 +281,18 @@ void AIGuideWizard::updateLockedSettingsLabel()
     if (Options::aIPIDAutoTune())
     {
         text = i18n("PID Auto-Tune is enabled, so this run will measure your mount's response "
-                     "and automatically determine and lock in the RA/DEC aggressiveness for "
-                     "you; there is no need to set it beforehand. The AI model is still "
-                     "trained and locked to your current guide exposure and pulse settings, "
-                     "so use the values you normally guide with for those.");
+                    "and automatically determine and lock in the RA/DEC aggressiveness for "
+                    "you; there is no need to set it beforehand. The AI model is still "
+                    "trained and locked to your current guide exposure and pulse settings, "
+                    "so use the values you normally guide with for those.");
     }
     else
     {
         text = i18n("The AI model is trained and locked to your current guide exposure, "
-                     "aggressiveness, and pulse settings; use the values you normally guide with.");
+                    "aggressiveness, and pulse settings; use the values you normally guide with.");
     }
-    lockedSettingsLabel->setText(QString("<html><body><p><span style=\" color:#ff5500;\">%1</span></p></body></html>").arg(text));
+    lockedSettingsLabel->setText(QString("<html><body><p><span style=\" color:#ff5500;\">%1</span></p></body></html>").arg(
+                                     text));
 }
 
 void AIGuideWizard::showEvent(QShowEvent *event)
@@ -461,6 +468,9 @@ bool AIGuideWizard::validateCurrentPage()
         if (running)
             return false;
     }
+    // Leaving page 1 forward is the user's explicit "go" for the protocol
+    if (currentId() == 1)
+        m_StartRequested = true;
     return QWizard::validateCurrentPage();
 }
 
@@ -468,30 +478,81 @@ void AIGuideWizard::initializePage(int id)
 {
     QWizard::initializePage(id);
 
+    // "Start New Session" is only useful once a run exists to restart from
+    setOption(QWizard::HaveCustomButton2, id >= 2);
+
     // Page 2 (0-indexed) is the "System Identification Progress" page
     if (id == 2 && !m_AutoNavigating)
     {
-        progressBar->setValue(0);
-        logTextEdit->clear();
-        exportOfflineButton->setEnabled(false);
-
-        stopButton->setText(i18n("Stop"));
-        stopButton->setEnabled(true);
-        disconnect(stopButton, &QPushButton::clicked, this, &AIGuideWizard::slotStartProtocol);
-        connect(stopButton, &QPushButton::clicked, this, &AIGuideWizard::slotStopProtocol, Qt::UniqueConnection);
+        const auto s = m_Protocol->state();
+        const bool running = s != AIGuideProtocol::STATE_IDLE
+                             && s != AIGuideProtocol::STATE_DONE
+                             && s != AIGuideProtocol::STATE_ERROR
+                             && s != AIGuideProtocol::STATE_TRAINING_DONE;
 
-        // No skipping ahead: the page auto-advances on protocolComplete.
-        // Deferred: QWizard re-enables its buttons right after initializePage().
-        QTimer::singleShot(0, this, [this]()
+        if (m_StartRequested && !running)
         {
-            if (auto *nextBtn = button(QWizard::NextButton))
-                nextBtn->setEnabled(false);
-        });
+            m_StartRequested = false;
+            progressBar->setValue(0);
+            logTextEdit->clear();
+            exportOfflineButton->setEnabled(false);
+
+            stopButton->setText(i18n("Stop"));
+            stopButton->setEnabled(true);
+            disconnect(stopButton, &QPushButton::clicked, this, &AIGuideWizard::slotStartProtocol);
+            connect(stopButton, &QPushButton::clicked, this, &AIGuideWizard::slotStopProtocol, Qt::UniqueConnection);
+
+            // No skipping ahead: the page auto-advances on protocolComplete.
+            // Deferred: QWizard re-enables its buttons right after initializePage().
+            QTimer::singleShot(0, this, [this]()
+            {
+                if (auto *nextBtn = button(QWizard::NextButton))
+                    nextBtn->setEnabled(false);
+            });
 
-        m_Protocol->start(mountTypeCombo->currentText());
+            m_Protocol->start(mountTypeCombo->currentText());
+        }
+        else
+        {
+            // Navigation-only entry: reflect the protocol's state, start nothing
+            m_StartRequested = false;
+            stopButton->setText(running ? i18n("Stop") : i18n("Start"));
+            stopButton->setEnabled(true);
+            disconnect(stopButton, &QPushButton::clicked, this, &AIGuideWizard::slotStartProtocol);
+            disconnect(stopButton, &QPushButton::clicked, this, &AIGuideWizard::slotStopProtocol);
+            connect(stopButton, &QPushButton::clicked, this,
+                    running ? &AIGuideWizard::slotStopProtocol : &AIGuideWizard::slotStartProtocol,
+                    Qt::UniqueConnection);
+            if (running)
+            {
+                QTimer::singleShot(0, this, [this]()
+                {
+                    if (auto *nextBtn = button(QWizard::NextButton))
+                        nextBtn->setEnabled(false);
+                });
+            }
+        }
     }
 }
 
+// One click back to a clean slate: stop a live protocol, return to the first page.
+// Existing weights stay active until a new training run completes and overwrites them.
+void AIGuideWizard::slotStartNewSession()
+{
+    const auto s = m_Protocol->state();
+    const bool running = s != AIGuideProtocol::STATE_IDLE
+                         && s != AIGuideProtocol::STATE_DONE
+                         && s != AIGuideProtocol::STATE_ERROR
+                         && s != AIGuideProtocol::STATE_TRAINING_DONE;
+    if (running)
+        m_Protocol->stop();
+
+    m_StartRequested = false;
+    m_AutoNavigating = true;
+    restart();
+    m_AutoNavigating = false;
+}
+
 void AIGuideWizard::appendLog(const QString &message)
 {
     QString timeStr = QDateTime::currentDateTime().toString("hh:mm:ss");
@@ -503,6 +564,7 @@ void AIGuideWizard::slotStartProtocol()
     // Navigate to the progress page (page 2) so the user sees logs/progress,
     // even when called programmatically via DBus/EkosLive.
     // initializePage(2) handles UI setup and protocol start.
+    m_StartRequested = true;
     restart();
     for (int i = 0; i < 2; ++i)
         next();
diff --git a/kstars/ekos/guide/aiguidewizard.h b/kstars/ekos/guide/aiguidewizard.h
index bdc76b9cca..97cc30f622 100644
--- a/kstars/ekos/guide/aiguidewizard.h
+++ b/kstars/ekos/guide/aiguidewizard.h
@@ -53,6 +53,7 @@ class AIGuideWizard : public QWizard, public Ui::AIGuideWizard
 
     public slots:
         Q_INVOKABLE void slotStartProtocol();
+        void slotStartNewSession();
         Q_INVOKABLE void slotStopProtocol();
 
         void onTrainingResult(bool success, const QJsonObject &result);
@@ -67,6 +68,9 @@ class AIGuideWizard : public QWizard, public Ui::AIGuideWizard
 
         AIGuideProtocol *m_Protocol { nullptr };
         bool m_AutoNavigating { false };
+        // Set on the explicit forward transition into the progress page; navigation
+        // (Back, auto-jump, reopen) must never start the protocol by itself.
+        bool m_StartRequested { false };
         // Combo text we last auto-set (empty if detection never succeeded yet). Re-checked
         // every time the wizard is shown, but only applied if the combo still shows either
         // the untouched Designer default or exactly this value, so a manual override
diff --git a/kstars/ekos/guide/offlinetrainer/pid_autotune.py b/kstars/ekos/guide/offlinetrainer/pid_autotune.py
index c9b72d558d..8cd31e496b 100644
--- a/kstars/ekos/guide/offlinetrainer/pid_autotune.py
+++ b/kstars/ekos/guide/offlinetrainer/pid_autotune.py
@@ -190,9 +190,32 @@ def _recommend_axis_pid_gain(sysid: dict, axis: str, guide_exp: float,
     if len(k_by_mag) < 2:
         return {"confidence": "unavailable",
                 "reason": f"only one usable pulse magnitude ({detail}) -- the cross-magnitude check cannot run"}
+    loss_modeled = False
+    loss_ms = 0.0
     if k_lo <= 0.0 or (k_hi - k_lo) / k_lo > K_MAGNITUDE_CONSISTENCY_TOLERANCE:
-        return {"confidence": "unavailable",
-                "reason": f"process gain disagrees across pulse magnitudes ({detail}) -- responses are contaminated"}
+        # Short pulses delivering LESS per ms is the signature of a fixed per-pulse loss
+        # (ramp/stiction on an at-rest axis): K(mag) = K_inf * (1 - L/mag). Solve the two
+        # magnitudes instead of refusing, inside strict sanity bounds. Mirrors
+        # AIGuideProtocol::computeAndApplyAxisGain().
+        mags = sorted(k_by_mag)
+        mag_lo, mag_hi = mags[0], mags[-1]
+        km_lo, km_hi = k_by_mag[mag_lo], k_by_mag[mag_hi]
+        hi = by_mag[mag_hi]
+        hi_med = float(np.median(hi)) * pixel_scale / mag_hi
+        hi_spread = ((max(hi) - min(hi)) * pixel_scale / mag_hi) / hi_med if hi_med > 0 else 1.0
+        solved = False
+        # range-based spread over n~6 samples runs wide; 0.25 keeps a real bar
+        if 0.0 < km_lo < km_hi and hi_spread <= 0.25:
+            r = km_lo / km_hi
+            L = (1.0 - r) / (1.0 / mag_lo - r / mag_hi)
+            if 0.0 < L < mag_lo:
+                k_inf = km_hi / (1.0 - L / mag_hi)
+                if 0.5 < k_inf * cal_ms_per_arcsec < 2.0:
+                    solved, loss_modeled, loss_ms = True, True, L
+                    k_by_mag[mag_hi] = k_inf   # rung-2 gain flows through the normal path
+        if not solved:
+            return {"confidence": "unavailable",
+                    "reason": f"process gain disagrees across pulse magnitudes ({detail}) -- responses are contaminated"}
 
     # The largest pulse has the best signal-to-contamination ratio.
     best_mag = max(by_mag)
@@ -214,8 +237,10 @@ def _recommend_axis_pid_gain(sysid: dict, axis: str, guide_exp: float,
     confidence = "low" if (resolution_limited or n_fits < MIN_FITS_FOR_MEDIUM_CONFIDENCE) else "medium"
 
     if verbose:
-        print(f"  [{axis} PID] K={K:.5f} arcsec/ms  tau={tau:.2f}s  L={L:.2f}s  lambda={lam:.2f}s "
-              f"(n={n_fits} fits @ {best_mag:.0f}ms, cal={cal_ms_per_arcsec:.1f}ms/arcsec)")
+        loss_note = f"  startup_loss={loss_ms:.0f}ms" if loss_modeled else ""
+        print(f"  [{axis} PID] K={K:.5f} arcsec/ms{loss_note} "
+              f"(n={n_fits} fits @ {best_mag:.0f}ms, cal={cal_ms_per_arcsec:.1f}ms/arcsec; "
+              f"settle within one frame, conservative 0.25/K rule)")
         print(f"  [{axis} PID] Recommended proportional_gain={proportional_gain:.3f}  "
               f"integral_gain={integral_gain:.3f}  confidence={confidence}")
 
@@ -231,6 +256,8 @@ def _recommend_axis_pid_gain(sysid: dict, axis: str, guide_exp: float,
         "calibration_ms_per_arcsec":  cal_ms_per_arcsec,
         "n_fits":                     n_fits,
         "resolution_limited":         resolution_limited,
+        "startup_loss_ms":            float(loss_ms) if loss_modeled else None,
+        "method":                     "startup_loss_model" if loss_modeled else "linear",
     }
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.