[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();
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.