[education/kstars] kstars: Separate AI Guide protocol from wizard
Jasem Mutlaq <[email protected]>
| Newsgroups | gmane.comp.kde.cvs |
|---|---|
| Message-ID | <[email protected]> |
Git commit 92f0630dd5d429bef4fe9a4022a89c094d3965cc by Jasem Mutlaq.
Committed on 17/07/2026 at 14:22.
Pushed by mutlaqja into branch 'master'.
Separate AI Guide protocol from wizard
M +3 -2 kstars/CMakeLists.txt
M +6 -0 kstars/ekos/ekoslive/commands.h
M +34 -75 kstars/ekos/ekoslive/message.cpp
M +5 -3 kstars/ekos/ekoslive/message.h
A +830 -0 kstars/ekos/guide/aiguideprotocol.cpp [License: GPL(v2.0+)]
A +156 -0 kstars/ekos/guide/aiguideprotocol.h [License: GPL(v2.0+)]
A +402 -0 kstars/ekos/guide/aiguidewizard.cpp [License: GPL(v2.0+)]
A +69 -0 kstars/ekos/guide/aiguidewizard.h [License: GPL(v2.0+)]
R +2 -2 kstars/ekos/guide/aiguidewizard.ui [from: kstars/ekos/guide/opsaiguide.ui - 099% similarity]
M +35 -13 kstars/ekos/guide/guide.cpp
M +18 -6 kstars/ekos/guide/guide.h
M +0 -1 kstars/ekos/guide/opsaiconfig.cpp
D +0 -1221 kstars/ekos/guide/opsaiguide.cpp
D +0 -159 kstars/ekos/guide/opsaiguide.h
M +16 -0 kstars/ekos/manager.cpp
https://invent.kde.org/education/kstars/-/commit/92f0630dd5d429bef4fe9a4022a89c094d3965cc
diff --git a/kstars/CMakeLists.txt b/kstars/CMakeLists.txt
index 7a260fc48a..c9861e976d 100644
--- a/kstars/CMakeLists.txt
+++ b/kstars/CMakeLists.txt
@@ -190,7 +190,7 @@ if (INDI_FOUND)
ekos/guide/guidestatewidget.ui
ekos/guide/opscalibration.ui
ekos/guide/opsguide.ui
- ekos/guide/opsaiguide.ui
+ ekos/guide/aiguidewizard.ui
ekos/guide/opsaiconfig.ui
ekos/guide/opsdither.ui
ekos/guide/opsgpg.ui
@@ -395,7 +395,8 @@ if (INDI_FOUND)
ekos/guide/opsdither.cpp
ekos/guide/opsgpg.cpp
ekos/guide/opsaiconfig.cpp
- ekos/guide/opsaiguide.cpp
+ ekos/guide/aiguidewizard.cpp
+ ekos/guide/aiguideprotocol.cpp
ekos/guide/guidedriftgraph.cpp
ekos/guide/guidetargetplot.cpp
ekos/guide/manualpulse.cpp
diff --git a/kstars/ekos/ekoslive/commands.h b/kstars/ekos/ekoslive/commands.h
index e1c826ec9e..7f5b741a8a 100644
--- a/kstars/ekos/ekoslive/commands.h
+++ b/kstars/ekos/ekoslive/commands.h
@@ -284,6 +284,9 @@ enum COMMANDS
NEW_AI_GUIDE_PROGRESS,
NEW_AI_GUIDE_LOG,
NEW_AI_GUIDE_COMPLETE,
+ NEW_AI_GUIDE_TRAINING_PROGRESS,
+ NEW_AI_GUIDE_TRAINING_COMPLETE,
+ NEW_AI_GUIDE_TRAINING_ERROR,
};
static QMap<COMMANDS, QString> const commands =
@@ -529,6 +532,9 @@ static QMap<COMMANDS, QString> const commands =
{NEW_AI_GUIDE_PROGRESS, "new_ai_guide_progress"},
{NEW_AI_GUIDE_LOG, "new_ai_guide_log"},
{NEW_AI_GUIDE_COMPLETE, "new_ai_guide_complete"},
+ {NEW_AI_GUIDE_TRAINING_PROGRESS, "new_ai_guide_training_progress"},
+ {NEW_AI_GUIDE_TRAINING_COMPLETE, "new_ai_guide_training_complete"},
+ {NEW_AI_GUIDE_TRAINING_ERROR, "new_ai_guide_training_error"},
};
}
diff --git a/kstars/ekos/ekoslive/message.cpp b/kstars/ekos/ekoslive/message.cpp
index 9e93cf5e14..1d76944617 100644
--- a/kstars/ekos/ekoslive/message.cpp
+++ b/kstars/ekos/ekoslive/message.cpp
@@ -21,7 +21,7 @@
#include "ekos/focus/focusmodule.h"
#include "ekos/auxiliary/buildfilteroffsets.h"
#include "ekos/guide/guide.h"
-#include "ekos/guide/opsaiguide.h"
+#include "ekos/guide/aiguidewizard.h"
#include "ekos/guide/internalguide/internalguider.h"
#include "ekos/guide/internalguide/gmath.h"
#include "ekos/mount/mount.h"
@@ -305,9 +305,6 @@ void Message::onTextReceived(const QString &message)
processLiveStackerCommands(command, payload);
else if (command.startsWith("filter_offset_"))
processFilterOffsetCommands(command, payload);
- else if (command.startsWith("ai_guide_"))
- processAIGuideCommands(command, payload);
-
}
///////////////////////////////////////////////////////////////////////////////////////////
@@ -3536,108 +3533,70 @@ void Message::sendFilterOffsetCalculated(const QString &filter, int newOffset, i
///////////////////////////////////////////////////////////////////////////////////////////
///
///////////////////////////////////////////////////////////////////////////////////////////
-void Message::processAIGuideCommands(const QString &command, const QJsonObject &payload)
+///////////////////////////////////////////////////////////////////////////////////////////
+///
+///////////////////////////////////////////////////////////////////////////////////////////
+void Message::sendAIGuideProgress(int current, int total, const QString &status)
{
- if (!m_Manager)
- return;
-
- Ekos::Guide *guide = m_Manager->guideModule();
- if (!guide)
- {
- qCWarning(KSTARS_EKOS) << "Ignoring AI guide command" << command << "as guide module is not available";
- return;
- }
-
- // Access the OpsAIGuide wizard
- auto *aiGuide = guide->getAIGuide();
-
- // Stream the wizard's progress/log/completion to the client (idempotent).
- connect(aiGuide, &Ekos::OpsAIGuide::aiGuideProgress, this, &Message::sendAIGuideProgress, Qt::UniqueConnection);
- connect(aiGuide, &Ekos::OpsAIGuide::aiGuideLog, this, &Message::sendAIGuideLog, Qt::UniqueConnection);
- connect(aiGuide, &Ekos::OpsAIGuide::aiGuideComplete, this, &Message::sendAIGuideComplete, Qt::UniqueConnection);
-
- if (command == commands[AI_GUIDE_GET_ALL_SETTINGS])
+ QJsonObject payload =
{
- // Gather AI-related settings from the Guide module and OpsAIGuide
- QVariantMap settings;
- settings["mount_type"] = aiGuide->property("mountType").toString();
- settings["state"] = static_cast<int>(aiGuide->property("state").value<Ekos::OpsAIGuide::ProtocolState>());
- settings["ai_free_drift"] = guide->isAIFreeDrift();
- settings["total_phases"] = aiGuide->property("totalPhases").toInt();
- settings["phases_remaining"] = aiGuide->property("phasesRemaining").toInt();
-
- // AI Guider state from the internal guider
- auto *guiderInstance = guide->getGuiderInstance();
- if (guiderInstance)
- {
- auto *internalGuider = qobject_cast<Ekos::InternalGuider *>(guiderInstance);
- if (internalGuider)
- {
- auto *aiGuider = internalGuider->getAIGuider();
- if (aiGuider)
- {
- settings["ai_confidence"] = aiGuider->confidence();
- settings["ai_state"] = aiGuider->stateString();
- settings["ai_loaded"] = aiGuider->isLoaded();
- settings["ai_warmup_frames"] = aiGuider->warmupFrames();
- }
- }
- }
+ {"state_string", status},
+ {"phases_completed", current},
+ {"total_phases", total}
+ };
+ sendResponse(commands[NEW_AI_GUIDE_PROGRESS], payload);
+}
- sendAIGuideSettings(settings);
- }
- else if (command == commands[AI_GUIDE_SET_ALL_SETTINGS])
+///////////////////////////////////////////////////////////////////////////////////////////
+///
+///////////////////////////////////////////////////////////////////////////////////////////
+void Message::sendAIGuideLog(const QString &message)
+{
+ QJsonObject payload =
{
- auto settings = payload.toVariantMap();
- if (settings.contains("mount_type"))
- {
- auto mountTypeCombo = aiGuide->findChild<QComboBox *>("mountTypeCombo");
- if (mountTypeCombo)
- mountTypeCombo->setCurrentText(settings["mount_type"].toString());
- }
- }
+ {"message", message}
+ };
+ sendResponse(commands[NEW_AI_GUIDE_LOG], payload);
}
///////////////////////////////////////////////////////////////////////////////////////////
///
///////////////////////////////////////////////////////////////////////////////////////////
-void Message::sendAIGuideSettings(const QVariantMap &settings)
+void Message::sendAIGuideComplete()
{
- sendResponse(commands[AI_GUIDE_GET_ALL_SETTINGS], QJsonObject::fromVariantMap(settings));
+ sendResponse(commands[NEW_AI_GUIDE_COMPLETE], QJsonObject());
}
///////////////////////////////////////////////////////////////////////////////////////////
///
///////////////////////////////////////////////////////////////////////////////////////////
-void Message::sendAIGuideProgress(int current, int total, const QString &status)
+void Message::sendAIGuideTrainingProgress(const QString &message)
{
QJsonObject payload =
{
- {"current", current},
- {"total", total},
- {"status", status}
+ {"message", message}
};
- sendResponse(commands[NEW_AI_GUIDE_PROGRESS], payload);
+ sendResponse(commands[NEW_AI_GUIDE_TRAINING_PROGRESS], payload);
}
///////////////////////////////////////////////////////////////////////////////////////////
///
///////////////////////////////////////////////////////////////////////////////////////////
-void Message::sendAIGuideLog(const QString &message)
+void Message::sendAIGuideTrainingComplete()
{
- QJsonObject payload =
- {
- {"message", message}
- };
- sendResponse(commands[NEW_AI_GUIDE_LOG], payload);
+ sendResponse(commands[NEW_AI_GUIDE_TRAINING_COMPLETE], QJsonObject());
}
///////////////////////////////////////////////////////////////////////////////////////////
///
///////////////////////////////////////////////////////////////////////////////////////////
-void Message::sendAIGuideComplete()
+void Message::sendAIGuideTrainingError(const QString &error)
{
- sendResponse(commands[NEW_AI_GUIDE_COMPLETE], QJsonObject());
+ QJsonObject payload =
+ {
+ {"message", error}
+ };
+ sendResponse(commands[NEW_AI_GUIDE_TRAINING_ERROR], payload);
}
///////////////////////////////////////////////////////////////////////////////////////////
diff --git a/kstars/ekos/ekoslive/message.h b/kstars/ekos/ekoslive/message.h
index 085a799e59..a1ebaa4cfa 100644
--- a/kstars/ekos/ekoslive/message.h
+++ b/kstars/ekos/ekoslive/message.h
@@ -161,6 +161,11 @@ class Message : public QObject
void sendAIGuideLog(const QString &message);
void sendAIGuideComplete();
+ // AI Guider Training
+ void sendAIGuideTrainingProgress(const QString &message);
+ void sendAIGuideTrainingComplete();
+ void sendAIGuideTrainingError(const QString &error);
+
private Q_SLOTS:
// Connection
@@ -242,9 +247,6 @@ class Message : public QObject
void processFilterOffsetCommands(const QString &command, const QJsonObject &payload);
void sendFilterOffsetSettings(const QVariantMap &settings);
- // AI Guider commands
- void processAIGuideCommands(const QString &command, const QJsonObject &payload);
- void sendAIGuideSettings(const QVariantMap &settings);
void sendLiveStackerProgress(bool ok, int sub, int total, double meanSNR, double minSNR, double maxSNR);
void sendLiveStackerComplete();
// Slot called for each captured frame when livestacking in looping mode.
diff --git a/kstars/ekos/guide/aiguideprotocol.cpp b/kstars/ekos/guide/aiguideprotocol.cpp
new file mode 100644
index 0000000000..4b639e582f
--- /dev/null
+++ b/kstars/ekos/guide/aiguideprotocol.cpp
@@ -0,0 +1,830 @@
+/*
+ SPDX-FileCopyrightText: 2026 Pavan <[email protected]>
+
+ SPDX-License-Identifier: GPL-2.0-or-later
+*/
+
+#include "aiguideprotocol.h"
+#include "guide.h"
+#include "kstarsdata.h"
+#include "kspaths.h"
+#include "skycomponents/skymapcomposite.h"
+#include "skycomponents/artificialhorizoncomponent.h"
+#include "indi/indimount.h"
+#include "Options.h"
+#include "internalguide/internalguider.h"
+#include "internalguide/calibration.h"
+#include <QDateTime>
+#include <QDebug>
+#include <QDir>
+#include <algorithm>
+
+namespace Ekos
+{
+
+AIGuideProtocol::AIGuideProtocol(Guide *guide) : QObject(guide), m_Guide(guide)
+{
+ connect(&m_ProtocolTimer, &QTimer::timeout, this, &AIGuideProtocol::processProtocol);
+ setObjectName("AIGuideProtocol");
+}
+
+void AIGuideProtocol::enforceSettings()
+{
+ if (!m_SettingsEnforced)
+ {
+ m_OrigRAAlgorithm = Options::rAGuidePulseAlgorithm();
+ m_OrigDECAlgorithm = Options::dECGuidePulseAlgorithm();
+ m_OrigRAEnabled = Options::rAGuideEnabled();
+ m_OrigDECEnabled = Options::dECGuideEnabled();
+ m_OrigEastEnabled = Options::eastRAGuideEnabled();
+ m_OrigWestEnabled = Options::westRAGuideEnabled();
+ m_OrigNorthEnabled = Options::northDECGuideEnabled();
+ m_OrigSouthEnabled = Options::southDECGuideEnabled();
+ m_OrigMaxDeltaRMS = Options::guideMaxDeltaRMS();
+ m_SettingsEnforced = true;
+ }
+
+ Options::setRAGuidePulseAlgorithm(0);
+ Options::setDECGuidePulseAlgorithm(0);
+ Options::setRAGuideEnabled(true);
+ Options::setDECGuideEnabled(true);
+ Options::setEastRAGuideEnabled(true);
+ Options::setWestRAGuideEnabled(true);
+ Options::setNorthDECGuideEnabled(true);
+ Options::setSouthDECGuideEnabled(true);
+ Options::setGuideMaxDeltaRMS(100.0);
+}
+
+void AIGuideProtocol::restoreSettings()
+{
+ if (!m_SettingsEnforced)
+ return;
+ m_SettingsEnforced = false;
+
+ Options::setRAGuidePulseAlgorithm(m_OrigRAAlgorithm);
+ Options::setDECGuidePulseAlgorithm(m_OrigDECAlgorithm);
+ Options::setRAGuideEnabled(m_OrigRAEnabled);
+ Options::setDECGuideEnabled(m_OrigDECEnabled);
+ Options::setEastRAGuideEnabled(m_OrigEastEnabled);
+ Options::setWestRAGuideEnabled(m_OrigWestEnabled);
+ Options::setNorthDECGuideEnabled(m_OrigNorthEnabled);
+ Options::setSouthDECGuideEnabled(m_OrigSouthEnabled);
+ Options::setGuideMaxDeltaRMS(m_OrigMaxDeltaRMS);
+}
+
+void AIGuideProtocol::start(const QString &mountType)
+{
+ m_State = STATE_IDLE;
+ m_Phases.clear();
+
+ emit protocolLog("Starting AI Guiding Assistant Protocol...");
+
+ QString mountStr = mountType;
+ QString mountTypeEnum;
+ if (mountStr == "Worm Gear") mountTypeEnum = "WORM_GEAR";
+ else if (mountStr == "Harmonic Drive") mountTypeEnum = "HARMONIC_DRIVE";
+ else mountTypeEnum = "DIRECT_DRIVE";
+
+ // Initialize the root JSON object for SysId Data
+ m_SysIdData = QJsonObject();
+ m_SysIdData["format_version"] = "1.0";
+ QJsonObject equipment;
+ equipment["mount_type"] = mountTypeEnum;
+ equipment["mount_name"] = "Unknown";
+ equipment["camera"] = "Unknown";
+ equipment["focal_length_mm"] = 0;
+ equipment["pixel_size_um"] = 0.0;
+ equipment["pixel_scale_arcsec_per_px"] = 0.0;
+ equipment["guide_exposure_ms"] = 0;
+ equipment["guide_optics_type"] = "Unknown";
+
+ if (m_Guide)
+ {
+ equipment["camera"] = m_Guide->camera();
+ equipment["focal_length_mm"] = m_Guide->focalLength();
+ equipment["pixel_size_um"] = m_Guide->pixelSizeX();
+ equipment["guide_exposure_ms"] = static_cast<int>(m_Guide->exposure() * 1000.0);
+ if (m_Guide->mount())
+ equipment["mount_name"] = m_Guide->mount()->getDeviceName();
+ if (m_Guide->focalLength() > 0)
+ {
+ equipment["pixel_scale_arcsec_per_px"] = (206.265 * m_Guide->pixelSizeX()) / m_Guide->focalLength();
+ if (std::abs(m_Guide->focalLength() - Options::telescopeFocalLength()) < 1.0)
+ equipment["guide_optics_type"] = "OAG";
+ else
+ equipment["guide_optics_type"] = "Guidescope";
+ }
+ }
+ 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<int>(Options::rAMaximumPulseArcSec());
+ fingerprint["dec_max_pulse_arcsec"] = static_cast<int>(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_SysIdData["sessions"] = QJsonArray();
+
+ enforceSettings();
+
+ QString dirPath = QDir(KSPaths::writableLocation(QStandardPaths::AppLocalDataLocation)).filePath("ai_training_logs");
+ QDir().mkpath(dirPath);
+ m_LogFilename = dirPath + "/" + QString("sysid_data_%1.json").arg(QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss"));
+
+ if (mountStr == "Worm Gear")
+ {
+ m_Phases.append({65.0, -45.0, 480, false, false, "", "", 0, 0, 0});
+ m_Phases.append({65.0, -45.0, 600, true, false, "", "", 0, 0, 0});
+ m_Phases.append({40.0, -45.0, 480, false, false, "", "", 0, 0, 0});
+ m_Phases.append({40.0, -45.0, 400, true, false, "", "", 0, 0, 0});
+ m_Phases.append({65.0, 45.0, 480, false, false, "", "", 0, 0, 0});
+ m_Phases.append({65.0, 45.0, 400, true, false, "", "", 0, 0, 0});
+ }
+ else if (mountStr == "Harmonic Drive")
+ {
+ m_Phases.append({65.0, -45.0, 480, false, false, {}, {}, 0, 15, 30});
+ m_Phases.append({65.0, -45.0, 120, true, false, {}, {}, 0, 15, 30});
+ m_Phases.append({65.0, -45.0, 0, false, true, "RA", "EAST", 50, 15, 30});
+ m_Phases.append({65.0, -45.0, 0, false, true, "RA", "EAST", 100, 15, 30});
+ m_Phases.append({65.0, -45.0, 0, false, true, "RA", "EAST", 200, 15, 30});
+ m_Phases.append({65.0, -45.0, 0, false, true, "RA", "WEST", 50, 15, 30});
+ m_Phases.append({65.0, -45.0, 0, false, true, "RA", "WEST", 100, 15, 30});
+ m_Phases.append({65.0, -45.0, 0, false, true, "RA", "WEST", 200, 15, 30});
+ m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "NORTH", 50, 15, 30});
+ m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "NORTH", 100, 15, 30});
+ m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "NORTH", 200, 15, 30});
+ m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "SOUTH", 50, 15, 30});
+ m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "SOUTH", 100, 15, 30});
+ m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "SOUTH", 200, 15, 30});
+ m_Phases.append({45.0, -45.0, 120, true, false, {}, {}, 0, 15, 30});
+ m_Phases.append({45.0, -45.0, 300, false, false, {}, {}, 0, 15, 30});
+ }
+ else
+ {
+ m_Phases.append({70.0, 0.0, 120, false, false, "", "", 0, 0, 0});
+ m_Phases.append({70.0, 0.0, 180, true, false, "", "", 0, 0, 0});
+ m_Phases.append({50.0, -60.0, 120, false, false, "", "", 0, 0, 0});
+ m_Phases.append({50.0, -60.0, 180, true, false, "", "", 0, 0, 0});
+ m_Phases.append({35.0, 60.0, 120, false, false, "", "", 0, 0, 0});
+ m_Phases.append({35.0, 60.0, 180, true, false, "", "", 0, 0, 0});
+ }
+
+ emit protocolLog(QString("Loaded %1 phases for %2").arg(m_Phases.size()).arg(mountStr));
+
+ m_TotalPhases = m_Phases.size();
+ m_State = STATE_PRECHECK;
+ m_ProtocolTimer.start(1000);
+}
+
+void AIGuideProtocol::requestTraining()
+{
+ if (m_SysIdData["sessions"].toArray().isEmpty())
+ {
+ emit protocolLog(i18n("No data to train! Please run the protocol first."));
+ emit trainingError(i18n("No data to train"));
+ return;
+ }
+
+ m_State = STATE_TRAINING;
+ emit trainingProgress(i18n("Starting AI training via EkosLive cloud..."));
+ emit trainingRequested(m_SysIdData);
+}
+
+void AIGuideProtocol::loadAndComplete(const QString &jsonFilePath)
+{
+ QFile file(jsonFilePath);
+ if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
+ {
+ emit protocolLog(QString("ERROR: Failed to open training data: %1").arg(jsonFilePath));
+ return;
+ }
+
+ QJsonParseError parseError;
+ QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &parseError);
+ file.close();
+
+ if (parseError.error != QJsonParseError::NoError)
+ {
+ emit protocolLog(QString("ERROR: JSON parse error: %1").arg(parseError.errorString()));
+ return;
+ }
+
+ m_SysIdData = doc.object();
+ m_State = STATE_DONE;
+ emit protocolLog(QString("Loaded pre-recorded sysid data from %1 (%2 sessions). Ready for training.")
+ .arg(jsonFilePath).arg(m_SysIdData["sessions"].toArray().size()));
+ emit protocolComplete();
+}
+
+void AIGuideProtocol::onTrainingResult(bool success, const QString &message)
+{
+ if (success)
+ {
+ m_State = STATE_TRAINING_DONE;
+ emit trainingProgress(message);
+ emit trainingComplete();
+ }
+ else
+ {
+ emit trainingError(message);
+ }
+}
+
+void AIGuideProtocol::stop()
+{
+ m_ProtocolTimer.stop();
+ if (m_Guide)
+ {
+ m_Guide->setAIFreeDrift(false);
+ m_Guide->abort();
+ disconnect(m_Guide, &Guide::guideStats, this, &AIGuideProtocol::onGuideStats);
+ }
+ restoreSettings();
+ m_State = STATE_DONE;
+ emit protocolLog("Protocol Aborted by User");
+ emit protocolStopped();
+}
+
+void AIGuideProtocol::processProtocol()
+{
+ switch (m_State)
+ {
+ case STATE_IDLE:
+ case STATE_DONE:
+ m_ProtocolTimer.stop();
+ break;
+
+ case STATE_ERROR:
+ m_ProtocolTimer.stop();
+ emit protocolStopped();
+ break;
+
+ case STATE_PRECHECK:
+ {
+ if (!m_Guide || !m_Guide->mount() || !m_Guide->mount()->isConnected())
+ {
+ emit protocolLog("ERROR: Mount is not connected!");
+ m_State = STATE_ERROR;
+ break;
+ }
+ if (m_Guide->mount()->status() == ISD::Mount::MOUNT_PARKED)
+ {
+ emit protocolLog("Mount is parked. Unparking before scan...");
+ m_Guide->mount()->unpark();
+ break;
+ }
+ if (m_Guide->mount()->status() != ISD::Mount::MOUNT_TRACKING && m_Guide->mount()->status() != ISD::Mount::MOUNT_IDLE)
+ break;
+
+ if (m_Phases.isEmpty())
+ {
+ emit protocolLog("All phases complete!");
+ restoreSettings();
+ m_State = STATE_DONE;
+ emit protocolComplete();
+ break;
+ }
+
+ if (m_Phases.first().pulseResponse)
+ {
+ m_State = STATE_PULSE_RESPONSE_INIT;
+ break;
+ }
+
+ m_State = STATE_HORIZON_SCAN;
+ break;
+ }
+
+ case STATE_HORIZON_SCAN:
+ {
+ double targetAlt = m_Phases.first().targetAlt;
+ emit protocolLog(QString("Phase started: Scanning Artificial Horizon for safe slew to Alt %1°...").arg(targetAlt));
+
+ double meridianAz = KStarsData::Instance()->geo()->lat()->Degrees() > 0 ? 180.0 : 0.0;
+ double targetAzOffset = m_Phases.first().azOffset;
+ double targetAz = fmod(meridianAz + targetAzOffset + 360.0, 360.0);
+
+ auto* horizon = &KStarsData::Instance()->skyComposite()->artificialHorizon()->getHorizon();
+ double clearAz = 0;
+ double clearAlt = targetAlt;
+ bool foundClear = false;
+
+ for (double altOffset = 0; altOffset <= 10.0; altOffset += 5.0)
+ {
+ for (double azOffset = 0; azOffset <= 30.0; azOffset += 5.0)
+ {
+ QString reason;
+ if (horizon->isAltitudeOK(targetAz + azOffset, targetAlt + altOffset, &reason))
+ {
+ clearAz = targetAz + azOffset;
+ clearAlt = targetAlt + altOffset;
+ foundClear = true;
+ break;
+ }
+ if (horizon->isAltitudeOK(targetAz - azOffset, targetAlt + altOffset, &reason))
+ {
+ clearAz = targetAz - azOffset;
+ clearAlt = targetAlt + altOffset;
+ foundClear = true;
+ break;
+ }
+ }
+ if (foundClear) break;
+ }
+
+ if (!foundClear)
+ {
+ emit protocolLog("ERROR: Entire meridian is blocked by Artificial Horizon! Cannot proceed.");
+ m_State = STATE_ERROR;
+ }
+ else
+ {
+ if (std::abs(m_TargetAz - clearAz) < 0.1 && std::abs(m_TargetAlt - clearAlt) < 0.1)
+ {
+ emit protocolLog("Continuing data collection at current position...");
+ m_State = STATE_SETTLING;
+ m_SettlingTimer = 2;
+ }
+ else
+ {
+ m_TargetAz = clearAz;
+ m_TargetAlt = clearAlt;
+ emit protocolLog(QString("Found safe sky patch: Az %1°, Alt %2°").arg(m_TargetAz).arg(m_TargetAlt));
+
+ if (!m_Guide || !m_Guide->mount())
+ {
+ emit protocolLog("ERROR: Mount unavailable, cannot slew.");
+ m_State = STATE_ERROR;
+ break;
+ }
+
+ SkyPoint targetPoint;
+ targetPoint.setAz(m_TargetAz);
+ targetPoint.setAlt(m_TargetAlt);
+ targetPoint.HorizontalToEquatorialNow();
+
+ emit protocolLog("Issuing Slew command to mount...");
+ m_Guide->mount()->Slew(&targetPoint);
+ m_State = STATE_SLEWING;
+ }
+ }
+ break;
+ }
+
+ case STATE_SLEWING:
+ {
+ if (!m_Guide || !m_Guide->mount())
+ {
+ emit protocolLog("ERROR: Mount became unavailable during slew!");
+ m_State = STATE_ERROR;
+ break;
+ }
+ if (m_Guide->mount()->status() == ISD::Mount::MOUNT_TRACKING)
+ {
+ emit protocolLog("Slew complete! Mount is tracking.");
+ m_State = STATE_SETTLING;
+ m_SettlingTimer = 10;
+ }
+ else if (m_Guide->mount()->status() == ISD::Mount::MOUNT_ERROR)
+ {
+ emit protocolLog("ERROR: Mount failed to slew!");
+ m_State = STATE_ERROR;
+ }
+ break;
+ }
+
+ case STATE_SETTLING:
+ if (m_SettlingTimer > 0)
+ {
+ emit protocolProgress(m_TotalPhases - m_Phases.size(), m_TotalPhases,
+ QString("Settling... %1s").arg(m_SettlingTimer));
+ m_SettlingTimer--;
+ }
+ else
+ {
+ emit protocolLog("Settling complete. Starting phase data collection...");
+ ProtocolPhase phase = m_Phases.first();
+ m_CaptureTimer = phase.durationSeconds;
+ m_AbortRetries = 0;
+ m_FreeDriftOverflow = false;
+
+ m_Guide->setAIFreeDrift(phase.freeDrift);
+ m_PhaseData = QJsonArray();
+
+ connect(m_Guide, &Guide::guideStats, this, &AIGuideProtocol::onGuideStats, Qt::UniqueConnection);
+ m_FrameTimer.start();
+
+ if (m_Guide->status() == GUIDE_IDLE || m_Guide->status() == GUIDE_ABORTED)
+ m_Guide->guide();
+
+ m_State = STATE_CAPTURING_DATA;
+ }
+ break;
+
+ case STATE_CAPTURING_DATA:
+ {
+ // Recovery from unexpected abort
+ if (!m_Guide || m_Guide->status() == GUIDE_ABORTED)
+ {
+ constexpr int MAX_RETRIES = 3;
+ if (m_AbortRetries < MAX_RETRIES)
+ {
+ m_AbortRetries++;
+ emit protocolLog(QString("Guider aborted (retry %1/%2). Attempting recovery...")
+ .arg(m_AbortRetries).arg(MAX_RETRIES));
+ if (m_Guide)
+ {
+ m_Guide->setAIFreeDrift(m_Phases.first().freeDrift);
+ m_Guide->guide();
+ m_FrameTimer.start();
+ }
+ break;
+ }
+ emit protocolLog(QString("Phase ended early (guide star lost after %1 retries). Saving %2 frames.")
+ .arg(MAX_RETRIES).arg(m_PhaseData.size()));
+ if (m_Guide)
+ {
+ m_Guide->setAIFreeDrift(false);
+ disconnect(m_Guide, &Guide::guideStats, this, &AIGuideProtocol::onGuideStats);
+ }
+ m_CaptureTimer = 0;
+ }
+
+ if (m_Guide && m_Guide->status() != GUIDE_GUIDING && m_Guide->status() != GUIDE_ABORTED)
+ {
+ emit protocolProgress(m_TotalPhases - m_Phases.size(), m_TotalPhases,
+ "Waiting for Guider to calibrate/start...");
+ m_FrameTimer.start();
+ break;
+ }
+
+ const bool phaseTimedOut = (m_CaptureTimer <= 0);
+ const bool freeDriftOverflowed = m_FreeDriftOverflow;
+
+ if (phaseTimedOut || freeDriftOverflowed)
+ {
+ if (freeDriftOverflowed)
+ emit protocolLog(QString("Free drift ended early to protect guide star (%1 frames saved).")
+ .arg(m_PhaseData.size()));
+ else
+ emit protocolLog("Phase complete. Stopping capture...");
+
+ if (m_Guide)
+ {
+ m_Guide->abort();
+ m_Guide->setAIFreeDrift(false);
+ disconnect(m_Guide, &Guide::guideStats, this, &AIGuideProtocol::onGuideStats);
+ }
+
+ ProtocolPhase phase = m_Phases.first();
+ QJsonObject phaseRecord;
+ phaseRecord["session_id"] = QString("phase_alt%1_%2").arg(phase.targetAlt).arg(
+ QDateTime::currentDateTime().toString("HHmmss"));
+ phaseRecord["type"] = phase.freeDrift ? "free_drift" : "standard_guiding";
+
+ double meanAlt = m_TargetAlt;
+ if (!m_PhaseData.isEmpty())
+ {
+ double sumAlt = 0.0;
+ for (int i = 0; i < m_PhaseData.size(); ++i)
+ sumAlt += m_PhaseData.at(i).toObject()["altitude_deg"].toDouble();
+ meanAlt = sumAlt / m_PhaseData.size();
+ }
+ phaseRecord["altitude_deg"] = meanAlt;
+ phaseRecord["azimuth_deg"] = m_TargetAz;
+ if (m_Guide && m_Guide->mount())
+ {
+ auto pierSide = m_Guide->mount()->pierSide();
+ phaseRecord["pier_side"] = (pierSide == ISD::Mount::PIER_EAST) ? "EAST" : "WEST";
+ }
+ phaseRecord["duration_s"] = phase.durationSeconds;
+ phaseRecord["aggressiveness_ra"] = phase.freeDrift ? 0.0 : Options::rAProportionalGain();
+ phaseRecord["aggressiveness_dec"] = phase.freeDrift ? 0.0 : Options::dECProportionalGain();
+ phaseRecord["min_pulse_ra_arcsec"] = phase.freeDrift ? 0.0 : Options::rAMinimumPulseArcSec();
+ phaseRecord["min_pulse_dec_arcsec"] = phase.freeDrift ? 0.0 : Options::dECMinimumPulseArcSec();
+ phaseRecord["max_pulse_ra_arcsec"] = phase.freeDrift ? 0.0 : Options::rAMaximumPulseArcSec();
+ phaseRecord["max_pulse_dec_arcsec"] = phase.freeDrift ? 0.0 : Options::dECMaximumPulseArcSec();
+
+ if (m_Guide)
+ {
+ auto *internalGuider = qobject_cast<Ekos::InternalGuider*>(m_Guide->getGuiderInstance());
+ if (internalGuider)
+ {
+ const auto &cal = internalGuider->getCalibration();
+ phaseRecord["ra_ms_per_arcsec"] = cal.raPulseMillisecondsPerArcsecond();
+ phaseRecord["dec_ms_per_arcsec"] = cal.decPulseMillisecondsPerArcsecond();
+ }
+ }
+
+ phaseRecord["frames"] = m_PhaseData;
+
+ QJsonArray sessions = m_SysIdData["sessions"].toArray();
+ sessions.append(phaseRecord);
+ m_SysIdData["sessions"] = sessions;
+
+ m_LogFile.setFileName(m_LogFilename);
+ if (m_LogFile.open(QIODevice::WriteOnly | QIODevice::Text))
+ {
+ QJsonDocument doc(m_SysIdData);
+ m_LogFile.write(doc.toJson());
+ m_LogFile.close();
+ }
+
+ m_Phases.removeFirst();
+ m_State = STATE_PRECHECK;
+ }
+ else
+ {
+ emit protocolProgress(m_TotalPhases - m_Phases.size(), m_TotalPhases,
+ QString("Capturing Data... %1s remaining").arg(m_CaptureTimer));
+ m_CaptureTimer--;
+ }
+ break;
+ }
+
+ case STATE_PULSE_RESPONSE_INIT:
+ {
+ if (!m_Guide)
+ {
+ m_State = STATE_ERROR;
+ break;
+ }
+
+ ProtocolPhase phase = m_Phases.first();
+ emit protocolLog(QString("Pulse Response: %1 %2 %3ms — preparing...")
+ .arg(phase.pulseAxis, phase.pulseDirection).arg(phase.pulseMagnitudeMs));
+
+ m_Guide->setAIFreeDrift(true);
+ m_PulseFrameCount = 0;
+ m_PulseResponseData = QJsonArray();
+
+ connect(m_Guide, &Guide::guideStats, this, &AIGuideProtocol::onGuideStats, Qt::UniqueConnection);
+
+ if (m_Guide->status() == GUIDE_IDLE || m_Guide->status() == GUIDE_ABORTED)
+ m_Guide->guide();
+
+ m_FrameTimer.start();
+ m_PulseSettleTimer = 5;
+ m_State = STATE_PULSE_SETTLING;
+ break;
+ }
+
+ case STATE_PULSE_SENDING:
+ {
+ if (!m_Guide)
+ {
+ m_State = STATE_ERROR;
+ break;
+ }
+
+ ProtocolPhase phase = m_Phases.first();
+
+ m_Guide->setAIFreeDrift(false);
+ if (phase.pulseAxis == "RA")
+ {
+ if (phase.pulseDirection == "EAST")
+ m_Guide->sendSinglePulse(RA_INC_DIR, phase.pulseMagnitudeMs, StartCaptureAfterPulses);
+ else
+ m_Guide->sendSinglePulse(RA_DEC_DIR, phase.pulseMagnitudeMs, StartCaptureAfterPulses);
+ }
+ else
+ {
+ if (phase.pulseDirection == "NORTH")
+ m_Guide->sendSinglePulse(DEC_INC_DIR, phase.pulseMagnitudeMs, StartCaptureAfterPulses);
+ else
+ m_Guide->sendSinglePulse(DEC_DEC_DIR, phase.pulseMagnitudeMs, StartCaptureAfterPulses);
+ }
+ m_Guide->setAIFreeDrift(true);
+
+ emit protocolLog(QString("Sent %1ms %2 %3 pulse. Recording %4 response frames...")
+ .arg(phase.pulseMagnitudeMs).arg(phase.pulseAxis).arg(phase.pulseDirection)
+ .arg(phase.responseFrames));
+
+ m_PulseFrameCount = 0;
+ m_PulseResponseData = QJsonArray();
+ m_FrameTimer.start();
+ m_PulseWatchdog = phase.responseFrames * 6 + 30;
+ m_State = STATE_PULSE_RECORDING;
+ break;
+ }
+
+ case STATE_PULSE_RECORDING:
+ {
+ ProtocolPhase phase = m_Phases.first();
+
+ const bool guiderAborted = (!m_Guide || m_Guide->status() == GUIDE_ABORTED);
+ if (m_PulseWatchdog > 0) m_PulseWatchdog--;
+ const bool completed = (m_PulseFrameCount >= phase.responseFrames);
+ const bool interrupted = (!completed && (guiderAborted || m_PulseWatchdog <= 0));
+
+ emit protocolProgress(m_TotalPhases - m_Phases.size(), m_TotalPhases,
+ QString("Recording pulse response: %1/%2 frames")
+ .arg(m_PulseFrameCount).arg(phase.responseFrames));
+
+ if (completed || interrupted)
+ {
+ if (interrupted)
+ emit protocolLog(QString("Pulse response interrupted (%1) after %2/%3 frames.")
+ .arg(guiderAborted ? "guider aborted — star likely pushed out of frame" : "timed out")
+ .arg(m_PulseFrameCount).arg(phase.responseFrames));
+ else
+ emit protocolLog(QString("Pulse response recorded: %1 frames").arg(m_PulseFrameCount));
+
+ if (m_PulseFrameCount > 0)
+ {
+ ProtocolPhase p = m_Phases.first();
+ QJsonObject pulseSession;
+ pulseSession["session_id"] = QString("pulse_response_%1_%2_%3ms_%4")
+ .arg(p.pulseAxis.toLower(), p.pulseDirection.toLower())
+ .arg(p.pulseMagnitudeMs)
+ .arg(QDateTime::currentDateTime().toString("HHmmss"));
+ pulseSession["type"] = "pulse_response";
+ pulseSession["pulse_axis"] = p.pulseAxis;
+ pulseSession["pulse_direction"] = p.pulseDirection;
+ pulseSession["pulse_magnitude_ms"] = p.pulseMagnitudeMs;
+ pulseSession["altitude_deg"] = m_TargetAlt;
+ pulseSession["azimuth_deg"] = m_TargetAz;
+
+ if (m_Guide && m_Guide->mount())
+ {
+ auto pierSide = m_Guide->mount()->pierSide();
+ pulseSession["pier_side"] = (pierSide == ISD::Mount::PIER_EAST) ? "EAST" : "WEST";
+ }
+
+ pulseSession["response_frames"] = m_PulseResponseData;
+
+ QJsonArray sessions = m_SysIdData["sessions"].toArray();
+ sessions.append(pulseSession);
+ m_SysIdData["sessions"] = sessions;
+
+ m_LogFile.setFileName(m_LogFilename);
+ if (m_LogFile.open(QIODevice::WriteOnly | QIODevice::Text))
+ {
+ QJsonDocument doc(m_SysIdData);
+ m_LogFile.write(doc.toJson());
+ m_LogFile.close();
+ }
+ }
+
+ if (interrupted)
+ {
+ if (m_Guide)
+ {
+ m_Guide->setAIFreeDrift(false);
+ disconnect(m_Guide, &Guide::guideStats, this, &AIGuideProtocol::onGuideStats);
+ }
+ m_Phases.removeFirst();
+ m_State = STATE_PRECHECK;
+ }
+ else
+ {
+ m_PulseSettleTimer = phase.settleSeconds;
+ m_State = STATE_PULSE_SETTLING;
+ }
+ }
+ break;
+ }
+
+ case STATE_PULSE_SETTLING:
+ {
+ if (m_PulseSettleTimer > 0)
+ {
+ emit protocolProgress(m_TotalPhases - m_Phases.size(), m_TotalPhases,
+ QString("Pulse settling... %1s").arg(m_PulseSettleTimer));
+ m_PulseSettleTimer--;
+ }
+ else
+ {
+ if (m_PulseFrameCount == 0 && m_PulseResponseData.isEmpty())
+ {
+ m_State = STATE_PULSE_SENDING;
+ }
+ else
+ {
+ if (m_Guide)
+ {
+ m_Guide->setAIFreeDrift(false);
+ disconnect(m_Guide, &Guide::guideStats, this, &AIGuideProtocol::onGuideStats);
+ }
+ m_Phases.removeFirst();
+ m_State = STATE_PRECHECK;
+ }
+ }
+ break;
+ }
+
+ default:
+ break;
+ }
+}
+
+void AIGuideProtocol::onGuideStats(double raErr, double decErr, int raPulse, int decPulse,
+ double snr, double skyBg, int numStars)
+{
+ Q_UNUSED(skyBg)
+ Q_UNUSED(numStars)
+
+ if (m_State == STATE_CAPTURING_DATA)
+ {
+ double dt = m_FrameTimer.isValid() ? (m_FrameTimer.restart() / 1000.0) : 0.0;
+
+ if (m_Phases.first().freeDrift && !m_FreeDriftOverflow)
+ {
+ constexpr double FREE_DRIFT_LIMIT_ARCSEC = 25.0;
+ if (std::abs(raErr) > FREE_DRIFT_LIMIT_ARCSEC || std::abs(decErr) > FREE_DRIFT_LIMIT_ARCSEC)
+ {
+ emit protocolLog(QString("Free drift limit reached (RA=%1\" DEC=%2\"). Ending phase early to protect star.")
+ .arg(raErr, 0, 'f', 1).arg(decErr, 0, 'f', 1));
+ m_FreeDriftOverflow = true;
+ return;
+ }
+ }
+
+ double dx = raErr;
+ double dy = decErr;
+ if (m_Guide && m_Guide->pixelSizeX() > 0 && m_Guide->focalLength() > 0)
+ {
+ double binning = std::max(1, Options::guideBinning().left(1).toInt());
+ double scale = (206.265 * m_Guide->pixelSizeX() * binning) / m_Guide->focalLength();
+ dx = -raErr / scale;
+ dy = decErr / scale;
+ }
+
+ double alt_deg = 45.0, az_deg = 180.0, dec_deg = 0.0;
+ if (m_Guide && m_Guide->mount())
+ {
+ SkyPoint currentPos = m_Guide->mount()->currentCoordinates();
+ az_deg = currentPos.az().Degrees();
+ alt_deg = currentPos.alt().Degrees();
+ dec_deg = currentPos.dec().Degrees();
+ }
+
+ double lat_deg = 45.0;
+ if (KStarsData::Instance() && KStarsData::Instance()->geo() && KStarsData::Instance()->geo()->lat())
+ lat_deg = KStarsData::Instance()->geo()->lat()->Degrees();
+
+ double parallactic_angle_deg = 0.0;
+ double sin_az = std::sin(az_deg * M_PI / 180.0);
+ double cos_lat = std::cos(lat_deg * M_PI / 180.0);
+ double cos_dec = std::cos(dec_deg * M_PI / 180.0);
+
+ if (std::abs(cos_dec) > 1e-6)
+ {
+ double sin_q = (sin_az * cos_lat) / cos_dec;
+ sin_q = std::clamp(sin_q, -1.0, 1.0);
+ parallactic_angle_deg = std::asin(sin_q) * 180.0 / M_PI;
+ }
+
+ QJsonObject frame;
+ frame["timestamp"] = QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm:ss.zzz");
+ frame["altitude_deg"] = alt_deg;
+ frame["azimuth_deg"] = az_deg;
+ frame["parallactic_angle_deg"] = parallactic_angle_deg;
+ frame["ra_raw_px"] = dx;
+ frame["dec_raw_px"] = dy;
+ frame["snr"] = snr;
+ frame["dt"] = dt;
+ frame["error_code"] = (snr == 0.0) ? FRAME_STAR_LOST : FRAME_OK;
+ frame["ra_pulse_ms"] = raPulse;
+ frame["dec_pulse_ms"] = decPulse;
+ m_PhaseData.append(frame);
+ }
+
+ if (m_State == STATE_PULSE_RECORDING)
+ {
+ double dt = m_FrameTimer.isValid() ? (m_FrameTimer.restart() / 1000.0) : 0.0;
+
+ double dx = raErr;
+ double dy = decErr;
+ if (m_Guide && m_Guide->pixelSizeX() > 0 && m_Guide->focalLength() > 0)
+ {
+ double binning = std::max(1, Options::guideBinning().left(1).toInt());
+ double scale = (206.265 * m_Guide->pixelSizeX() * binning) / m_Guide->focalLength();
+ dx = -raErr / scale;
+ dy = decErr / scale;
+ }
+
+ QJsonObject frame;
+ frame["t"] = m_PulseFrameCount * dt;
+ frame["ra_raw_px"] = dx;
+ frame["dec_raw_px"] = dy;
+ frame["snr"] = snr;
+ frame["dt"] = dt;
+ m_PulseResponseData.append(frame);
+ m_PulseFrameCount++;
+ }
+}
+
+}
\ No newline at end of file
diff --git a/kstars/ekos/guide/aiguideprotocol.h b/kstars/ekos/guide/aiguideprotocol.h
new file mode 100644
index 0000000000..51cdf68188
--- /dev/null
+++ b/kstars/ekos/guide/aiguideprotocol.h
@@ -0,0 +1,156 @@
+/*
+ SPDX-FileCopyrightText: 2026 Pavan <[email protected]>
+
+ SPDX-License-Identifier: GPL-2.0-or-later
+*/
+
+#pragma once
+
+#include <QObject>
+#include <QTimer>
+#include <QFile>
+#include <QJsonObject>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QElapsedTimer>
+#include <QList>
+#include <QString>
+
+namespace Ekos
+{
+
+class Guide;
+
+class AIGuideProtocol : public QObject
+{
+ Q_OBJECT
+
+ public:
+ explicit AIGuideProtocol(Guide *guide);
+ virtual ~AIGuideProtocol() override = default;
+
+ enum ProtocolState
+ {
+ STATE_IDLE,
+ STATE_PRECHECK,
+ STATE_HORIZON_SCAN,
+ STATE_SLEWING,
+ STATE_SETTLING,
+ STATE_CAPTURING_DATA,
+ STATE_PULSE_RESPONSE_INIT,
+ STATE_PULSE_SENDING,
+ STATE_PULSE_RECORDING,
+ STATE_PULSE_SETTLING,
+ STATE_DONE,
+ STATE_TRAINING,
+ STATE_TRAINING_DONE,
+ STATE_ERROR
+ };
+ Q_ENUM(ProtocolState)
+
+ enum FrameErrorCode
+ {
+ FRAME_OK = 0,
+ FRAME_STAR_LOST = 1
+ };
+
+ // Accessors for Q_PROPERTY
+ ProtocolState state() const
+ {
+ return m_State;
+ }
+ int totalPhases() const
+ {
+ return m_TotalPhases;
+ }
+ int phasesRemaining() const
+ {
+ return m_Phases.size();
+ }
+ const QJsonObject &sysIdData() const
+ {
+ return m_SysIdData;
+ }
+ const QString &logFilename() const
+ {
+ return m_LogFilename;
+ }
+
+ Q_SIGNALS:
+ void protocolLog(const QString &message);
+ void protocolProgress(int current, int total, const QString &status);
+ void protocolComplete();
+ void protocolStopped();
+ void trainingRequested(const QJsonObject &sysidData);
+ void trainingProgress(const QString &message);
+ void trainingComplete();
+ void trainingError(const QString &error);
+
+ public slots:
+ Q_INVOKABLE void start(const QString &mountType);
+ Q_INVOKABLE void stop();
+ Q_INVOKABLE void requestTraining();
+ Q_INVOKABLE void loadAndComplete(const QString &jsonFilePath);
+ void onTrainingResult(bool success, const QString &message);
+
+ private slots:
+ void processProtocol();
+ void onGuideStats(double raErr, double decErr, int raPulse, int decPulse,
+ double snr, double skyBg, int numStars);
+
+ private:
+ void enforceSettings();
+ void restoreSettings();
+
+ Guide *m_Guide { nullptr };
+ int m_TotalPhases { 0 };
+
+ struct ProtocolPhase
+ {
+ double targetAlt;
+ double azOffset;
+ int durationSeconds;
+ bool freeDrift;
+ bool pulseResponse {false};
+ QString pulseAxis;
+ QString pulseDirection;
+ int pulseMagnitudeMs {0};
+ int responseFrames {15};
+ int settleSeconds {30};
+ };
+ QList<ProtocolPhase> m_Phases;
+
+ ProtocolState m_State { STATE_IDLE };
+ QTimer m_ProtocolTimer;
+ double m_TargetAz { 0 };
+ double m_TargetAlt { 0 };
+ int m_SettlingTimer { 0 };
+ int m_CaptureTimer { 0 };
+ int m_AbortRetries { 0 };
+ bool m_FreeDriftOverflow { false };
+
+ QFile m_LogFile;
+ QString m_LogFilename;
+ QJsonObject m_SysIdData;
+ QJsonArray m_PhaseData;
+
+ QElapsedTimer m_FrameTimer;
+
+ bool m_SettingsEnforced { false };
+ int m_OrigRAAlgorithm { 0 };
+ int m_OrigDECAlgorithm { 0 };
+ bool m_OrigRAEnabled { true };
+ bool m_OrigDECEnabled { true };
+ bool m_OrigEastEnabled { true };
+ bool m_OrigWestEnabled { true };
+ bool m_OrigNorthEnabled { true };
+ bool m_OrigSouthEnabled { true };
+ double m_OrigMaxDeltaRMS { 2.0 };
+
+ int m_PulseFrameCount { 0 };
+ int m_PulseSettleTimer { 0 };
+ int m_PulseWatchdog { 0 };
+ QJsonArray m_PulseResponseData;
+};
+
+}
\ No newline at end of file
diff --git a/kstars/ekos/guide/aiguidewizard.cpp b/kstars/ekos/guide/aiguidewizard.cpp
new file mode 100644
index 0000000000..cb1601492c
--- /dev/null
+++ b/kstars/ekos/guide/aiguidewizard.cpp
@@ -0,0 +1,402 @@
+/*
+ SPDX-FileCopyrightText: 2026 Pavan <[email protected]>
+
+ SPDX-License-Identifier: GPL-2.0-or-later
+*/
+
+#include "aiguidewizard.h"
+#include "aiguideprotocol.h"
+#include "guide.h"
+#include "kspaths.h"
+#include <QDateTime>
+#include <QDebug>
+#include <QDir>
+#include <QFileDialog>
+#include <QDirIterator>
+#include <QFileInfo>
+#include <zlib.h>
+
+namespace
+{
+// Minimal in-process ZIP (DEFLATE) writer — avoids pulling in KArchive.
+class ZipWriter
+{
+ public:
+ bool open(const QString &path)
+ {
+ m_file.setFileName(path);
+ return m_file.open(QIODevice::WriteOnly);
+ }
+
+ int addTree(const QString &dir, const QString &prefix)
+ {
+ if (!QDir(dir).exists())
+ return 0;
+ int n = 0;
+ const QString baseDir = QDir(dir).absolutePath();
+ QDirIterator it(dir, QDir::Files, QDirIterator::Subdirectories);
+ while (it.hasNext())
+ {
+ const QString fp = it.next();
+ if (addOne(fp, prefix + "/" + QDir(baseDir).relativeFilePath(fp)))
+ ++n;
+ }
+ return n;
+ }
+
+ int addOne(const QString &path, const QString &archiveName)
+ {
+ QFile f(path);
+ if (!f.open(QIODevice::ReadOnly))
+ return 0;
+ const QByteArray data = f.readAll();
+ f.close();
+
+ const QByteArray name = archiveName.toUtf8();
+ const quint32 crc = static_cast<quint32>(crc32(0L, reinterpret_cast<const Bytef *>(data.constData()),
+ static_cast<uInt>(data.size())));
+ const QByteArray comp = rawDeflate(data);
+ const QDateTime mtime = QFileInfo(path).lastModified();
+
+ Entry e;
+ e.name = name;
+ e.crc = crc;
+ e.compSize = static_cast<quint32>(comp.size());
+ e.rawSize = static_cast<quint32>(data.size());
+ e.dosTime = toDosTime(mtime);
+ e.dosDate = toDosDate(mtime);
+ e.offset = static_cast<quint32>(m_file.pos());
+ m_entries.append(e);
+
+ QByteArray hdr;
+ put32(hdr, 0x04034b50);
+ put16(hdr, 20);
+ put16(hdr, 0);
+ put16(hdr, 8);
+ put16(hdr, e.dosTime);
+ put16(hdr, e.dosDate);
+ put32(hdr, e.crc);
+ put32(hdr, e.compSize);
+ put32(hdr, e.rawSize);
+ put16(hdr, static_cast<quint16>(name.size()));
+ put16(hdr, 0);
+ hdr.append(name);
+ m_file.write(hdr);
+ m_file.write(comp);
+ return 1;
+ }
+
+ void finish()
+ {
+ const quint32 cdStart = static_cast<quint32>(m_file.pos());
+ QByteArray cd;
+ for (const Entry &e : m_entries)
+ {
+ put32(cd, 0x02014b50);
+ put16(cd, 20);
+ put16(cd, 20);
+ put16(cd, 0);
+ put16(cd, 8);
+ put16(cd, e.dosTime);
+ put16(cd, e.dosDate);
+ put32(cd, e.crc);
+ put32(cd, e.compSize);
+ put32(cd, e.rawSize);
+ put16(cd, static_cast<quint16>(e.name.size()));
+ put16(cd, 0);
+ put16(cd, 0);
+ put16(cd, 0);
+ put16(cd, 0);
+ put32(cd, 0);
+ put32(cd, e.offset);
+ cd.append(e.name);
+ }
+ m_file.write(cd);
+
+ QByteArray eocd;
+ put32(eocd, 0x06054b50);
+ put16(eocd, 0);
+ put16(eocd, 0);
+ put16(eocd, static_cast<quint16>(m_entries.size()));
+ put16(eocd, static_cast<quint16>(m_entries.size()));
+ put32(eocd, static_cast<quint32>(cd.size()));
+ put32(eocd, cdStart);
+ put16(eocd, 0);
+ m_file.write(eocd);
+ m_file.close();
+ }
+
+ private:
+ struct Entry
+ {
+ QByteArray name;
+ quint32 crc = 0, compSize = 0, rawSize = 0, offset = 0;
+ quint16 dosTime = 0, dosDate = 0;
+ };
+
+ static void put16(QByteArray &b, quint16 v)
+ {
+ b.append(char(v & 0xFF));
+ b.append(char((v >> 8) & 0xFF));
+ }
+ static void put32(QByteArray &b, quint32 v)
+ {
+ b.append(char(v & 0xFF));
+ b.append(char((v >> 8) & 0xFF));
+ b.append(char((v >> 16) & 0xFF));
+ b.append(char((v >> 24) & 0xFF));
+ }
+ static quint16 toDosTime(const QDateTime &dt)
+ {
+ const QTime t = dt.isValid() ? dt.time() : QTime(0, 0, 0);
+ return static_cast<quint16>((t.hour() << 11) | (t.minute() << 5) | (t.second() / 2));
+ }
+ static quint16 toDosDate(const QDateTime &dt)
+ {
+ const QDate d = dt.isValid() ? dt.date() : QDate(1980, 1, 1);
+ int year = d.year() - 1980;
+ if (year < 0) year = 0;
+ return static_cast<quint16>((year << 9) | (d.month() << 5) | d.day());
+ }
+ static QByteArray rawDeflate(const QByteArray &data)
+ {
+ z_stream s {};
+ if (deflateInit2(&s, Z_DEFAULT_COMPRESSION, Z_DEFLATED, -MAX_WBITS, 8, Z_DEFAULT_STRATEGY) != Z_OK)
+ return QByteArray();
+ QByteArray out;
+ out.resize(static_cast<int>(deflateBound(&s, static_cast<uLong>(data.size()))));
+ s.next_in = reinterpret_cast<Bytef *>(const_cast<char *>(data.constData()));
+ s.avail_in = static_cast<uInt>(data.size());
+ s.next_out = reinterpret_cast<Bytef *>(out.data());
+ s.avail_out = static_cast<uInt>(out.size());
+ deflate(&s, Z_FINISH);
+ out.resize(out.size() - static_cast<int>(s.avail_out));
+ deflateEnd(&s);
+ return out;
+ }
+
+ QFile m_file;
+ QVector<Entry> m_entries;
+};
+} // namespace
+
+namespace Ekos
+{
+
+AIGuideWizard::AIGuideWizard(AIGuideProtocol *protocol, QWidget *parent) : QWizard(parent), m_Protocol(protocol)
+{
+ setupUi(this);
+
+ // Wire protocol signals → wizard UI only (EkosLive forwarding is handled directly in Guide ctor)
+ connect(m_Protocol, &AIGuideProtocol::protocolLog, this, &AIGuideWizard::appendLog);
+ connect(m_Protocol, &AIGuideProtocol::protocolProgress, this, [this](int current, int total, const QString & status)
+ {
+ if (total > 0)
+ {
+ int progressValue = static_cast<int>(((current + 0.1) / total) * 100);
+ progressBar->setValue(std::max(0, std::min(99, progressValue)));
+ }
+ statusLabel->setText(status);
+ });
+ connect(m_Protocol, &AIGuideProtocol::protocolComplete, this, [this]()
+ {
+ progressBar->setValue(100);
+ exportOfflineButton->setEnabled(true);
+ this->next();
+ });
+
+ connect(stopButton, &QPushButton::clicked, this, &AIGuideWizard::slotStopProtocol);
+ connect(exportOfflineButton, &QPushButton::clicked, this, &AIGuideWizard::slotExportOffline);
+
+ connect(m_Protocol, &AIGuideProtocol::protocolStopped, this, [this]()
+ {
+ stopButton->setText(i18n("Start"));
+ stopButton->setEnabled(true);
+ disconnect(stopButton, &QPushButton::clicked, this, &AIGuideWizard::slotStopProtocol);
+ connect(stopButton, &QPushButton::clicked, this, &AIGuideWizard::slotStartProtocol);
+ });
+
+ connect(m_Protocol, &AIGuideProtocol::trainingRequested, this, [this](const QJsonObject & data)
+ {
+ if (data["sessions"].toArray().isEmpty())
+ {
+ appendLog("No data to train! Please run the protocol first.");
+ return;
+ }
+ trainEkosLiveButton->setEnabled(false);
+ trainEkosLiveButton->setText("Uploading and Training...");
+ appendLog("Uploading system-ID data to EkosLive Cloud for training...");
+ emit trainInEkosLiveRequested(data);
+ });
+
+ connect(trainEkosLiveButton, &QPushButton::clicked, this, [this]()
+ {
+ m_Protocol->requestTraining();
+ });
+
+ setButtonText(QWizard::CustomButton1, "Export Logs");
+ setOption(QWizard::HaveCustomButton1, true);
+ connect(this, &QWizard::customButtonClicked, this, [this](int which)
+ {
+ if (which == QWizard::CustomButton1)
+ slotExportLogs();
+ });
+
+ progressBar->setValue(0);
+}
+
+void AIGuideWizard::showEvent(QShowEvent *event)
+{
+ QWizard::showEvent(event);
+
+ // If protocol is already running (e.g. started via EkosLive), jump to page 2
+ if (m_Protocol->state() != AIGuideProtocol::STATE_IDLE &&
+ m_Protocol->state() != AIGuideProtocol::STATE_DONE &&
+ m_Protocol->state() != AIGuideProtocol::STATE_ERROR)
+ {
+ while (currentId() != 2)
+ next();
+ }
+ // If protocol already completed, jump to training page (page 3)
+ else if (m_Protocol->state() == AIGuideProtocol::STATE_DONE)
+ {
+ while (currentId() != 3)
+ next();
+ exportOfflineButton->setEnabled(true);
+ progressBar->setValue(100);
+ }
+}
+
+int AIGuideWizard::state() const
+{
+ return static_cast<int>(m_Protocol->state());
+}
+
+int AIGuideWizard::totalPhases() const
+{
+ return m_Protocol->totalPhases();
+}
+
+int AIGuideWizard::phasesRemaining() const
+{
+ return m_Protocol->phasesRemaining();
+}
+
+const QJsonObject &AIGuideWizard::sysIdData() const
+{
+ return m_Protocol->sysIdData();
+}
+
+void AIGuideWizard::onTrainingResult(bool success, const QJsonObject &result)
+{
+ if (success)
+ {
+ trainEkosLiveButton->setText("Training Complete - Weights Loaded");
+ appendLog("EkosLive training complete. Weights saved and set as the active AI weights.");
+ }
+ else
+ {
+ trainEkosLiveButton->setEnabled(true);
+ trainEkosLiveButton->setText("Train in EkosLive");
+ appendLog(QString("EkosLive training failed: %1").arg(result.value("message").toString("unknown error")));
+ }
+}
+
+void AIGuideWizard::slotExportLogs()
+{
+ const QString base = KSPaths::writableLocation(QStandardPaths::AppLocalDataLocation);
+ const QString suggested = QDir::homePath() + "/ekos_ai_logs_" +
+ QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss") + ".zip";
+ const QString out = QFileDialog::getSaveFileName(this, "Export Logs", suggested, "Zip Archives (*.zip)");
+ if (out.isEmpty()) return;
+
+ ZipWriter zip;
+ if (!zip.open(out))
+ {
+ appendLog("Failed to create archive: " + out);
+ return;
+ }
+
+ int n = 0;
+ n += zip.addTree(base + "/ai_debug_logs", "ai_debug_logs");
+ n += zip.addTree(base + "/ai_training_logs", "ai_training_logs");
+ n += zip.addTree(base + "/guidelogs", "guidelogs");
+ n += zip.addTree(base + "/logs", "logs");
+ n += zip.addOne(base + "/guide_log.txt", "guide_log.txt");
+ zip.finish();
+
+ if (n > 0)
+ appendLog(QString("Exported %1 log file(s) to: %2").arg(n).arg(out));
+ else
+ appendLog("No logs found. Enable 'Log to File' in KStars settings to also capture terminal logs.");
+}
+
+void AIGuideWizard::initializePage(int id)
+{
+ QWizard::initializePage(id);
+
+ // Page 2 (0-indexed) is the "System Identification Progress" page
+ if (id == 2)
+ {
+ 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);
+
+ m_Protocol->start(mountTypeCombo->currentText());
+ }
+}
+
+void AIGuideWizard::appendLog(const QString &message)
+{
+ QString timeStr = QDateTime::currentDateTime().toString("hh:mm:ss");
+ logTextEdit->append(QString("[%1] %2").arg(timeStr, message));
+}
+
+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.
+ restart();
+ for (int i = 0; i < 2; ++i)
+ next();
+}
+
+void AIGuideWizard::slotExportOffline()
+{
+ const auto &filename = m_Protocol->logFilename();
+ if (filename.isEmpty() || !QFile::exists(filename))
+ {
+ appendLog("No data to export! Please run the protocol first.");
+ return;
+ }
+
+ QString savePath = QFileDialog::getSaveFileName(this, "Export Offline Training Data",
+ QDir::homePath() + "/sysid_data.json", "JSON Files (*.json)");
+ if (!savePath.isEmpty())
+ {
+ if (QFile::copy(filename, savePath))
+ appendLog(QString("Successfully exported data to: %1").arg(savePath));
+ else
+ appendLog("Failed to export data!");
+ }
+}
+
+void AIGuideWizard::slotStopProtocol()
+{
+ m_Protocol->stop();
+ statusLabel->setText("Protocol Aborted by User");
+}
+
+void AIGuideWizard::done(int result)
+{
+ m_Protocol->stop();
+ QWizard::done(result);
+}
+
+}
\ No newline at end of file
diff --git a/kstars/ekos/guide/aiguidewizard.h b/kstars/ekos/guide/aiguidewizard.h
new file mode 100644
index 0000000000..4a716dc5b6
--- /dev/null
+++ b/kstars/ekos/guide/aiguidewizard.h
@@ -0,0 +1,69 @@
+/*
+ SPDX-FileCopyrightText: 2026 Pavan <[email protected]>
+
+ SPDX-License-Identifier: GPL-2.0-or-later
+*/
+
+#pragma once
+
+#include "ui_aiguidewizard.h"
+#include <QWizard>
+#include <QJsonObject>
+#include <QJsonArray>
+#include <QJsonDocument>
+
+namespace Ekos
+{
+class AIGuideProtocol;
+
+class AIGuideWizard : public QWizard, public Ui::AIGuideWizard
+{
+ Q_OBJECT
+
+ public:
+ explicit AIGuideWizard(AIGuideProtocol *protocol, QWidget *parent = nullptr);
+ virtual ~AIGuideWizard() override = default;
+
+ // Q_PROPERTY exposed to EkosLive for remote monitoring (read via property()).
+ Q_PROPERTY(QString mountType READ mountType)
+ Q_PROPERTY(int state READ state)
+ Q_PROPERTY(int totalPhases READ totalPhases)
+ Q_PROPERTY(int phasesRemaining READ phasesRemaining)
+
+ QString mountType() const
+ {
+ return mountTypeCombo->currentText();
+ }
+ int state() const;
+ int totalPhases() const;
+ int phasesRemaining() const;
+ const QJsonObject &sysIdData() const;
+
+ Q_SIGNALS:
+ void aiGuideProgress(int current, int total, const QString &status);
+ void aiGuideLog(const QString &message);
+ void aiGuideComplete();
+ void trainInEkosLiveRequested(const QJsonObject &sysidData);
+
+ protected:
+ void initializePage(int id) override;
+ void done(int result) override;
+ void showEvent(QShowEvent *event) override;
+
+ public slots:
+ Q_INVOKABLE void slotStartProtocol();
+ Q_INVOKABLE void slotStopProtocol();
+
+ void onTrainingResult(bool success, const QJsonObject &result);
+
+ private slots:
+ void slotExportOffline();
+ void slotExportLogs();
+
+ private:
+ void appendLog(const QString &message);
+
+ AIGuideProtocol *m_Protocol { nullptr };
+};
+
+}
\ No newline at end of file
diff --git a/kstars/ekos/guide/opsaiguide.ui b/kstars/ekos/guide/aiguidewizard.ui
similarity index 99%
rename from kstars/ekos/guide/opsaiguide.ui
rename to kstars/ekos/guide/aiguidewizard.ui
index c4768f8b14..183647c5cc 100644
--- a/kstars/ekos/guide/opsaiguide.ui
+++ b/kstars/ekos/guide/aiguidewizard.ui
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
- <class>OpsAIGuide</class>
- <widget class="QWizard" name="OpsAIGuide">
+ <class>AIGuideWizard</class>
+ <widget class="QWizard" name="AIGuideWizard">
<property name="geometry">
<rect>
<x>0</x>
diff --git a/kstars/ekos/guide/guide.cpp b/kstars/ekos/guide/guide.cpp
index 15af21f417..dff9910a8b 100644
--- a/kstars/ekos/guide/guide.cpp
+++ b/kstars/ekos/guide/guide.cpp
@@ -14,7 +14,8 @@
#include "opscalibration.h"
#include "opsguide.h"
#include "opsaiconfig.h"
-#include "opsaiguide.h"
+#include "aiguidewizard.h"
+#include "aiguideprotocol.h"
#include "opsdither.h"
#include "opsgpg.h"
#include "Options.h"
@@ -216,6 +217,19 @@ Guide::Guide() : QWidget()
m_ManaulPulse->show();
});
+ // AI Guider protocol
+ m_AIGuideProtocol = new AIGuideProtocol(this);
+
+ connect(m_AIGuideProtocol, &AIGuideProtocol::protocolLog, this, &Guide::newAIGuideLog);
+ connect(m_AIGuideProtocol, &AIGuideProtocol::protocolProgress, this, &Guide::newAIGuideProgress);
+ connect(m_AIGuideProtocol, &AIGuideProtocol::protocolComplete, this, &Guide::newAIGuideComplete);
+
+ // AI Guider training signals
+ connect(m_AIGuideProtocol, &AIGuideProtocol::trainingRequested, this, &Guide::newTrainingData);
+ connect(m_AIGuideProtocol, &AIGuideProtocol::trainingProgress, this, &Guide::newAIGuideTrainingProgress);
+ connect(m_AIGuideProtocol, &AIGuideProtocol::trainingComplete, this, &Guide::newAIGuideTrainingComplete);
+ connect(m_AIGuideProtocol, &AIGuideProtocol::trainingError, this, &Guide::newAIGuideTrainingError);
+
loadGlobalSettings();
connectSettings();
@@ -3549,15 +3563,18 @@ void Guide::loop()
///////////////////////////////////////////////////////////////////////////////////////////
///
///////////////////////////////////////////////////////////////////////////////////////////
-Ekos::OpsAIGuide *Guide::getAIGuide()
+Ekos::AIGuideWizard *Guide::getAIGuide()
{
- if (!opsAIGuide)
+ if (!aIGuideWizard)
{
- opsAIGuide = new OpsAIGuide(this);
- // Forward the wizard's "Train in EkosLive" request up so the Manager can route it to the cloud.
- connect(opsAIGuide, &OpsAIGuide::trainInEkosLiveRequested, this, &Guide::newTrainingData);
+ aIGuideWizard = new AIGuideWizard(m_AIGuideProtocol, this);
+ connect(aIGuideWizard, &AIGuideWizard::trainInEkosLiveRequested, this, &Guide::newTrainingData);
+ // Forward AI Guider progress/log/complete signals for EkosLive streaming
+ connect(aIGuideWizard, &AIGuideWizard::aiGuideProgress, this, &Guide::newAIGuideProgress);
+ connect(aIGuideWizard, &AIGuideWizard::aiGuideLog, this, &Guide::newAIGuideLog);
+ connect(aIGuideWizard, &AIGuideWizard::aiGuideComplete, this, &Guide::newAIGuideComplete);
}
- return opsAIGuide;
+ return aIGuideWizard;
}
void Guide::updateTrainingWeight(bool success, const QJsonObject &result)
@@ -3566,8 +3583,11 @@ void Guide::updateTrainingWeight(bool success, const QJsonObject &result)
{
appendLogText(i18n("EkosLive AI training failed: %1",
result.value("message").toString(i18n("unknown error"))));
- if (opsAIGuide)
- opsAIGuide->onTrainingResult(false, result);
+ if (aIGuideWizard)
+ aIGuideWizard->onTrainingResult(false, result);
+ if (m_AIGuideProtocol)
+ m_AIGuideProtocol->onTrainingResult(false, i18n("EkosLive AI training failed: %1",
+ result.value("message").toString(i18n("unknown error"))));
return;
}
@@ -3582,8 +3602,8 @@ void Guide::updateTrainingWeight(bool success, const QJsonObject &result)
if (!file.open(QIODevice::WriteOnly | QIODevice::Text))
{
appendLogText(i18n("Failed to save AI weights to %1", path));
- if (opsAIGuide)
- opsAIGuide->onTrainingResult(false, result);
+ if (aIGuideWizard)
+ aIGuideWizard->onTrainingResult(false, result);
return;
}
file.write(QJsonDocument(weights).toJson(QJsonDocument::Indented));
@@ -3592,8 +3612,10 @@ void Guide::updateTrainingWeight(bool success, const QJsonObject &result)
// Point the AI guider at the new weights; they load automatically the next time guiding starts.
Options::setAIGuiderWeightsFile(QUrl::fromLocalFile(path));
appendLogText(i18n("AI training complete. Weights saved to %1 and set as the active AI weights.", path));
- if (opsAIGuide)
- opsAIGuide->onTrainingResult(true, result);
+ if (aIGuideWizard)
+ aIGuideWizard->onTrainingResult(true, result);
+ if (m_AIGuideProtocol)
+ m_AIGuideProtocol->onTrainingResult(true, i18n("AI training complete. Weights saved to %1.", path));
}
void Guide::loadAIWeights()
diff --git a/kstars/ekos/guide/guide.h b/kstars/ekos/guide/guide.h
index 6ae67433b1..3cce335265 100644
--- a/kstars/ekos/guide/guide.h
+++ b/kstars/ekos/guide/guide.h
@@ -30,7 +30,8 @@ namespace Ekos
{
class OpsCalibration;
class OpsGuide;
-class OpsAIGuide;
+class AIGuideProtocol;
+class AIGuideWizard;
class OpsAIConfig;
class OpsDither;
class OpsGPG;
@@ -225,10 +226,10 @@ class Guide : public QWidget, public Ui::Guide
}
/**
- * @brief getAIGuide Access the OpsAIGuide wizard. Creates it if it doesn't exist.
- * @return pointer to OpsAIGuide
+ * @brief getAIGuide Access the AIGuideWizard wizard. Creates it if it doesn't exist.
+ * @return pointer to AIGuideWizard
*/
- OpsAIGuide *getAIGuide();
+ AIGuideWizard *getAIGuide();
// Settings
QVariantMap getAllSettings() const;
@@ -246,7 +247,7 @@ class Guide : public QWidget, public Ui::Guide
*/
Q_SCRIPTABLE bool guide();
- // Used by OpsAIGuide to disable pulses but keep the tracking loop (Free Drift)
+ // Used by AIGuideProtocol to disable pulses but keep the tracking loop (Free Drift)
void setAIFreeDrift(bool enable)
{
m_AIFreeDrift = enable;
@@ -507,6 +508,16 @@ class Guide : public QWidget, public Ui::Guide
void settingsUpdated(const QVariantMap &settings);
void driverTimedout(const QString &deviceName);
+ // AI Guider signals mirrored from AIGuideWizard
+ void newAIGuideProgress(int current, int total, const QString &status);
+ void newAIGuideLog(const QString &message);
+ void newAIGuideComplete();
+
+ // AI Guider training signals
+ void newAIGuideTrainingProgress(const QString &message);
+ void newAIGuideTrainingComplete();
+ void newAIGuideTrainingError(const QString &error);
+
private:
void resizeEvent(QResizeEvent *event) override;
@@ -733,7 +744,8 @@ class Guide : public QWidget, public Ui::Guide
OpsDither *opsDither { nullptr };
OpsGPG *opsGPG { nullptr };
OpsAIConfig *opsAIConfig { nullptr };
- OpsAIGuide *opsAIGuide { nullptr };
+ AIGuideProtocol *m_AIGuideProtocol { nullptr };
+ AIGuideWizard *aIGuideWizard { nullptr };
// Guide Frame
QSharedPointer<GuideView> m_GuideView;
diff --git a/kstars/ekos/guide/opsaiconfig.cpp b/kstars/ekos/guide/opsaiconfig.cpp
index cde16c3a54..a323d2e091 100644
--- a/kstars/ekos/guide/opsaiconfig.cpp
+++ b/kstars/ekos/guide/opsaiconfig.cpp
@@ -7,7 +7,6 @@
#include "opsaiconfig.h"
#include "kstars.h"
-#include "opsaiguide.h"
namespace Ekos
{
diff --git a/kstars/ekos/guide/opsaiguide.cpp b/kstars/ekos/guide/opsaiguide.cpp
deleted file mode 100644
index 7182cd2f59..0000000000
--- a/kstars/ekos/guide/opsaiguide.cpp
+++ /dev/null
@@ -1,1221 +0,0 @@
-/*
- SPDX-FileCopyrightText: 2026 Pavan <[email protected]>
-
- SPDX-License-Identifier: GPL-2.0-or-later
-*/
-
-#include "opsaiguide.h"
-#include "guide.h"
-#include "kstarsdata.h"
-#include "kspaths.h"
-#include "skycomponents/skymapcomposite.h"
-#include "skycomponents/artificialhorizoncomponent.h"
-#include "indi/indimount.h"
-#include "Options.h"
-#include "internalguide/internalguider.h"
-#include "internalguide/calibration.h"
-#include "guideinterface.h"
-#include <QDateTime>
-#include <QDebug>
-#include <QDir>
-#include <QFileDialog>
-#include <QJsonDocument>
-#include <algorithm>
-#include <QDirIterator>
-#include <QFileInfo>
-#include <QVector>
-#include <zlib.h>
-
-namespace
-{
-// Minimal in-process ZIP (DEFLATE) writer built on the already-linked zlib — avoids pulling in KArchive.
-class ZipWriter
-{
- public:
- bool open(const QString &path)
- {
- m_file.setFileName(path);
- return m_file.open(QIODevice::WriteOnly);
- }
-
- // Recursively add every file under dir, stored as prefix/<relative path>. Returns count added.
- int addTree(const QString &dir, const QString &prefix)
- {
- if (!QDir(dir).exists())
- return 0;
- int n = 0;
- const QString baseDir = QDir(dir).absolutePath();
- QDirIterator it(dir, QDir::Files, QDirIterator::Subdirectories);
- while (it.hasNext())
- {
- const QString fp = it.next();
- if (addOne(fp, prefix + "/" + QDir(baseDir).relativeFilePath(fp)))
- ++n;
- }
- return n;
- }
-
- // Add a single file under archiveName. Returns 1 if added, 0 if unreadable.
- int addOne(const QString &path, const QString &archiveName)
- {
- QFile f(path);
- if (!f.open(QIODevice::ReadOnly))
- return 0;
- const QByteArray data = f.readAll();
- f.close();
-
- const QByteArray name = archiveName.toUtf8();
- const quint32 crc = static_cast<quint32>(crc32(0L, reinterpret_cast<const Bytef *>(data.constData()),
- static_cast<uInt>(data.size())));
- const QByteArray comp = rawDeflate(data);
- const QDateTime mtime = QFileInfo(path).lastModified();
-
- Entry e;
- e.name = name;
- e.crc = crc;
- e.compSize = static_cast<quint32>(comp.size());
- e.rawSize = static_cast<quint32>(data.size());
- e.dosTime = toDosTime(mtime);
- e.dosDate = toDosDate(mtime);
- e.offset = static_cast<quint32>(m_file.pos());
- m_entries.append(e);
-
- QByteArray hdr;
- put32(hdr, 0x04034b50); // local file header signature
- put16(hdr, 20); // version needed
- put16(hdr, 0); // general purpose flags
- put16(hdr, 8); // compression method = deflate
- put16(hdr, e.dosTime);
- put16(hdr, e.dosDate);
- put32(hdr, e.crc);
- put32(hdr, e.compSize);
- put32(hdr, e.rawSize);
- put16(hdr, static_cast<quint16>(name.size()));
- put16(hdr, 0); // extra field length
- hdr.append(name);
- m_file.write(hdr);
- m_file.write(comp);
- return 1;
- }
-
- void finish()
- {
- const quint32 cdStart = static_cast<quint32>(m_file.pos());
- QByteArray cd;
- for (const Entry &e : m_entries)
- {
- put32(cd, 0x02014b50); // central directory header signature
- put16(cd, 20); // version made by
- put16(cd, 20); // version needed
- put16(cd, 0); // flags
- put16(cd, 8); // method
- put16(cd, e.dosTime);
- put16(cd, e.dosDate);
- put32(cd, e.crc);
- put32(cd, e.compSize);
- put32(cd, e.rawSize);
- put16(cd, static_cast<quint16>(e.name.size()));
- put16(cd, 0); // extra len
- put16(cd, 0); // comment len
- put16(cd, 0); // disk number start
- put16(cd, 0); // internal attrs
- put32(cd, 0); // external attrs
- put32(cd, e.offset); // local header offset
- cd.append(e.name);
- }
- m_file.write(cd);
-
- QByteArray eocd;
- put32(eocd, 0x06054b50); // end of central directory signature
- put16(eocd, 0); // this disk number
- put16(eocd, 0); // disk with central dir
- put16(eocd, static_cast<quint16>(m_entries.size()));
- put16(eocd, static_cast<quint16>(m_entries.size()));
- put32(eocd, static_cast<quint32>(cd.size()));
- put32(eocd, cdStart);
- put16(eocd, 0); // comment length
- m_file.write(eocd);
- m_file.close();
- }
-
- private:
- struct Entry
- {
- QByteArray name;
- quint32 crc = 0, compSize = 0, rawSize = 0, offset = 0;
- quint16 dosTime = 0, dosDate = 0;
- };
-
- static void put16(QByteArray &b, quint16 v)
- {
- b.append(char(v & 0xFF));
- b.append(char((v >> 8) & 0xFF));
- }
- static void put32(QByteArray &b, quint32 v)
- {
- b.append(char(v & 0xFF));
- b.append(char((v >> 8) & 0xFF));
- b.append(char((v >> 16) & 0xFF));
- b.append(char((v >> 24) & 0xFF));
- }
- static quint16 toDosTime(const QDateTime &dt)
- {
- const QTime t = dt.isValid() ? dt.time() : QTime(0, 0, 0);
- return static_cast<quint16>((t.hour() << 11) | (t.minute() << 5) | (t.second() / 2));
- }
- static quint16 toDosDate(const QDateTime &dt)
- {
- const QDate d = dt.isValid() ? dt.date() : QDate(1980, 1, 1);
- int year = d.year() - 1980;
- if (year < 0)
- year = 0;
- return static_cast<quint16>((year << 9) | (d.month() << 5) | d.day());
- }
- static QByteArray rawDeflate(const QByteArray &data)
- {
- z_stream s {};
- // Negative windowBits selects a raw DEFLATE stream (no zlib header/trailer), which is what
- // the ZIP format stores — plain compress2() would wrap it and corrupt the archive.
- if (deflateInit2(&s, Z_DEFAULT_COMPRESSION, Z_DEFLATED, -MAX_WBITS, 8, Z_DEFAULT_STRATEGY) != Z_OK)
- return QByteArray();
- QByteArray out;
- out.resize(static_cast<int>(deflateBound(&s, static_cast<uLong>(data.size()))));
- s.next_in = reinterpret_cast<Bytef *>(const_cast<char *>(data.constData()));
- s.avail_in = static_cast<uInt>(data.size());
- s.next_out = reinterpret_cast<Bytef *>(out.data());
- s.avail_out = static_cast<uInt>(out.size());
- deflate(&s, Z_FINISH);
- out.resize(out.size() - static_cast<int>(s.avail_out));
- deflateEnd(&s);
- return out;
- }
-
- QFile m_file;
- QVector<Entry> m_entries;
-};
-} // namespace
-
-namespace Ekos
-{
-
-OpsAIGuide::OpsAIGuide(QWidget *parent) : QWizard(parent)
-{
- setupUi(this);
-
- connect(stopButton, &QPushButton::clicked, this, &OpsAIGuide::slotStopProtocol);
- connect(exportOfflineButton, &QPushButton::clicked, this, &OpsAIGuide::slotExportOffline);
- connect(&m_ProtocolTimer, &QTimer::timeout, this, &OpsAIGuide::slotProcessProtocol);
-
- connect(trainEkosLiveButton, &QPushButton::clicked, this, [this]()
- {
- if (m_SysIdData["sessions"].toArray().isEmpty())
- {
- appendLog("No data to train! Please run the protocol first.");
- return;
- }
- trainEkosLiveButton->setEnabled(false);
- trainEkosLiveButton->setText("Uploading and Training...");
- appendLog("Uploading system-ID data to EkosLive Cloud for training...");
- emit trainInEkosLiveRequested(m_SysIdData);
- });
-
- // Wizard-wide "Export Logs" button (visible on every page) that bundles the AI/guide/terminal logs into a zip.
- setButtonText(QWizard::CustomButton1, "Export Logs");
- setOption(QWizard::HaveCustomButton1, true);
- connect(this, &QWizard::customButtonClicked, this, [this](int which)
- {
- if (which == QWizard::CustomButton1)
- slotExportLogs();
- });
-
- progressBar->setValue(0);
-}
-
-void OpsAIGuide::onTrainingResult(bool success, const QJsonObject &result)
-{
- if (success)
- {
- trainEkosLiveButton->setText("Training Complete - Weights Loaded");
- appendLog("EkosLive training complete. Weights saved and set as the active AI weights.");
- }
- else
- {
- trainEkosLiveButton->setEnabled(true);
- trainEkosLiveButton->setText("Train in EkosLive");
- appendLog(QString("EkosLive training failed: %1").arg(result.value("message").toString("unknown error")));
- }
-}
-
-void OpsAIGuide::slotExportLogs()
-{
- const QString base = KSPaths::writableLocation(QStandardPaths::AppLocalDataLocation);
- const QString suggested = QDir::homePath() + "/ekos_ai_logs_" +
- QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss") + ".zip";
- const QString out = QFileDialog::getSaveFileName(this, "Export Logs", suggested, "Zip Archives (*.zip)");
- if (out.isEmpty())
- return;
-
- ZipWriter zip;
- if (!zip.open(out))
- {
- appendLog("Failed to create archive: " + out);
- return;
- }
-
- int n = 0;
- n += zip.addTree(base + "/ai_debug_logs", "ai_debug_logs"); // per-frame AI telemetry CSVs
- n += zip.addTree(base + "/ai_training_logs", "ai_training_logs"); // sysid captures
- n += zip.addTree(base + "/guidelogs", "guidelogs"); // per-session guide logs
- n += zip.addTree(base + "/logs", "logs"); // KStars console log (if Log to File enabled)
- n += zip.addOne(base + "/guide_log.txt", "guide_log.txt");
- zip.finish();
-
- if (n > 0)
- appendLog(QString("Exported %1 log file(s) to: %2").arg(n).arg(out));
- else
- appendLog("No logs found. Enable 'Log to File' in KStars settings to also capture terminal logs.");
-}
-
-void OpsAIGuide::initializePage(int id)
-{
- QWizard::initializePage(id);
-
- // Page 2 (0-indexed) is the "System Identification Progress" page
- // where the protocol actually starts executing
- if (id == 2)
- {
- slotStartProtocol();
- }
-}
-
-void OpsAIGuide::appendLog(const QString &message)
-{
- QString timeStr = QDateTime::currentDateTime().toString("hh:mm:ss");
- logTextEdit->append(QString("[%1] %2").arg(timeStr, message));
- emit aiGuideLog(message);
-}
-
-void OpsAIGuide::enforceSettings()
-{
- if (!m_SettingsEnforced)
- {
- m_OrigRAAlgorithm = Options::rAGuidePulseAlgorithm();
- m_OrigDECAlgorithm = Options::dECGuidePulseAlgorithm();
- m_OrigRAEnabled = Options::rAGuideEnabled();
- m_OrigDECEnabled = Options::dECGuideEnabled();
- m_OrigEastEnabled = Options::eastRAGuideEnabled();
- m_OrigWestEnabled = Options::westRAGuideEnabled();
- m_OrigNorthEnabled = Options::northDECGuideEnabled();
- m_OrigSouthEnabled = Options::southDECGuideEnabled();
- m_OrigMaxDeltaRMS = Options::guideMaxDeltaRMS();
- m_SettingsEnforced = true;
- }
-
- Options::setRAGuidePulseAlgorithm(0);
- Options::setDECGuidePulseAlgorithm(0);
- Options::setRAGuideEnabled(true);
- Options::setDECGuideEnabled(true);
- Options::setEastRAGuideEnabled(true);
- Options::setWestRAGuideEnabled(true);
- Options::setNorthDECGuideEnabled(true);
- Options::setSouthDECGuideEnabled(true);
- Options::setGuideMaxDeltaRMS(100.0); // Allow huge drifts during Free Drift without aborting
-
- appendLog("Enforced Standard guiding algorithm and all directions enabled for data collection.");
-}
-
-void OpsAIGuide::restoreSettings()
-{
- // Nothing was enforced (e.g. wizard opened and closed without starting) — do not
- // clobber the user's live settings with default-constructed "originals".
- if (!m_SettingsEnforced)
- return;
- m_SettingsEnforced = false;
-
- Options::setRAGuidePulseAlgorithm(m_OrigRAAlgorithm);
- Options::setDECGuidePulseAlgorithm(m_OrigDECAlgorithm);
- Options::setRAGuideEnabled(m_OrigRAEnabled);
- Options::setDECGuideEnabled(m_OrigDECEnabled);
- Options::setEastRAGuideEnabled(m_OrigEastEnabled);
- Options::setWestRAGuideEnabled(m_OrigWestEnabled);
- Options::setNorthDECGuideEnabled(m_OrigNorthEnabled);
- Options::setSouthDECGuideEnabled(m_OrigSouthEnabled);
- Options::setGuideMaxDeltaRMS(m_OrigMaxDeltaRMS);
-
- appendLog("Restored original guide algorithm and direction settings.");
-}
-
-void OpsAIGuide::slotStartProtocol()
-{
- progressBar->setValue(0);
- logTextEdit->clear();
- appendLog("Starting AI Guiding Assistant Protocol...");
-
- // Build the Queue based on Mount Type
- m_Phases.clear();
- QString mountStr = mountTypeCombo->currentText();
- QString mountTypeEnum;
- if (mountStr == "Worm Gear") mountTypeEnum = "WORM_GEAR";
- else if (mountStr == "Harmonic Drive") mountTypeEnum = "HARMONIC_DRIVE";
- else mountTypeEnum = "DIRECT_DRIVE";
-
- // Initialize the root JSON object for SysId Data
- m_SysIdData = QJsonObject();
- m_SysIdData["format_version"] = "1.0";
- QJsonObject equipment;
- equipment["mount_type"] = mountTypeEnum;
- equipment["mount_name"] = "Unknown"; // Can be populated dynamically later
- equipment["camera"] = "Unknown";
- equipment["focal_length_mm"] = 0;
- equipment["pixel_size_um"] = 0.0;
- equipment["pixel_scale_arcsec_per_px"] = 0.0;
- equipment["guide_exposure_ms"] = 0; // Usually dynamic
- equipment["guide_optics_type"] = "Unknown"; // Will be populated below
- auto *guide = qobject_cast<Guide *>(parentWidget());
- if (guide)
- {
- equipment["camera"] = guide->camera();
- equipment["focal_length_mm"] = guide->focalLength();
- equipment["pixel_size_um"] = guide->pixelSizeX();
- equipment["guide_exposure_ms"] = static_cast<int>(guide->exposure() * 1000.0);
- if (guide->mount())
- equipment["mount_name"] = guide->mount()->getDeviceName();
- if (guide->focalLength() > 0)
- {
- equipment["pixel_scale_arcsec_per_px"] = (206.265 * guide->pixelSizeX()) / guide->focalLength();
-
- // Heuristic for OAG vs Guidescope
- if (std::abs(guide->focalLength() - Options::telescopeFocalLength()) < 1.0)
- {
- equipment["guide_optics_type"] = "OAG";
- }
- else
- {
- equipment["guide_optics_type"] = "Guidescope";
- }
- }
- }
- m_SysIdData["equipment"] = equipment;
-
- QJsonObject fingerprint;
- fingerprint["guide_exposure_s"] = guide ? 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<int>(Options::rAMaximumPulseArcSec());
- fingerprint["dec_max_pulse_arcsec"] = static_cast<int>(Options::dECMaximumPulseArcSec());
- fingerprint["ra_hysteresis"] = Options::rAHysteresis();
- fingerprint["dec_hysteresis"] = Options::dECHysteresis();
- fingerprint["ra_pulse_algorithm"] = 0; // Forced
- fingerprint["dec_pulse_algorithm"] = 0; // Forced
- fingerprint["all_directions_enabled"] = true; // Forced
- m_SysIdData["model_fingerprint"] = fingerprint;
-
- m_SysIdData["sessions"] = QJsonArray();
-
- enforceSettings();
-
- // Generate a single filename for this entire run
- QString dirPath = QDir(KSPaths::writableLocation(QStandardPaths::AppLocalDataLocation)).filePath("ai_training_logs");
- QDir().mkpath(dirPath);
- m_LogFilename = dirPath + "/" + QString("sysid_data_%1.json").arg(QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss"));
-
- if (mountStr == "Worm Gear")
- {
-
- // Position 1: Pier West, high altitude
- m_Phases.append({65.0, -45.0, 480, false, false, "", "", 0, 0, 0}); // 8min Standard Guiding
- m_Phases.append({65.0, -45.0, 600, true, false, "", "", 0, 0, 0}); // 10min Free Drift (captures 3 full 198s worm cycles)
-
- // Position 2: lower altitude
- m_Phases.append({40.0, -45.0, 480, false, false, "", "", 0, 0, 0}); // 8min Standard Guiding
- m_Phases.append({40.0, -45.0, 400, true, false, "", "", 0, 0, 0}); // ~6.7min Free Drift
-
- // Position 3: Pier East, high altitude
- m_Phases.append({65.0, 45.0, 480, false, false, "", "", 0, 0, 0}); // 8min Standard Guiding
- m_Phases.append({65.0, 45.0, 400, true, false, "", "", 0, 0, 0}); // ~6.7min Free Drift
- }
- else if (mountStr == "Harmonic Drive")
- {
- // Standard guiding at Position 1
- m_Phases.append({65.0, -45.0, 480, false, false, {}, {}, 0, 15, 30}); // 8min standard guiding
- // Position 1: Alt ~65°, 3 hours East of meridian to prevent flip
- m_Phases.append({65.0, -45.0, 120, true, false, {}, {}, 0, 15, 30}); // 2min Free Drift (PE detection)
-
- // Pulse response tests at Position 1 — RA axis
- m_Phases.append({65.0, -45.0, 0, false, true, "RA", "EAST", 50, 15, 30});
- m_Phases.append({65.0, -45.0, 0, false, true, "RA", "EAST", 100, 15, 30});
- m_Phases.append({65.0, -45.0, 0, false, true, "RA", "EAST", 200, 15, 30});
- m_Phases.append({65.0, -45.0, 0, false, true, "RA", "WEST", 50, 15, 30});
- m_Phases.append({65.0, -45.0, 0, false, true, "RA", "WEST", 100, 15, 30});
- m_Phases.append({65.0, -45.0, 0, false, true, "RA", "WEST", 200, 15, 30});
-
- // Pulse response tests — DEC axis
- m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "NORTH", 50, 15, 30});
- m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "NORTH", 100, 15, 30});
- m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "NORTH", 200, 15, 30});
- m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "SOUTH", 50, 15, 30});
- m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "SOUTH", 100, 15, 30});
- m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "SOUTH", 200, 15, 30});
-
-
- // Position 2: Alt ~45°
- m_Phases.append({45.0, -45.0, 120, true, false, {}, {}, 0, 15, 30}); // 2min free drift
- m_Phases.append({45.0, -45.0, 300, false, false, {}, {}, 0, 15, 30}); // 5min standard guiding
- }
- else
- {
-
-
- // Position 1: Alt 70, Az ~180 (meridian south) — parallactic angle near zero
- m_Phases.append({70.0, 0.0, 120, false, false, "", "", 0, 0, 0}); // 2min standard (calibrate)
- m_Phases.append({70.0, 0.0, 180, true, false, "", "", 0, 0, 0}); // 3min free drift
-
- // Position 2: Alt 50, Az ~120 (SE, ~2h east) — parallactic angle negative
- m_Phases.append({50.0, -60.0, 120, false, false, "", "", 0, 0, 0}); // 2min standard
- m_Phases.append({50.0, -60.0, 180, true, false, "", "", 0, 0, 0}); // 3min free drift
-
- // Position 3: Alt 35, Az ~240 (SW, ~2h west) — parallactic angle positive (opposite sign to pos 2)
- m_Phases.append({35.0, 60.0, 120, false, false, "", "", 0, 0, 0}); // 2min standard
- m_Phases.append({35.0, 60.0, 180, true, false, "", "", 0, 0, 0}); // 3min free drift
- }
-
- appendLog(QString("Loaded %1 phases for %2").arg(m_Phases.size()).arg(mountStr));
-
- m_TotalPhases = m_Phases.size();
- progressBar->setValue(0);
- exportOfflineButton->setEnabled(false);
-
- m_State = STATE_PRECHECK;
- m_ProtocolTimer.start(1000); // 1Hz tick
-}
-
-void OpsAIGuide::slotExportOffline()
-{
- if (m_LogFilename.isEmpty() || !QFile::exists(m_LogFilename))
- {
- appendLog("No data to export! Please run the protocol first.");
- return;
- }
-
- QString savePath = QFileDialog::getSaveFileName(this, "Export Offline Training Data", QDir::homePath() + "/sysid_data.json",
- "JSON Files (*.json)");
- if (!savePath.isEmpty())
- {
- if (QFile::copy(m_LogFilename, savePath))
- {
- appendLog(QString("Successfully exported data to: %1").arg(savePath));
- }
- else
- {
- appendLog("Failed to export data!");
- }
- }
-}
-
-void OpsAIGuide::slotStopProtocol()
-{
- m_ProtocolTimer.stop();
- auto *guide = qobject_cast<Guide*>(parentWidget());
- if (guide)
- {
- guide->setAIFreeDrift(false);
- guide->abort();
- disconnect(guide, &Guide::guideStats, this, &OpsAIGuide::slotOnGuideStats);
- }
- restoreSettings();
- m_State = STATE_DONE;
- statusLabel->setText("Protocol Aborted by User");
- appendLog("User requested protocol stop. Idle.");
-}
-
-void OpsAIGuide::done(int result)
-{
- // Called by QWizard/QDialog for every dismissal path — Finish (accept), Cancel/Esc/
- // window-close (reject). Without this the protocol timer keeps ticking on a hidden
- // dialog (still driving the mount) and the guider is left in Free-Drift, silently
- // suppressing every subsequent guide pulse.
- const bool wasRunning = (m_State != STATE_IDLE && m_State != STATE_DONE && m_State != STATE_ERROR);
-
- m_ProtocolTimer.stop();
-
- auto *guide = qobject_cast<Guide *>(parentWidget());
- if (guide)
- {
- guide->setAIFreeDrift(false);
- disconnect(guide, &Guide::guideStats, this, &OpsAIGuide::slotOnGuideStats);
- if (wasRunning)
- guide->abort();
- }
-
- // No-op if settings were never enforced.
- restoreSettings();
-
- QWizard::done(result);
-}
-
-void OpsAIGuide::slotProcessProtocol()
-{
- switch (m_State)
- {
- case STATE_IDLE:
- case STATE_DONE:
- case STATE_ERROR:
- m_ProtocolTimer.stop();
- break;
-
- case STATE_PRECHECK:
- {
- auto *guide = qobject_cast<Guide*>(parentWidget());
- if (!guide || !guide->mount() || !guide->mount()->isConnected())
- {
- appendLog("ERROR: Mount is not connected!");
- m_State = STATE_ERROR;
- break;
- }
- if (guide->mount()->status() == ISD::Mount::MOUNT_PARKED)
- {
- appendLog("Mount is parked. Unparking before scan...");
- guide->mount()->unpark();
- break; // Wait in PRECHECK until unpark is complete
- }
- if (guide->mount()->status() != ISD::Mount::MOUNT_TRACKING && guide->mount()->status() != ISD::Mount::MOUNT_IDLE)
- {
- // Wait until mount is idle or tracking
- break;
- }
- if (m_Phases.isEmpty())
- {
- appendLog("All phases complete!");
- restoreSettings();
- m_State = STATE_DONE;
- progressBar->setValue(100);
- exportOfflineButton->setEnabled(true);
- emit aiGuideComplete();
- this->next();
- break;
- }
-
- // Check if the next phase is a pulse response at the same position
- if (m_Phases.first().pulseResponse)
- {
- m_State = STATE_PULSE_RESPONSE_INIT;
- break;
- }
-
- m_State = STATE_HORIZON_SCAN;
- break;
- }
-
- case STATE_HORIZON_SCAN:
- {
- double targetAlt = m_Phases.first().targetAlt;
- appendLog(QString("Phase started: Scanning Artificial Horizon for safe slew to Alt %1°...").arg(targetAlt));
- statusLabel->setText("Calculating safe slew target...");
-
- // 1. Define target Altitude and starting Azimuth based on Phase offset from local meridian
- double meridianAz = KStarsData::Instance()->geo()->lat()->Degrees() > 0 ? 180.0 : 0.0;
- double targetAzOffset = m_Phases.first().azOffset;
- double targetAz = fmod(meridianAz + targetAzOffset + 360.0, 360.0);
-
- // 2. Scan Azimuth East/West first, and if necessary, bump Altitude up slightly (up to +10°)
- auto* horizon = &KStarsData::Instance()->skyComposite()->artificialHorizon()->getHorizon();
- double clearAz = 0;
- double clearAlt = targetAlt;
- bool foundClear = false;
-
- for (double altOffset = 0; altOffset <= 10.0; altOffset += 5.0)
- {
- for (double azOffset = 0; azOffset <= 30.0; azOffset += 5.0)
- {
- QString reason;
-
- // Try West
- if (horizon->isAltitudeOK(targetAz + azOffset, targetAlt + altOffset, &reason))
- {
- clearAz = targetAz + azOffset;
- clearAlt = targetAlt + altOffset;
- foundClear = true;
- break;
- }
- // Try East
- if (horizon->isAltitudeOK(targetAz - azOffset, targetAlt + altOffset, &reason))
- {
- clearAz = targetAz - azOffset;
- clearAlt = targetAlt + altOffset;
- foundClear = true;
- break;
- }
- }
- if (foundClear) break; // Found a clear patch!
- }
-
- if (!foundClear)
- {
- appendLog("ERROR: Entire meridian is blocked by Artificial Horizon! Cannot proceed.");
- statusLabel->setText("Error: View Blocked");
- m_State = STATE_ERROR;
- }
- else
- {
- if (std::abs(m_TargetAz - clearAz) < 0.1 && std::abs(m_TargetAlt - clearAlt) < 0.1)
- {
- appendLog("Continuing data collection at current position...");
- m_State = STATE_SETTLING;
- m_SettlingTimer = 2; // Minimal settling since we're already here
- }
- else
- {
- m_TargetAz = clearAz;
- m_TargetAlt = clearAlt;
- appendLog(QString("Found safe sky patch: Az %1°, Alt %2°").arg(m_TargetAz).arg(m_TargetAlt));
-
- // Issue Slew Command
- auto *guide = qobject_cast<Guide*>(parentWidget());
- if (!guide || !guide->mount())
- {
- appendLog("ERROR: Mount unavailable, cannot slew.");
- m_State = STATE_ERROR;
- break;
- }
- SkyPoint targetPoint;
- targetPoint.setAz(m_TargetAz);
- targetPoint.setAlt(m_TargetAlt);
- // Convert Az/Alt to Equatorial (RA/Dec) so Mount can slew
- targetPoint.HorizontalToEquatorialNow();
-
- appendLog("Issuing Slew command to mount...");
- guide->mount()->Slew(&targetPoint);
- m_State = STATE_SLEWING;
- }
- }
- break;
- }
-
- case STATE_SLEWING:
- {
- auto *guide = qobject_cast<Guide*>(parentWidget());
- if (!guide || !guide->mount())
- {
- appendLog("ERROR: Mount became unavailable during slew!");
- m_State = STATE_ERROR;
- break;
- }
- if (guide->mount()->status() == ISD::Mount::MOUNT_TRACKING)
- {
- appendLog("Slew complete! Mount is tracking.");
- m_State = STATE_SETTLING;
- m_SettlingTimer = 10; // Settle for 10 seconds
- }
- else if (guide->mount()->status() == ISD::Mount::MOUNT_ERROR)
- {
- appendLog("ERROR: Mount failed to slew!");
- m_State = STATE_ERROR;
- }
- break;
- }
-
- case STATE_SETTLING:
- if (m_SettlingTimer > 0)
- {
- statusLabel->setText(QString("Settling... %1s").arg(m_SettlingTimer));
- m_SettlingTimer--;
- }
- else
- {
- appendLog("Settling complete. Starting phase data collection...");
- ProtocolPhase phase = m_Phases.first();
- m_CaptureTimer = phase.durationSeconds;
- m_AbortRetries = 0;
- m_FreeDriftOverflow = false;
-
- auto *guide = qobject_cast<Guide*>(parentWidget());
- guide->setAIFreeDrift(phase.freeDrift);
-
- // Clear the phase array for this session
- m_PhaseData = QJsonArray();
-
- // Connect to the guideStats signal
- connect(guide, &Guide::guideStats, this, &OpsAIGuide::slotOnGuideStats, Qt::UniqueConnection);
- m_FrameTimer.start();
-
- // Start KStars guiding to trigger camera loop
- if (guide->status() == GUIDE_IDLE || guide->status() == GUIDE_ABORTED)
- {
- guide->guide();
- }
-
- m_State = STATE_CAPTURING_DATA;
- }
- break;
-
- case STATE_CAPTURING_DATA:
- {
- auto *guide = qobject_cast<Guide*>(parentWidget());
-
- // --- Recovery from unexpected abort (star drift / seeing spike) ---
- if (!guide || guide->status() == GUIDE_ABORTED)
- {
- constexpr int MAX_RETRIES = 3;
- if (m_AbortRetries < MAX_RETRIES)
- {
- m_AbortRetries++;
- appendLog(QString("Guider aborted (retry %1/%2). Attempting recovery...")
- .arg(m_AbortRetries).arg(MAX_RETRIES));
- if (guide)
- {
- guide->setAIFreeDrift(m_Phases.first().freeDrift);
- guide->guide();
- m_FrameTimer.start(); // restart so next dt is not huge
- }
- break; // wait for next 1Hz tick to see if guider recovered
- }
- // Retries exhausted — save whatever frames we have and move on
- appendLog(QString("Phase ended early (guide star lost after %1 retries). Saving %2 frames.")
- .arg(MAX_RETRIES).arg(m_PhaseData.size()));
- if (guide)
- {
- guide->setAIFreeDrift(false);
- guide->abort();
- disconnect(guide, &Guide::guideStats, this, &OpsAIGuide::slotOnGuideStats);
- }
- // Fall through to phase-finalisation below by setting timer to 0
- m_CaptureTimer = 0;
- }
-
- if (guide && guide->status() != GUIDE_GUIDING && guide->status() != GUIDE_ABORTED)
- {
- statusLabel->setText("Waiting for Guider to calibrate/start...");
- // Restart frame timer so the first 'dt' isn't huge
- m_FrameTimer.start();
- break; // Pause timer countdown
- }
-
- // --- Free-drift safety: end early if star has drifted too far ---
- const bool phaseTimedOut = (m_CaptureTimer <= 0);
- const bool freeDriftOverflowed = m_FreeDriftOverflow;
-
- if (phaseTimedOut || freeDriftOverflowed)
- {
- if (freeDriftOverflowed)
- appendLog(QString("Free drift ended early to protect guide star (%1 frames saved).")
- .arg(m_PhaseData.size()));
- else
- appendLog("Phase complete. Stopping capture...");
-
- if (guide)
- {
- guide->abort();
- guide->setAIFreeDrift(false);
- disconnect(guide, &Guide::guideStats, this, &OpsAIGuide::slotOnGuideStats);
- }
-
- // Package the phase data into the main JSON
- ProtocolPhase phase = m_Phases.first();
- QJsonObject phaseRecord;
- phaseRecord["session_id"] = QString("phase_alt%1_%2").arg(phase.targetAlt).arg(
- QDateTime::currentDateTime().toString("HHmmss"));
- phaseRecord["type"] = phase.freeDrift ? "free_drift" : "standard_guiding";
- // Record the MEAN achieved altitude over the phase, not the nominal target: the
- // horizon scan can raise the actual slew target by up to +10° and the mount tracks
- // through the multi-minute session. The trainers fit refraction (k_ref/k_ref_dec)
- // against this single session altitude, so the nominal targetAlt biases the fit.
- double meanAlt = m_TargetAlt;
- if (!m_PhaseData.isEmpty())
- {
- double sumAlt = 0.0;
- for (int i = 0; i < m_PhaseData.size(); ++i)
- sumAlt += m_PhaseData.at(i).toObject()["altitude_deg"].toDouble();
- meanAlt = sumAlt / m_PhaseData.size();
- }
- phaseRecord["altitude_deg"] = meanAlt;
- phaseRecord["azimuth_deg"] = m_TargetAz;
- if (guide && guide->mount())
- {
- auto pierSide = guide->mount()->pierSide();
- phaseRecord["pier_side"] = (pierSide == ISD::Mount::PIER_EAST) ? "EAST" : "WEST";
- }
- phaseRecord["duration_s"] = phase.durationSeconds;
- phaseRecord["aggressiveness_ra"] = phase.freeDrift ? 0.0 : Options::rAProportionalGain();
- phaseRecord["aggressiveness_dec"] = phase.freeDrift ? 0.0 : Options::dECProportionalGain();
- phaseRecord["min_pulse_ra_arcsec"] = phase.freeDrift ? 0.0 : Options::rAMinimumPulseArcSec();
- phaseRecord["min_pulse_dec_arcsec"] = phase.freeDrift ? 0.0 : Options::dECMinimumPulseArcSec();
- phaseRecord["max_pulse_ra_arcsec"] = phase.freeDrift ? 0.0 : Options::rAMaximumPulseArcSec();
- phaseRecord["max_pulse_dec_arcsec"] = phase.freeDrift ? 0.0 : Options::dECMaximumPulseArcSec();
-
- if (guide)
- {
- auto *internalGuider = qobject_cast<Ekos::InternalGuider*>(guide->getGuiderInstance());
- if (internalGuider)
- {
- const auto &cal = internalGuider->getCalibration();
- phaseRecord["ra_ms_per_arcsec"] = cal.raPulseMillisecondsPerArcsecond();
- phaseRecord["dec_ms_per_arcsec"] = cal.decPulseMillisecondsPerArcsecond();
- }
- }
-
- phaseRecord["frames"] = m_PhaseData;
-
- QJsonArray sessions = m_SysIdData["sessions"].toArray();
- sessions.append(phaseRecord);
- m_SysIdData["sessions"] = sessions;
-
- // Write out the sysid_data.json
- m_LogFile.setFileName(m_LogFilename);
- if (m_LogFile.open(QIODevice::WriteOnly | QIODevice::Text))
- {
- QJsonDocument doc(m_SysIdData);
- m_LogFile.write(doc.toJson());
- m_LogFile.close();
- }
-
- m_Phases.removeFirst();
- m_State = STATE_PRECHECK; // Loop back for next phase
- }
- else
- {
- statusLabel->setText(QString("Capturing Data... %1s remaining").arg(m_CaptureTimer));
- m_CaptureTimer--;
- }
- break;
- }
-
- // ── Pulse Response States ─────────────────────────────────────────────
- case STATE_PULSE_RESPONSE_INIT:
- {
- auto *guide = qobject_cast<Guide*>(parentWidget());
- if (!guide)
- {
- m_State = STATE_ERROR;
- break;
- }
-
- ProtocolPhase phase = m_Phases.first();
- appendLog(QString("Pulse Response: %1 %2 %3ms — preparing...")
- .arg(phase.pulseAxis, phase.pulseDirection)
- .arg(phase.pulseMagnitudeMs));
-
- // Disable all corrections (enter free drift)
- guide->setAIFreeDrift(true);
-
- m_PulseFrameCount = 0;
- m_PulseResponseData = QJsonArray();
-
- // Connect to stats to record response frames
- connect(guide, &Guide::guideStats, this, &OpsAIGuide::slotOnGuideStats, Qt::UniqueConnection);
-
- // Make sure guiding is running (camera loop)
- if (guide->status() == GUIDE_IDLE || guide->status() == GUIDE_ABORTED)
- guide->guide();
-
- m_FrameTimer.start();
- m_PulseSettleTimer = 5; // 5s initial settle
- m_State = STATE_PULSE_SETTLING;
- break;
- }
-
- case STATE_PULSE_SENDING:
- {
- auto *guide = qobject_cast<Guide*>(parentWidget());
- if (!guide)
- {
- m_State = STATE_ERROR;
- break;
- }
-
- ProtocolPhase phase = m_Phases.first();
-
- // Send the pulse via the guide module's sendSinglePulse API
- guide->setAIFreeDrift(false);
- if (phase.pulseAxis == "RA")
- {
- if (phase.pulseDirection == "EAST")
- guide->sendSinglePulse(RA_INC_DIR, phase.pulseMagnitudeMs, StartCaptureAfterPulses);
- else
- guide->sendSinglePulse(RA_DEC_DIR, phase.pulseMagnitudeMs, StartCaptureAfterPulses);
- }
- else // DEC
- {
- if (phase.pulseDirection == "NORTH")
- guide->sendSinglePulse(DEC_INC_DIR, phase.pulseMagnitudeMs, StartCaptureAfterPulses);
- else
- guide->sendSinglePulse(DEC_DEC_DIR, phase.pulseMagnitudeMs, StartCaptureAfterPulses);
- }
- guide->setAIFreeDrift(true);
-
- appendLog(QString("Sent %1ms %2 %3 pulse. Recording %4 response frames...")
- .arg(phase.pulseMagnitudeMs).arg(phase.pulseAxis).arg(phase.pulseDirection)
- .arg(phase.responseFrames));
-
- m_PulseFrameCount = 0;
- m_PulseResponseData = QJsonArray();
- m_FrameTimer.start();
- // Backstop (1Hz ticks): the pulse deliberately shoves the star, so it may leave the
- // tracking box and abort the guider mid-recording. Bound the wait so we never hang.
- m_PulseWatchdog = phase.responseFrames * 6 + 30;
- m_State = STATE_PULSE_RECORDING;
- break;
- }
-
- case STATE_PULSE_RECORDING:
- {
- ProtocolPhase phase = m_Phases.first();
- auto *guide = qobject_cast<Guide*>(parentWidget());
-
- // The pulse deliberately shoves the star; if it leaves the tracking box the internal
- // guider ABORTS, guideStats stops firing and m_PulseFrameCount freezes. Without this
- // guard the wizard would hang here forever with the mount stuck in Free-Drift. Detect
- // an aborted/gone guider or a stalled recording (watchdog) and finalize gracefully.
- const bool guiderAborted = (!guide || guide->status() == GUIDE_ABORTED);
- if (m_PulseWatchdog > 0)
- m_PulseWatchdog--;
- const bool completed = (m_PulseFrameCount >= phase.responseFrames);
- const bool interrupted = (!completed && (guiderAborted || m_PulseWatchdog <= 0));
-
- statusLabel->setText(QString("Recording pulse response: %1/%2 frames")
- .arg(m_PulseFrameCount).arg(phase.responseFrames));
-
- if (completed || interrupted)
- {
- if (interrupted)
- appendLog(QString("Pulse response interrupted (%1) after %2/%3 frames.")
- .arg(guiderAborted ? "guider aborted — star likely pushed out of frame" : "timed out")
- .arg(m_PulseFrameCount).arg(phase.responseFrames));
- else
- appendLog(QString("Pulse response recorded: %1 frames").arg(m_PulseFrameCount));
-
- // Package the pulse response session into sysid JSON (skip if nothing usable was captured).
- if (m_PulseFrameCount > 0)
- {
- ProtocolPhase p = m_Phases.first();
- QJsonObject pulseSession;
- pulseSession["session_id"] = QString("pulse_response_%1_%2_%3ms_%4")
- .arg(p.pulseAxis.toLower(), p.pulseDirection.toLower())
- .arg(p.pulseMagnitudeMs)
- .arg(QDateTime::currentDateTime().toString("HHmmss"));
- pulseSession["type"] = "pulse_response";
- pulseSession["pulse_axis"] = p.pulseAxis;
- pulseSession["pulse_direction"] = p.pulseDirection;
- pulseSession["pulse_magnitude_ms"] = p.pulseMagnitudeMs;
- // Record the commanded slew altitude (post horizon-scan), consistent with m_TargetAz.
- pulseSession["altitude_deg"] = m_TargetAlt;
- pulseSession["azimuth_deg"] = m_TargetAz;
-
- if (guide && guide->mount())
- {
- auto pierSide = guide->mount()->pierSide();
- pulseSession["pier_side"] = (pierSide == ISD::Mount::PIER_EAST) ? "EAST" : "WEST";
- }
-
- pulseSession["response_frames"] = m_PulseResponseData;
-
- QJsonArray sessions = m_SysIdData["sessions"].toArray();
- sessions.append(pulseSession);
- m_SysIdData["sessions"] = sessions;
-
- // Write out the sysid_data.json incrementally
- m_LogFile.setFileName(m_LogFilename);
- if (m_LogFile.open(QIODevice::WriteOnly | QIODevice::Text))
- {
- QJsonDocument doc(m_SysIdData);
- m_LogFile.write(doc.toJson());
- m_LogFile.close();
- }
- }
-
- if (interrupted)
- {
- // Guider is aborted/stalled: skip the post-pulse settle (it would just wait out
- // the timer with no star) and advance directly to the next phase.
- if (guide)
- {
- guide->setAIFreeDrift(false);
- disconnect(guide, &Guide::guideStats, this, &OpsAIGuide::slotOnGuideStats);
- }
- m_Phases.removeFirst();
- m_State = STATE_PRECHECK;
- }
- else
- {
- // Post-pulse settle before next pulse
- m_PulseSettleTimer = phase.settleSeconds;
- m_State = STATE_PULSE_SETTLING;
- }
- }
- break;
- }
-
- case STATE_PULSE_SETTLING:
- {
- if (m_PulseSettleTimer > 0)
- {
- statusLabel->setText(QString("Pulse settling... %1s").arg(m_PulseSettleTimer));
- m_PulseSettleTimer--;
- }
- else
- {
- // Check if this was the initial settle (before sending pulse)
- // or the post-recording settle (after recording)
- if (m_PulseFrameCount == 0 && m_PulseResponseData.isEmpty())
- {
- // Initial settle complete → send the pulse
- m_State = STATE_PULSE_SENDING;
- }
- else
- {
- // Post-recording settle complete → advance to next phase
- auto *guide = qobject_cast<Guide*>(parentWidget());
- if (guide)
- {
- guide->setAIFreeDrift(false);
- disconnect(guide, &Guide::guideStats, this, &OpsAIGuide::slotOnGuideStats);
- }
- m_Phases.removeFirst();
- m_State = STATE_PRECHECK;
- }
- }
- break;
- }
-
- default:
- break;
- } // end switch
-
- // Update Progress Bar
- if (m_TotalPhases > 0 && m_State != STATE_DONE && m_State != STATE_ERROR && m_State != STATE_IDLE)
- {
- int completed = m_TotalPhases - m_Phases.size();
- double currentProgress = 0.0;
-
- if (!m_Phases.isEmpty() && m_Phases.first().durationSeconds > 0)
- {
- if (m_State == STATE_CAPTURING_DATA)
- {
- currentProgress = 1.0 - (static_cast<double>(m_CaptureTimer) / m_Phases.first().durationSeconds);
- }
- else if (m_State == STATE_HORIZON_SCAN || m_State == STATE_SLEWING || m_State == STATE_SETTLING)
- {
- currentProgress = 0.1; // Baseline for transition states
- }
- }
-
- int progressValue = static_cast<int>(((completed + currentProgress) / m_TotalPhases) * 100);
- progressBar->setValue(std::max(0, std::min(99, progressValue)));
- emit aiGuideProgress(completed, m_TotalPhases, statusLabel->text());
- }
-}
-
-void OpsAIGuide::slotOnGuideStats(double raErr, double decErr, int raPulse, int decPulse, double snr, double skyBg,
- int numStars)
-{
- Q_UNUSED(skyBg)
- Q_UNUSED(numStars)
-
- if (m_State == STATE_CAPTURING_DATA)
- {
- double dt = m_FrameTimer.isValid() ? (m_FrameTimer.restart() / 1000.0) : 0.0;
-
- // --- Free-drift overflow guard ---
- // If the guide star has drifted more than 25 arcsec from centre during free drift,
- // flag the overflow so STATE_CAPTURING_DATA ends the phase before the star is lost.
- if (m_Phases.first().freeDrift && !m_FreeDriftOverflow)
- {
- constexpr double FREE_DRIFT_LIMIT_ARCSEC = 25.0;
- if (std::abs(raErr) > FREE_DRIFT_LIMIT_ARCSEC || std::abs(decErr) > FREE_DRIFT_LIMIT_ARCSEC)
- {
- appendLog(QString("Free drift limit reached (RA=%1\" DEC=%2\"). Ending phase early to protect star.")
- .arg(raErr, 0, 'f', 1).arg(decErr, 0, 'f', 1));
- m_FreeDriftOverflow = true;
- return; // don't record this out-of-bounds frame
- }
- }
-
- double dx = raErr;
- double dy = decErr;
- auto *guide = qobject_cast<Guide *>(parentWidget());
- if (guide && guide->pixelSizeX() > 0 && guide->focalLength() > 0)
- {
- // Note: Options::guideBinning() is "1x1", "2x2", etc. We extract the first char to get bin factor.
- double binning = std::max(1, Options::guideBinning().left(1).toInt());
- double scale = (206.265 * guide->pixelSizeX() * binning) / guide->focalLength();
- dx = -raErr / scale; // raErr is negated delta in gmath
- dy = decErr / scale;
- }
-
- // Fetch current mount altitude, azimuth and DEC
- double alt_deg = 45.0, az_deg = 180.0, dec_deg = 0.0;
- if (guide && guide->mount())
- {
- SkyPoint currentPos = guide->mount()->currentCoordinates();
- az_deg = currentPos.az().Degrees();
- alt_deg = currentPos.alt().Degrees();
- dec_deg = currentPos.dec().Degrees();
- }
-
- // Fetch Latitude
- double lat_deg = 45.0;
- if (KStarsData::Instance() && KStarsData::Instance()->geo() && KStarsData::Instance()->geo()->lat())
- {
- lat_deg = KStarsData::Instance()->geo()->lat()->Degrees();
- }
-
- // Calculate Parallactic Angle (q)
- double parallactic_angle_deg = 0.0;
- double sin_az = std::sin(az_deg * M_PI / 180.0);
- double cos_lat = std::cos(lat_deg * M_PI / 180.0);
- double cos_dec = std::cos(dec_deg * M_PI / 180.0);
-
- if (std::abs(cos_dec) > 1e-6)
- {
- double sin_q = (sin_az * cos_lat) / cos_dec;
- sin_q = std::clamp(sin_q, -1.0, 1.0);
- parallactic_angle_deg = std::asin(sin_q) * 180.0 / M_PI;
- }
-
- QJsonObject frame;
- frame["timestamp"] = QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm:ss.zzz");
- frame["altitude_deg"] = alt_deg;
- frame["azimuth_deg"] = az_deg;
- frame["parallactic_angle_deg"] = parallactic_angle_deg;
- frame["ra_raw_px"] = dx;
- frame["dec_raw_px"] = dy;
- frame["snr"] = snr;
- frame["dt"] = dt;
- frame["error_code"] = (snr == 0.0) ? FRAME_STAR_LOST : FRAME_OK;
- frame["ra_pulse_ms"] = raPulse;
- frame["dec_pulse_ms"] = decPulse;
- m_PhaseData.append(frame);
- }
-
- // ── Pulse response frame recording ───────────────────────────────────
- if (m_State == STATE_PULSE_RECORDING)
- {
- double dt = m_FrameTimer.isValid() ? (m_FrameTimer.restart() / 1000.0) : 0.0;
-
- double dx = raErr;
- double dy = decErr;
- auto *guide = qobject_cast<Guide *>(parentWidget());
- if (guide && guide->pixelSizeX() > 0 && guide->focalLength() > 0)
- {
- double binning = std::max(1, Options::guideBinning().left(1).toInt());
- double scale = (206.265 * guide->pixelSizeX() * binning) / guide->focalLength();
- dx = -raErr / scale;
- dy = decErr / scale;
- }
-
- QJsonObject frame;
- frame["t"] = m_PulseFrameCount * dt;
- frame["ra_raw_px"] = dx;
- frame["dec_raw_px"] = dy;
- frame["snr"] = snr;
- frame["dt"] = dt;
- m_PulseResponseData.append(frame);
- m_PulseFrameCount++;
- }
-}
-
-}
diff --git a/kstars/ekos/guide/opsaiguide.h b/kstars/ekos/guide/opsaiguide.h
deleted file mode 100644
index f56e1a346c..0000000000
--- a/kstars/ekos/guide/opsaiguide.h
+++ /dev/null
@@ -1,159 +0,0 @@
-/*
- SPDX-FileCopyrightText: 2026 Pavan <[email protected]>
-
- SPDX-License-Identifier: GPL-2.0-or-later
-*/
-
-#pragma once
-
-#include "ui_opsaiguide.h"
-#include <QWizard>
-#include <QTimer>
-#include <QFile>
-#include <QJsonObject>
-#include <QJsonArray>
-#include <QJsonDocument>
-#include <QElapsedTimer>
-
-namespace Ekos
-{
-
-class OpsAIGuide : public QWizard, public Ui::OpsAIGuide
-{
- Q_OBJECT
-
- public:
- explicit OpsAIGuide(QWidget *parent = nullptr);
- virtual ~OpsAIGuide() override = default;
-
- enum ProtocolState
- {
- STATE_IDLE,
- STATE_PRECHECK,
- STATE_HORIZON_SCAN,
- STATE_SLEWING,
- STATE_SETTLING,
- STATE_CAPTURING_DATA,
- STATE_PULSE_RESPONSE_INIT,
- STATE_PULSE_SENDING,
- STATE_PULSE_RECORDING,
- STATE_PULSE_SETTLING,
- STATE_DONE,
- STATE_ERROR
- };
- Q_ENUM(ProtocolState)
-
- enum FrameErrorCode
- {
- FRAME_OK = 0,
- FRAME_STAR_LOST = 1
- };
-
- // Exposed to EkosLive (ekoslive/message.cpp reads these by name via property()).
- Q_PROPERTY(QString mountType READ mountType)
- Q_PROPERTY(ProtocolState state READ state)
- Q_PROPERTY(int totalPhases READ totalPhases)
- Q_PROPERTY(int phasesRemaining READ phasesRemaining)
-
- QString mountType() const
- {
- return mountTypeCombo->currentText();
- }
- ProtocolState state() const
- {
- return m_State;
- }
- int totalPhases() const
- {
- return m_TotalPhases;
- }
- int phasesRemaining() const
- {
- return m_Phases.size();
- }
-
- Q_SIGNALS:
- void aiGuideProgress(int current, int total, const QString &status);
- void aiGuideLog(const QString &message);
- void aiGuideComplete();
- // Emitted when the user clicks "Train in EkosLive"; carries the collected sysid data up to Guide.
- void trainInEkosLiveRequested(const QJsonObject &sysidData);
-
- protected:
- void initializePage(int id) override;
- // Ensure the guider is never left in Free-Drift or with enforced settings,
- // no matter how the wizard is dismissed (Finish, Cancel, window close, Esc).
- void done(int result) override;
-
- public slots:
- // Exposed to EkosLive / DBus so the training protocol can be started/stopped remotely.
- Q_INVOKABLE void slotStartProtocol();
- Q_INVOKABLE void slotStopProtocol();
-
- // Called by Guide with the EkosLive cloud training result to update the wizard UI.
- void onTrainingResult(bool success, const QJsonObject &result);
-
- private slots:
- void slotProcessProtocol();
- void slotOnGuideStats(double raErr, double decErr, int raPulse, int decPulse, double snr, double skyBg, int numStars);
- void slotExportOffline();
- void slotExportLogs();
-
- private:
- void appendLog(const QString &message);
- void enforceSettings();
- void restoreSettings();
-
- int m_TotalPhases { 0 };
-
- struct ProtocolPhase
- {
- double targetAlt;
- double azOffset; // -20.0 for East of Meridian (Pier West), +20.0 for West of Meridian (Pier East)
- int durationSeconds;
- bool freeDrift;
- // Pulse response fields (used only for HARMONIC_DRIVE)
- bool pulseResponse {false};
- QString pulseAxis; // "RA" or "DEC"
- QString pulseDirection; // "EAST", "WEST", "NORTH", "SOUTH"
- int pulseMagnitudeMs {0};
- int responseFrames {15};
- int settleSeconds {30};
- };
- QList<ProtocolPhase> m_Phases;
-
- ProtocolState m_State { STATE_IDLE };
- QTimer m_ProtocolTimer;
- double m_TargetAz { 0 };
- double m_TargetAlt { 0 };
- int m_SettlingTimer { 0 };
- int m_CaptureTimer { 0 };
- int m_AbortRetries { 0 }; ///< number of recovery attempts in current phase
- bool m_FreeDriftOverflow { false }; ///< set when free-drift accumulated pos exceeds safety limit
-
- QFile m_LogFile;
- QString m_LogFilename;
- QJsonObject m_SysIdData;
- QJsonArray m_PhaseData;
-
- QElapsedTimer m_FrameTimer;
-
- bool m_SettingsEnforced { false }; ///< true once originals are saved & standard mode forced
- int m_OrigRAAlgorithm { 0 };
- int m_OrigDECAlgorithm { 0 };
- bool m_OrigRAEnabled { true };
- bool m_OrigDECEnabled { true };
- bool m_OrigEastEnabled { true };
- bool m_OrigWestEnabled { true };
- bool m_OrigNorthEnabled { true };
- bool m_OrigSouthEnabled { true };
- double m_OrigMaxDeltaRMS { 2.0 };
-
- // Pulse response tracking (harmonic drive)
- int m_PulseFrameCount { 0 };
- int m_PulseSettleTimer { 0 };
- int m_PulseWatchdog { 0 }; ///< 1Hz backstop so pulse recording can't hang if the star is lost
- QJsonArray m_PulseResponseData;
-};
-
-}
diff --git a/kstars/ekos/manager.cpp b/kstars/ekos/manager.cpp
index eae46f37a9..c017a483be 100644
--- a/kstars/ekos/manager.cpp
+++ b/kstars/ekos/manager.cpp
@@ -2849,6 +2849,22 @@ void Manager::initGuide()
toolsWidget->setTabIcon(index, icon);
}
guideManager->init(guideModule());
+
+ // Wire AI Guider progress/log/complete signals
+ connect(guideModule(), &Ekos::Guide::newAIGuideProgress, ekosLiveClient.get()->message(),
+ &EkosLive::Message::sendAIGuideProgress);
+ connect(guideModule(), &Ekos::Guide::newAIGuideLog, ekosLiveClient.get()->message(),
+ &EkosLive::Message::sendAIGuideLog);
+ connect(guideModule(), &Ekos::Guide::newAIGuideComplete, ekosLiveClient.get()->message(),
+ &EkosLive::Message::sendAIGuideComplete);
+
+ // Wire AI Guider training signals
+ connect(guideModule(), &Ekos::Guide::newAIGuideTrainingProgress, ekosLiveClient.get()->message(),
+ &EkosLive::Message::sendAIGuideTrainingProgress);
+ connect(guideModule(), &Ekos::Guide::newAIGuideTrainingComplete, ekosLiveClient.get()->message(),
+ &EkosLive::Message::sendAIGuideTrainingComplete);
+ connect(guideModule(), &Ekos::Guide::newAIGuideTrainingError, ekosLiveClient.get()->message(),
+ &EkosLive::Message::sendAIGuideTrainingError);
}
connectModules();