[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",
}