[education/kstars] /: AI Guide: run PID Auto-Tune first, add live gain-lock, free-drift recenter
Jasem Mutlaq <[email protected]>
| Newsgroups | gmane.comp.kde.cvs |
|---|---|
| Message-ID | <[email protected]> |
Git commit 0e1853a5407cdd1558743efd73b5ef28993fc4f7 by Jasem Mutlaq. Committed on 04/08/2026 at 02:59. Pushed by mutlaqja into branch 'master'. AI Guide: run PID Auto-Tune first, add live gain-lock, free-drift recenter - aiguideprotocol.cpp/.h: PID Auto-Tune (step-response gain calibration, formerly "Pulse Response Test") now runs first in the protocol for every mount type, before any other system-ID data collection -- both for fingerprint validity (changing the gain after data is collected under a different gain invalidates it) and PE-detection data quality (the standard-guiding phase's PE reconstruction assumes a well-tuned controller). Adds a live C++ gain-lock (applyPIDAutoTuneGainLock() / computeAndApplyAxisGain()), a simplified plateau-average port of offline_trainer/pid_autotune.py's calculation, applied automatically once the PID Auto-Tune pulses are exhausted. - Fixes two bugs found while validating the reordering: (1) the mount never slewed before firing pulses, since pulse-response inherited a "no new slew needed" shortcut only safe when it ran after Position 1 had already been slewed to -- pulse-response phases now go through the same slew/settle path as every other phase; (2) pulses fired before guide calibration had finished, recording all-zero data -- STATE_PULSE_RESPONSE_INIT now polls guide status and waits for GUIDE_GUIDING before firing. - Adds STATE_DRIFT_RECENTER: free-drift segments now re-center once the star drifts past a threshold rather than running the drift off-frame, so a single long free-drift phase can span multiple bounded segments. - gmath.cpp/.h: adds the AIProportionalBackoff mechanism (scales down the classical proportional gain by up to 50% in proportion to AI confidence, on top of the additive feed-forward term) and related AI debug-log fields. - guide.cpp/.h, aiguidewizard.ui, opsaiconfig.ui, kstars.kcfg, org.kde.kstars.Ekos.Guide.xml: supporting options/UI/schema plumbing for the above (AIPIDAutoTune, AIProportionalBackoff options, wizard exposure recommendation panel). - .gitignore: ignore __pycache__/ and .claude (Python bytecode from the offline trainer scripts, and this session's worktree metadata). Build verified: full `ninja all` via distcc completes cleanly with this change in place. Co-Authored-By: Claude Sonnet 5 <[email protected]> M +4 -0 .gitignore M +654 -124 kstars/ekos/guide/aiguideprotocol.cpp M +34 -0 kstars/ekos/guide/aiguideprotocol.h M +36 -0 kstars/ekos/guide/aiguidewizard.cpp M +1 -0 kstars/ekos/guide/aiguidewizard.h M +3 -3 kstars/ekos/guide/aiguidewizard.ui M +12 -0 kstars/ekos/guide/guide.cpp M +19 -0 kstars/ekos/guide/guide.h M +131 -11 kstars/ekos/guide/internalguide/gmath.cpp M +30 -0 kstars/ekos/guide/internalguide/gmath.h M +6 -0 kstars/ekos/guide/internalguide/internalguider.h M +3 -3 kstars/ekos/guide/opsaiconfig.ui M +4 -4 kstars/kstars.kcfg M +4 -1 kstars/org.kde.kstars.Ekos.Guide.xml https://invent.kde.org/education/kstars/-/commit/0e1853a5407cdd1558743efd73b5ef28993fc4f7 diff --git a/.gitignore b/.gitignore index 2b795a5324..0588181b56 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ # Compiled Dynamic libraries *.so +__pycache__/ + +# Claude +.claude # Sentry .sentryclirc diff --git a/kstars/ekos/guide/aiguideprotocol.cpp b/kstars/ekos/guide/aiguideprotocol.cpp index 4d9e0990bc..fb1015ed88 100644 --- a/kstars/ekos/guide/aiguideprotocol.cpp +++ b/kstars/ekos/guide/aiguideprotocol.cpp @@ -18,11 +18,23 @@ #include <QDateTime> #include <QDebug> #include <QDir> +#include <QMap> +#include <QSet> +#include <QVector> #include <algorithm> +#include <cmath> namespace Ekos { +// Free drift ends a segment when the star reaches this offset; the guider then re-centers +// and the drift resumes, so the star never wanders further than this. +static constexpr double FREE_DRIFT_LIMIT_ARCSEC = 25.0; +static constexpr double RECENTER_DONE_ARCSEC = 3.0; +static constexpr int RECENTER_TIMEOUT_S = 90; +static constexpr int MAX_RECENTER_ATTEMPTS = 6; +static constexpr int MIN_SEGMENT_FRAMES = 30; + AIGuideProtocol::AIGuideProtocol(Guide *guide) : QObject(guide), m_Guide(guide) { connect(&m_ProtocolTimer, &QTimer::timeout, this, &AIGuideProtocol::processProtocol); @@ -188,6 +200,7 @@ void AIGuideProtocol::start(const QString &mountType) m_SettingsChangedWarned = false; m_BestDriftNoiseFrames = 0; + m_GainLocked = false; m_SysIdData["model_fingerprint"] = buildFingerprint(); m_SysIdData["sessions"] = QJsonArray(); @@ -200,6 +213,30 @@ void AIGuideProtocol::start(const QString &mountType) if (mountStr == "Worm Gear") { + // PID Auto-Tune: step-response system-ID used to recommend a base RA/DEC + // guiding gain on any mount class (pid_autotune_plan.md §7-8) -- worm-gear + // backlash on DEC direction reversal should show up directly as dead time (L) + // in the fitted model. Runs FIRST, at Position 1's sky location, before any + // other phase: applyPIDAutoTuneGainLock() (called from STATE_PRECHECK once + // these pulses are exhausted) computes K/L/tau from them and locks + // Options::rA/dECProportionalGain() (+ integral gain) before the long + // standard-guiding phase runs under it -- see pid_autotune_plan.md §7 for why + // this must happen before, not after, the rest of the protocol. On by default. + if (Options::aIPIDAutoTune()) + { + for (int rep = 0; rep < 3; rep++) + { + m_Phases.append({65.0, -45.0, 0, false, true, "RA", "EAST", 500, 12, 10}); + m_Phases.append({65.0, -45.0, 0, false, true, "RA", "WEST", 500, 12, 10}); + m_Phases.append({65.0, -45.0, 0, false, true, "RA", "EAST", 1000, 12, 10}); + m_Phases.append({65.0, -45.0, 0, false, true, "RA", "WEST", 1000, 12, 10}); + m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "NORTH", 500, 12, 10}); + m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "SOUTH", 500, 12, 10}); + m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "NORTH", 1000, 12, 10}); + m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "SOUTH", 1000, 12, 10}); + } + } + 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}); @@ -209,34 +246,74 @@ void AIGuideProtocol::start(const QString &mountType) } else if (mountStr == "Harmonic Drive") { - // Position 1: 1800s standard guiding (resolves PE to 900s — strain-wave - // fundamentals live at sidereal/ratio, 288-865s on rigs measured so far) and 480 free drift - m_Phases.append({65.0, -45.0, 1800, false, false, {}, {}, 0, 15, 20}); - m_Phases.append({65.0, -45.0, 480, true, false, {}, {}, 0, 15, 20}); - - // Pulse response: large pulses, alternating direction so drift/PE cancel in pairing. - // Off by default: the spring response has not been measurable on rigs tested so far. - if (Options::aIProtocolPulseTest()) + // PID Auto-Tune: large pulses, alternating direction so drift/PE cancel in + // pairing. Runs FIRST, before the long standard-guiding phase below -- + // applyPIDAutoTuneGainLock() (called from STATE_PRECHECK once these pulses + // are exhausted) computes K/L/tau from them and locks + // Options::rA/dECProportionalGain() (+ integral gain) before anything else + // runs under it. This matters for two reasons (pid_autotune_plan.md §7): + // (1) fingerprint locking -- changing the gain mid- or post-protocol would + // invalidate all the sysid data already collected under the old gain; (2) + // data quality -- the long PE-detection phase's reconstruction of the + // "uncorrected" trajectory assumes a well-behaved, non-oscillating + // correction, so it needs to run under an already-good gain, not whatever + // was last manually set. The harmonic-drive elastic/spring (kappa/tau) fit + // that used to also consume this same pulse data is commented out in + // train_harmonic.py -- it has never resolved above the noise floor on any + // rig tested so far (pid_autotune_plan.md §9.1); flagged there for future + // exploration rather than run unconditionally every time. On by default; + // no new slew (runs at Position 1's sky location). + if (Options::aIPIDAutoTune()) { for (int rep = 0; rep < 3; rep++) { - m_Phases.append({65.0, -45.0, 0, false, true, "RA", "EAST", 500, 15, 20}); - m_Phases.append({65.0, -45.0, 0, false, true, "RA", "WEST", 500, 15, 20}); - m_Phases.append({65.0, -45.0, 0, false, true, "RA", "EAST", 1000, 15, 20}); - m_Phases.append({65.0, -45.0, 0, false, true, "RA", "WEST", 1000, 15, 20}); - m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "NORTH", 500, 15, 20}); - m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "SOUTH", 500, 15, 20}); - m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "NORTH", 1000, 15, 20}); - m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "SOUTH", 1000, 15, 20}); + m_Phases.append({65.0, -45.0, 0, false, true, "RA", "EAST", 500, 12, 10}); + m_Phases.append({65.0, -45.0, 0, false, true, "RA", "WEST", 500, 12, 10}); + m_Phases.append({65.0, -45.0, 0, false, true, "RA", "EAST", 1000, 12, 10}); + m_Phases.append({65.0, -45.0, 0, false, true, "RA", "WEST", 1000, 12, 10}); + m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "NORTH", 500, 12, 10}); + m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "SOUTH", 500, 12, 10}); + m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "NORTH", 1000, 12, 10}); + m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "SOUTH", 1000, 12, 10}); } } + // Position 1: 1800s standard guiding (resolves PE to 900s — strain-wave + // fundamentals live at sidereal/ratio, 288-865s on rigs measured so far) and 480 free drift. + // Now runs under the gain locked above, not whatever was last manually set. + m_Phases.append({65.0, -45.0, 1800, false, false, {}, {}, 0, 15, 20}); + m_Phases.append({65.0, -45.0, 480, true, false, {}, {}, 0, 15, 20}); + // Position 2 east of the meridian: parallactic spread for the DEC refraction fit m_Phases.append({45.0, 45.0, 120, true, false, {}, {}, 0, 15, 20}); m_Phases.append({45.0, 45.0, 300, false, false, {}, {}, 0, 15, 20}); } else { + // PID Auto-Tune: step-response system-ID used to recommend a base RA/DEC + // guiding gain on any mount class (pid_autotune_plan.md §7-8) -- direct-drive + // motors are expected to show a small, near-negligible tau/dead-time; a + // confidently-small result is itself a useful finding, not just a null one. + // Runs FIRST, at Position 1's sky location, before any other phase -- + // applyPIDAutoTuneGainLock() (called from STATE_PRECHECK once these pulses + // are exhausted) computes K/L/tau from them and locks + // Options::rA/dECProportionalGain() (+ integral gain) before the rest of the + // protocol runs under it. On by default. + if (Options::aIPIDAutoTune()) + { + for (int rep = 0; rep < 3; rep++) + { + m_Phases.append({70.0, 0.0, 0, false, true, "RA", "EAST", 500, 12, 10}); + m_Phases.append({70.0, 0.0, 0, false, true, "RA", "WEST", 500, 12, 10}); + m_Phases.append({70.0, 0.0, 0, false, true, "RA", "EAST", 1000, 12, 10}); + m_Phases.append({70.0, 0.0, 0, false, true, "RA", "WEST", 1000, 12, 10}); + m_Phases.append({70.0, 0.0, 0, false, true, "DEC", "NORTH", 500, 12, 10}); + m_Phases.append({70.0, 0.0, 0, false, true, "DEC", "SOUTH", 500, 12, 10}); + m_Phases.append({70.0, 0.0, 0, false, true, "DEC", "NORTH", 1000, 12, 10}); + m_Phases.append({70.0, 0.0, 0, false, true, "DEC", "SOUTH", 1000, 12, 10}); + } + } + 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}); @@ -321,6 +398,83 @@ void AIGuideProtocol::stop() emit protocolStopped(); } +// Writes one captured segment as a session and flushes the log. recordedDuration is what +// the segment was meant to run for: the trainer discards drift sessions much shorter than it. +void AIGuideProtocol::flushPhaseSegment(const ProtocolPhase &phase, int recordedDuration) +{ + 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"] = recordedDuration; + 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(); + } + } + + if (m_NoiseStats.count() > 30) + { + const double hf = m_NoiseStats.sigma(); + phaseRecord["hf_motion_arcsec"] = hf; + // The clean value is the LONGEST free drift; guided phases only serve + // as a fallback when no drift completed, and are never shown. + if (phase.freeDrift && m_NoiseStats.count() > m_BestDriftNoiseFrames) + { + m_BestDriftNoiseFrames = m_NoiseStats.count(); + m_SysIdData["noise_floor_arcsec"] = hf; + emit protocolLog(QString("Phase noise floor: %1\" HF star motion " + "(unguided — clean measurement).").arg(hf, 0, 'f', 2)); + } + else if (m_BestDriftNoiseFrames == 0 && !m_SysIdData.contains("noise_floor_arcsec")) + m_SysIdData["noise_floor_arcsec"] = hf; + } + + phaseRecord["frames"] = m_PhaseData; + + QJsonArray sessions = m_SysIdData["sessions"].toArray(); + sessions.append(phaseRecord); + m_SysIdData["sessions"] = sessions; + + refreshFingerprint(); + 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(); + } + +} + void AIGuideProtocol::processProtocol() { switch (m_State) @@ -369,12 +523,21 @@ void AIGuideProtocol::processProtocol() break; } - if (m_Phases.first().pulseResponse) - { - m_State = STATE_PULSE_RESPONSE_INIT; - break; - } - + // First non-pulse-response phase reached: the PID Auto-Tune pulses (if any + // were collected -- see the mount-type branches in start()) are all in by + // now. Lock the base gain from them, once, before anything else runs -- + // pid_autotune_plan.md §7. A no-op (leaves the current gain untouched) if + // the option was off or the data wasn't usable. + if (!m_GainLocked && !m_Phases.first().pulseResponse) + applyPIDAutoTuneGainLock(); + + // Pulse-response phases go through the same horizon-scan/slew/settle path as + // every other phase (see STATE_HORIZON_SCAN, STATE_SETTLING below) -- they are + // NOT special-cased into skipping the slew. That used to be safe when the pulse + // test ran after Position 1's standard-guiding/free-drift phases had already + // slewed there; now that it runs first (pid_autotune_plan.md §7), skipping the + // slew would fire calibration pulses wherever the mount happened to be at + // startup (parked / pointed at the pole), which is exactly wrong for this test. m_State = STATE_HORIZON_SCAN; break; } @@ -486,11 +649,22 @@ void AIGuideProtocol::processProtocol() } else { - emit protocolLog("Settling complete. Starting phase data collection..."); ProtocolPhase phase = m_Phases.first(); + + if (phase.pulseResponse) + { + emit protocolLog("Settling complete. Starting PID Auto-Tune pulse-response test..."); + m_State = STATE_PULSE_RESPONSE_INIT; + break; + } + + emit protocolLog("Settling complete. Starting phase data collection..."); m_CaptureTimer = phase.durationSeconds; m_AbortRetries = 0; m_FreeDriftOverflow = false; + m_SegmentSeconds = 0; + m_PhaseAborted = false; + m_RecenterAttempts = 0; m_NoiseHPF.configure(1.0, m_Guide ? m_Guide->exposure() : 1.0); m_NoiseStats.reset(); @@ -530,6 +704,7 @@ void AIGuideProtocol::processProtocol() } emit protocolLog(QString("Phase ended early (guide star lost after %1 retries). Saving %2 frames.") .arg(MAX_RETRIES).arg(m_PhaseData.size())); + m_PhaseAborted = true; if (m_Guide) { m_Guide->setAIFreeDrift(false); @@ -549,6 +724,27 @@ void AIGuideProtocol::processProtocol() const bool phaseTimedOut = (m_CaptureTimer <= 0); const bool freeDriftOverflowed = m_FreeDriftOverflow; + // The star reached the safety limit but the phase still has time: bank this + // segment, let the guider pull the star back, then resume the drift. + if (freeDriftOverflowed && !phaseTimedOut && m_Phases.first().freeDrift + && m_RecenterAttempts < MAX_RECENTER_ATTEMPTS) + { + m_RecenterAttempts++; + emit protocolLog(QString("Free drift reached the %1\" limit after %2s. Re-centering the star " + "to continue (%3s of drift remaining).") + .arg(FREE_DRIFT_LIMIT_ARCSEC, 0, 'f', 0).arg(m_SegmentSeconds).arg(m_CaptureTimer)); + // Too few frames to fit anything — drop rather than record a junk session + if (m_PhaseData.size() >= MIN_SEGMENT_FRAMES) + flushPhaseSegment(m_Phases.first(), m_SegmentSeconds); + else + emit protocolLog(QString("Segment too short (%1 frames) — discarded.").arg(m_PhaseData.size())); + if (m_Guide) + m_Guide->setAIFreeDrift(false); // pulses resume: the loop re-centers + m_RecenterTimer = RECENTER_TIMEOUT_S; + m_State = STATE_DRIFT_RECENTER; + break; + } + if (phaseTimedOut || freeDriftOverflowed) { if (freeDriftOverflowed) @@ -565,86 +761,89 @@ void AIGuideProtocol::processProtocol() } 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"; + // Segments cut short by star loss keep the full requested duration so the + // trainer's truncation guard can still discard them. + const int recordedDuration = (phase.freeDrift && !m_PhaseAborted) + ? m_SegmentSeconds : phase.durationSeconds; + flushPhaseSegment(phase, recordedDuration); - 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(); + 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--; + m_SegmentSeconds++; + } + break; + } - 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(); - } - } + case STATE_DRIFT_RECENTER: + { + const bool centered = std::abs(m_LastRAErrArcsec) < RECENTER_DONE_ARCSEC + && std::abs(m_LastDECErrArcsec) < RECENTER_DONE_ARCSEC; + const bool starLost = (!m_Guide || m_Guide->status() == GUIDE_ABORTED); - if (m_NoiseStats.count() > 30) + if (starLost) + { + emit protocolLog("Lost the star while re-centering. Ending the drift phase."); + if (m_Guide) { - const double hf = m_NoiseStats.sigma(); - phaseRecord["hf_motion_arcsec"] = hf; - // The clean value is the LONGEST free drift; guided phases only serve - // as a fallback when no drift completed, and are never shown. - if (phase.freeDrift && m_NoiseStats.count() > m_BestDriftNoiseFrames) - { - m_BestDriftNoiseFrames = m_NoiseStats.count(); - m_SysIdData["noise_floor_arcsec"] = hf; - emit protocolLog(QString("Phase noise floor: %1\" HF star motion " - "(unguided — clean measurement).").arg(hf, 0, 'f', 2)); - } - else if (m_BestDriftNoiseFrames == 0 && !m_SysIdData.contains("noise_floor_arcsec")) - m_SysIdData["noise_floor_arcsec"] = hf; + m_Guide->abort(); + m_Guide->setAIFreeDrift(false); + disconnect(m_Guide, &Guide::guideStats, this, &AIGuideProtocol::onGuideStats); } + m_PhaseData = QJsonArray(); + m_Phases.removeFirst(); + m_State = STATE_PRECHECK; + break; + } - phaseRecord["frames"] = m_PhaseData; - - QJsonArray sessions = m_SysIdData["sessions"].toArray(); - sessions.append(phaseRecord); - m_SysIdData["sessions"] = sessions; + const bool insideFence = std::abs(m_LastRAErrArcsec) < FREE_DRIFT_LIMIT_ARCSEC + && std::abs(m_LastDECErrArcsec) < FREE_DRIFT_LIMIT_ARCSEC; - refreshFingerprint(); - m_LogFile.setFileName(m_LogFilename); - if (m_LogFile.open(QIODevice::WriteOnly | QIODevice::Text)) + // Timed out and still at the fence: the loop cannot recover the star, so + // resuming would just trip the limit again. End the phase with what we have. + if (m_RecenterTimer <= 0 && !insideFence) + { + emit protocolLog(QString("Re-centering failed (star still %1\" off). Ending the drift phase.") + .arg(std::max(std::abs(m_LastRAErrArcsec), std::abs(m_LastDECErrArcsec)), 0, 'f', 1)); + if (m_Guide) { - QJsonDocument doc(m_SysIdData); - m_LogFile.write(doc.toJson()); - m_LogFile.close(); + m_Guide->abort(); + m_Guide->setAIFreeDrift(false); + disconnect(m_Guide, &Guide::guideStats, this, &AIGuideProtocol::onGuideStats); } - + m_PhaseData = QJsonArray(); m_Phases.removeFirst(); m_State = STATE_PRECHECK; + break; } - else + + if (centered || m_RecenterTimer <= 0) { - emit protocolProgress(m_TotalPhases - m_Phases.size(), m_TotalPhases, - QString("Capturing Data... %1s remaining").arg(m_CaptureTimer)); - m_CaptureTimer--; + if (!centered) + emit protocolLog("Re-centering timed out; resuming drift from the current position."); + emit protocolLog(QString("Star re-centered. Resuming free drift (%1s remaining).").arg(m_CaptureTimer)); + + m_PhaseData = QJsonArray(); + m_NoiseStats.reset(); + m_NoiseFrameCount = 0; + m_FreeDriftOverflow = false; + m_SegmentSeconds = 0; + if (m_Guide) + m_Guide->setAIFreeDrift(true); + m_FrameTimer.start(); + m_State = STATE_CAPTURING_DATA; + break; } + + emit protocolProgress(m_TotalPhases - m_Phases.size(), m_TotalPhases, + QString("Re-centering star... (RA %1\" DEC %2\")") + .arg(m_LastRAErrArcsec, 0, 'f', 1).arg(m_LastDECErrArcsec, 0, 'f', 1)); + m_RecenterTimer--; break; } @@ -656,6 +855,47 @@ void AIGuideProtocol::processProtocol() break; } + // Pulse-response can now run before any other guiding phase (it's first in + // the protocol -- pid_autotune_plan.md §7), so unlike before, the guider may + // not be calibrated/locked yet. RA/DEC error isn't decomposable without a + // completed calibration, so firing test pulses before GUIDE_GUIDING is + // reached records nothing but zeros (confirmed in a real run: every + // ra_raw_px/dec_raw_px came back exactly 0.0). Wait here for calibration to + // actually finish instead of a fixed short settle. + if (m_Guide->status() != GUIDE_GUIDING) + { + if (m_Guide->status() == GUIDE_IDLE || m_Guide->status() == GUIDE_ABORTED) + { + if (m_AbortRetries >= 3) + { + emit protocolLog("Pulse Response: guide calibration failed repeatedly -- skipping this pulse."); + m_Phases.removeFirst(); + m_AbortRetries = 0; + m_State = STATE_PRECHECK; + break; + } + m_AbortRetries++; + emit protocolLog("Pulse Response: starting guide calibration/lock before firing test pulses..."); + m_Guide->guide(); + m_PulseWatchdog = 300; // ~5 minutes at 1Hz for calibration to complete + } + else if (m_PulseWatchdog > 0) + { + m_PulseWatchdog--; + if (m_PulseWatchdog % 30 == 0) + emit protocolLog(QString("Pulse Response: still waiting for guide calibration/lock (status=%1)...") + .arg(m_Guide->status())); + } + else + { + emit protocolLog("Pulse Response: timed out waiting for guide calibration/lock -- skipping this pulse."); + m_Phases.removeFirst(); + m_State = STATE_PRECHECK; + } + break; // stay in STATE_PULSE_RESPONSE_INIT, re-check next tick + } + + m_AbortRetries = 0; ProtocolPhase phase = m_Phases.first(); emit protocolLog(QString("Pulse Response: %1 %2 %3ms — preparing...") .arg(phase.pulseAxis, phase.pulseDirection).arg(phase.pulseMagnitudeMs)); @@ -667,52 +907,35 @@ void AIGuideProtocol::processProtocol() 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_PulseSettleTimer = 3; m_State = STATE_PULSE_SETTLING; break; } case STATE_PULSE_SENDING: { - if (!m_Guide) + // Armed and waiting for a clean frame boundary: the pulse itself is fired from + // onGuideStats() (see firePulseResponsePulse()), the instant the next guide + // frame completes, not from this 1Hz tick. Firing here unconditionally was the + // bug — it could land the pulse mid-exposure (the free-drift capture loop keeps + // running all through STATE_PULSE_SETTLING), which is what made the recorded + // response shape inconsistent frame-to-frame. This watchdog only guards against + // no frame ever arriving at all (e.g. star lost) while armed. + if (!m_Guide || m_Guide->status() == GUIDE_ABORTED) { - m_State = STATE_ERROR; + emit protocolLog("Pulse response: guider aborted while waiting to fire pulse."); + m_Phases.removeFirst(); + m_State = STATE_PRECHECK; 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 (m_PulseWatchdog > 0) m_PulseWatchdog--; + if (m_PulseWatchdog <= 0) { - if (phase.pulseDirection == "NORTH") - m_Guide->sendSinglePulse(DEC_INC_DIR, phase.pulseMagnitudeMs, StartCaptureAfterPulses); - else - m_Guide->sendSinglePulse(DEC_DEC_DIR, phase.pulseMagnitudeMs, StartCaptureAfterPulses); + emit protocolLog("Pulse response: timed out waiting for a clean frame boundary to fire the pulse (no frames arriving?). Skipping this test."); + m_Phases.removeFirst(); + m_State = STATE_PRECHECK; } - 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_PulseSentAtMs = QDateTime::currentMSecsSinceEpoch(); - m_FrameTimer.start(); - m_PulseWatchdog = phase.responseFrames * 6 + 30; - m_State = STATE_PULSE_RECORDING; break; } @@ -808,6 +1031,12 @@ void AIGuideProtocol::processProtocol() { if (m_PulseFrameCount == 0 && m_PulseResponseData.isEmpty()) { + // Arm the pulse; it fires from onGuideStats() at the next clean frame + // boundary, not from this timer tick — see firePulseResponsePulse(). + // The watchdog here only guards against no frames arriving at all + // (e.g. star lost) while armed; it is unrelated to the recording-phase + // watchdog below. + m_PulseWatchdog = 30; m_State = STATE_PULSE_SENDING; } else @@ -835,13 +1064,27 @@ void AIGuideProtocol::onGuideStats(double raErr, double decErr, int raPulse, int Q_UNUSED(skyBg) Q_UNUSED(numStars) + m_LastRAErrArcsec = raErr; + m_LastDECErrArcsec = decErr; + + // Armed by STATE_PULSE_SETTLING once the settle countdown elapses (see + // processProtocol()). This guideStats call marks a clean frame-completion boundary — + // firing here, rather than from the 1Hz protocol timer, guarantees the pulse is never + // sent while a capture is mid-exposure, for both streaming and single-capture guiding. + // This frame itself is the last pre-pulse sample, not a response frame, so return + // immediately after firing rather than falling through. + if (m_State == STATE_PULSE_SENDING) + { + firePulseResponsePulse(); + return; + } + 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.") @@ -964,4 +1207,291 @@ void AIGuideProtocol::onGuideStats(double raErr, double decErr, int raPulse, int } } +void AIGuideProtocol::firePulseResponsePulse() +{ + if (!m_Guide) + { + m_State = STATE_ERROR; + return; + } + + ProtocolPhase phase = m_Phases.first(); + + // Streaming guiding delivers the next frame automatically once the pulse-in-flight + // guard (m_streamingPulseGuard) expires; single-capture guiding needs an explicit new + // exposure requested after the pulse (m_PulseTimer -> capture()). Passing the wrong + // one here previously meant an extra, unneeded capture() request could be issued even + // in streaming mode — matching the convention InternalGuider already uses for regular + // guiding pulses (see internalguider.cpp). + const CaptureAfterPulses captureMode = m_Guide->isStreamingGuide() ? DontCaptureAfterPulses : StartCaptureAfterPulses; + + m_Guide->setAIFreeDrift(false); + if (phase.pulseAxis == "RA") + { + if (phase.pulseDirection == "EAST") + m_Guide->sendSinglePulse(RA_INC_DIR, phase.pulseMagnitudeMs, captureMode); + else + m_Guide->sendSinglePulse(RA_DEC_DIR, phase.pulseMagnitudeMs, captureMode); + } + else + { + if (phase.pulseDirection == "NORTH") + m_Guide->sendSinglePulse(DEC_INC_DIR, phase.pulseMagnitudeMs, captureMode); + else + m_Guide->sendSinglePulse(DEC_DEC_DIR, phase.pulseMagnitudeMs, captureMode); + } + 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_PulseSentAtMs = QDateTime::currentMSecsSinceEpoch(); + m_FrameTimer.start(); + m_PulseWatchdog = phase.responseFrames * 6 + 30; + m_State = STATE_PULSE_RECORDING; +} + +namespace +{ + +// One pulse-response session's response curve: seconds since the pulse, and +// baseline-subtracted raw pixel displacement along the relevant axis. +struct PulseCurve +{ + QVector<double> t; + QVector<double> pos; +}; + +PulseCurve extractPulseCurve(const QJsonObject &session, const QString &axisKey) +{ + PulseCurve curve; + const QJsonArray baseline = session.value("baseline_frames").toArray(); + double baselineMean = 0.0; + if (!baseline.isEmpty()) + { + double sum = 0.0; + for (const auto &bf : baseline) + sum += bf.toObject().value(axisKey).toDouble(); + baselineMean = sum / baseline.size(); + } + const QJsonArray frames = session.value("response_frames").toArray(); + curve.t.reserve(frames.size()); + curve.pos.reserve(frames.size()); + for (const auto &f : frames) + { + const QJsonObject fo = f.toObject(); + curve.t.append(fo.value("t").toDouble()); + curve.pos.append(fo.value(axisKey).toDouble() - baselineMean); + } + return curve; +} + +// Linear interpolation of (t, v) at queryT; clamps to the curve's endpoints outside its range. +double interpAt(const QVector<double> &t, const QVector<double> &v, double queryT) +{ + if (t.isEmpty()) + return 0.0; + if (queryT <= t.first()) + return v.first(); + if (queryT >= t.last()) + return v.last(); + for (int i = 1; i < t.size(); ++i) + { + if (t[i] >= queryT) + { + const double frac = (queryT - t[i - 1]) / std::max(t[i] - t[i - 1], 1e-9); + return v[i - 1] + frac * (v[i] - v[i - 1]); + } + } + return v.last(); +} + +double medianOf(QVector<double> values) +{ + if (values.isEmpty()) + return 0.0; + std::sort(values.begin(), values.end()); + const int n = values.size(); + return (n % 2 == 0) ? (values[n / 2 - 1] + values[n / 2]) / 2.0 : values[n / 2]; +} + +// Below this many usable step-response fits, don't trust the result enough to apply +// it automatically -- offline_trainer/pid_autotune.py uses the same style of gate +// (MIN_FITS_FOR_MEDIUM_CONFIDENCE) for its advisory recommendation; applied here as a +// hard floor since this result is applied live, not just surfaced for review. +constexpr int MIN_FITS_TO_APPLY = 4; +// Same default as offline_trainer/pid_autotune.py's SIMC_LAMBDA_L_FACTOR. +constexpr double SIMC_LAMBDA_L_FACTOR = 3.0; +constexpr double INTEGRAL_GAIN_CONSERVATIVE_FRACTION = 0.25; + +} // namespace + +// Live C++ port of offline_trainer/pid_autotune.py's per-axis SIMC-style calculation, +// simplified to avoid a nonlinear curve fit: the plateau amplitude is estimated as the +// mean of the last third of a paired pulse's response curve rather than fit, since on +// every rig tested so far the step response is effectively flat well before the +// response-frame window ends and the fitted tau has never resolved below the sampling +// floor anyway (pid_autotune_plan.md §9.1) -- this is a reasonable same-night stand-in, +// not a replacement for the offline trainer's fuller fit. +bool AIGuideProtocol::computeAndApplyAxisGain(const QString &axis, double msPerArcsec) +{ + if (msPerArcsec <= 0.0) + { + emit protocolLog(QString("PID Auto-Tune [%1]: no calibrated ms/arcsec on record -- keeping current gain.").arg(axis)); + return false; + } + + const QString axisKey = (axis == "RA") ? "ra_raw_px" : "dec_raw_px"; + const QString posDir = (axis == "RA") ? "EAST" : "NORTH"; + const QString negDir = (axis == "RA") ? "WEST" : "SOUTH"; + const double pixelScale = m_SysIdData.value("equipment").toObject() + .value("pixel_scale_arcsec_per_px").toDouble(1.0); + + QMap<double, QList<QJsonObject>> posByMag, negByMag; + const QJsonArray sessions = m_SysIdData.value("sessions").toArray(); + for (const auto &s : sessions) + { + const QJsonObject so = s.toObject(); + if (so.value("type").toString() != "pulse_response" || so.value("pulse_axis").toString() != axis) + continue; + const double mag = so.value("pulse_magnitude_ms").toDouble(); + const QString dir = so.value("pulse_direction").toString(); + if (dir == posDir) + posByMag[mag].append(so); + else if (dir == negDir) + negByMag[mag].append(so); + } + + QVector<double> kSamples, lSamples, tFirstSamples; + QSet<int> signSet; + + for (auto it = posByMag.constBegin(); it != posByMag.constEnd(); ++it) + { + const double mag = it.key(); + const QList<QJsonObject> &posList = it.value(); + const QList<QJsonObject> &negList = negByMag.value(mag); + const int pairs = std::min(posList.size(), negList.size()); + for (int i = 0; i < pairs; ++i) + { + const PulseCurve cp = extractPulseCurve(posList.at(i), axisKey); + const PulseCurve cn = extractPulseCurve(negList.at(i), axisKey); + if (cp.t.size() < 5 || cn.t.size() < 5) + continue; + + QVector<double> tArr, diff; + for (int j = 0; j < cp.t.size(); ++j) + { + if (cp.t[j] < cn.t.first() || cp.t[j] > cn.t.last()) + continue; + tArr.append(cp.t[j]); + diff.append(cp.pos[j] - interpAt(cn.t, cn.pos, cp.t[j])); + } + if (tArr.size() < 5) + continue; + + // Plateau amplitude/noise estimate from the last third of samples. + const int tailCount = std::max(1, static_cast<int>(tArr.size()) / 3); + double tailSum = 0.0; + for (int j = tArr.size() - tailCount; j < tArr.size(); ++j) + tailSum += diff[j]; + const double pFit = tailSum / tailCount; + + double varSum = 0.0; + for (int j = tArr.size() - tailCount; j < tArr.size(); ++j) + varSum += (diff[j] - pFit) * (diff[j] - pFit); + const double residualStd = std::sqrt(varSum / tailCount); + + if (std::abs(pFit) < 2.0 * std::max(residualStd, 1e-6)) + continue; // noise-dominated, skip -- same gate as pulse_response_fit.py + + double lSample = tArr.last(); + const double threshold = std::max(2.5 * residualStd, 1e-6); + for (int j = 0; j < tArr.size(); ++j) + { + if (std::abs(diff[j]) > threshold) + { + lSample = tArr[j]; + break; + } + } + + kSamples.append(std::abs(pFit) * pixelScale / mag); + lSamples.append(lSample); + tFirstSamples.append(tArr.first()); + signSet.insert(pFit > 0 ? 1 : -1); + } + } + + if (kSamples.size() < MIN_FITS_TO_APPLY) + { + emit protocolLog(QString("PID Auto-Tune [%1]: only %2 usable pulse-response fit(s) (need >= %3) -- keeping current gain.") + .arg(axis).arg(kSamples.size()).arg(MIN_FITS_TO_APPLY)); + return false; + } + if (signSet.size() > 1) + { + emit protocolLog(QString("PID Auto-Tune [%1]: pulse-response signs inconsistent across pulses -- " + "data looks like noise, keeping current gain.").arg(axis)); + return false; + } + + const double K = medianOf(kSamples); + const double L = medianOf(lSamples); + const double tau = std::max(medianOf(tFirstSamples), L); + + if (K <= 0.0) + { + emit protocolLog(QString("PID Auto-Tune [%1]: computed process gain is zero -- keeping current gain.").arg(axis)); + return false; + } + + const double lambda = std::max(tau, SIMC_LAMBDA_L_FACTOR * L); + const double Kc = (1.0 / K) * tau / (lambda + L); + const double proportionalGain = std::max(0.0, std::min(1.0, Kc / msPerArcsec)); + const double integralGain = std::max(0.0, std::min(1.0, INTEGRAL_GAIN_CONSERVATIVE_FRACTION * proportionalGain)); + + const double oldGain = (axis == "RA") ? Options::rAProportionalGain() : Options::dECProportionalGain(); + if (axis == "RA") + { + Options::setRAProportionalGain(proportionalGain); + Options::setRAIntegralGain(integralGain); + } + else + { + Options::setDECProportionalGain(proportionalGain); + Options::setDECIntegralGain(integralGain); + } + + emit protocolLog(QString("PID Auto-Tune [%1]: K=%2\"/ms L=%3s tau=%4s (n=%5 fits) " + "-- gain %6 -> %7 (locked for the rest of this session)") + .arg(axis).arg(K, 0, 'f', 5).arg(L, 0, 'f', 2).arg(tau, 0, 'f', 2) + .arg(kSamples.size()).arg(oldGain, 0, 'f', 3).arg(proportionalGain, 0, 'f', 3)); + return true; +} + +void AIGuideProtocol::applyPIDAutoTuneGainLock() +{ + m_GainLocked = true; + + if (!Options::aIPIDAutoTune()) + return; + + auto *internalGuider = m_Guide ? qobject_cast<InternalGuider *>(m_Guide->getGuiderInstance()) : nullptr; + if (!internalGuider) + { + emit protocolLog("PID Auto-Tune: Internal Guider required to read calibration -- skipping gain lock."); + return; + } + const auto &cal = internalGuider->getCalibration(); + + const bool raApplied = computeAndApplyAxisGain("RA", cal.raPulseMillisecondsPerArcsecond()); + const bool decApplied = computeAndApplyAxisGain("DEC", cal.decPulseMillisecondsPerArcsecond()); + + if (raApplied || decApplied) + refreshFingerprint(); +} + } \ No newline at end of file diff --git a/kstars/ekos/guide/aiguideprotocol.h b/kstars/ekos/guide/aiguideprotocol.h index c2e29ea618..f55b248b9d 100644 --- a/kstars/ekos/guide/aiguideprotocol.h +++ b/kstars/ekos/guide/aiguideprotocol.h @@ -39,6 +39,7 @@ class AIGuideProtocol : public QObject STATE_SLEWING, STATE_SETTLING, STATE_CAPTURING_DATA, + STATE_DRIFT_RECENTER, STATE_PULSE_RESPONSE_INIT, STATE_PULSE_SENDING, STATE_PULSE_RECORDING, @@ -113,6 +114,28 @@ class AIGuideProtocol : public QObject void restoreSettings(); QJsonObject buildFingerprint() const; void refreshFingerprint(); + // Actually sends the armed pulse-response test pulse. Called from onGuideStats() + // at the first clean frame boundary once STATE_PULSE_SENDING is armed — NOT from + // processProtocol()'s 1Hz tick — so the pulse can never land mid-exposure, + // regardless of whether guiding is streaming or single-capture. See + // STATE_PULSE_SETTLING / STATE_PULSE_SENDING handling in processProtocol() and + // onGuideStats() for the full rationale. + void firePulseResponsePulse(); + + // Computes a recommended base RA/DEC proportional (+ conservative integral) + // guide gain from the pulse_response sessions collected so far (a live C++ + // equivalent of offline_trainer/pid_autotune.py's SIMC-style calculation) and, + // if the data is usable, applies it via Options::setRA/dECProportionalGain() + // (+ integral gain) before the rest of the protocol runs. Called once from + // STATE_PRECHECK, right after the PID Auto-Tune pulses (if any) are exhausted + // and before the first real phase -- see pid_autotune_plan.md §7 for why this + // must happen before, not after. A no-op if Options::aIPIDAutoTune() was + // off or the collected data isn't usable (too few fits, inconsistent signs, + // no calibration on record); the previously-set gain is left untouched. + void applyPIDAutoTuneGainLock(); + // One axis of the above; returns true if it computed and applied a gain. + bool computeAndApplyAxisGain(const QString &axis, double msPerArcsec); + bool m_GainLocked { false }; Guide *m_Guide { nullptr }; int m_TotalPhases { 0 }; @@ -132,6 +155,11 @@ class AIGuideProtocol : public QObject }; QList<ProtocolPhase> m_Phases; + // Writes one captured segment as a session and flushes the log. recordedDuration is + // what the segment was meant to run for: the trainer discards drift sessions much + // shorter than it. + void flushPhaseSegment(const ProtocolPhase &phase, int recordedDuration); + ProtocolState m_State { STATE_IDLE }; QTimer m_ProtocolTimer; double m_TargetAz { 0 }; @@ -140,6 +168,12 @@ class AIGuideProtocol : public QObject int m_CaptureTimer { 0 }; int m_AbortRetries { 0 }; bool m_FreeDriftOverflow { false }; + bool m_PhaseAborted { false }; + int m_SegmentSeconds { 0 }; ///< seconds captured in the current drift segment + int m_RecenterTimer { 0 }; + int m_RecenterAttempts { 0 }; + double m_LastRAErrArcsec { 0.0 }; + double m_LastDECErrArcsec { 0.0 }; QFile m_LogFile; QString m_LogFilename; diff --git a/kstars/ekos/guide/aiguidewizard.cpp b/kstars/ekos/guide/aiguidewizard.cpp index 7b83510ce7..adaf8dd8bd 100644 --- a/kstars/ekos/guide/aiguidewizard.cpp +++ b/kstars/ekos/guide/aiguidewizard.cpp @@ -5,6 +5,7 @@ */ #include "aiguidewizard.h" +#include <QTimer> #include "aiguideprotocol.h" #include "guide.h" #include "kspaths.h" @@ -207,6 +208,8 @@ AIGuideWizard::AIGuideWizard(AIGuideProtocol *protocol, QWidget *parent) : QWiza { progressBar->setValue(100); exportOfflineButton->setEnabled(true); + if (auto *nextBtn = button(QWizard::NextButton)) + nextBtn->setEnabled(true); this->next(); }); @@ -215,6 +218,8 @@ AIGuideWizard::AIGuideWizard(AIGuideProtocol *protocol, QWidget *parent) : QWiza connect(m_Protocol, &AIGuideProtocol::protocolStopped, this, [this]() { + if (auto *nextBtn = button(QWizard::NextButton)) + nextBtn->setEnabled(true); stopButton->setText(i18n("Start")); stopButton->setEnabled(true); disconnect(stopButton, &QPushButton::clicked, this, &AIGuideWizard::slotStopProtocol); @@ -296,6 +301,12 @@ void AIGuideWizard::showEvent(QShowEvent *event) stopButton->setEnabled(true); disconnect(stopButton, &QPushButton::clicked, this, &AIGuideWizard::slotStartProtocol); connect(stopButton, &QPushButton::clicked, this, &AIGuideWizard::slotStopProtocol, Qt::UniqueConnection); + + QTimer::singleShot(0, this, [this]() + { + if (auto *nextBtn = button(QWizard::NextButton)) + nextBtn->setEnabled(false); + }); } // If protocol already completed, jump to training page (page 3) else if (m_Protocol->state() == AIGuideProtocol::STATE_DONE) @@ -403,6 +414,23 @@ void AIGuideWizard::slotExportLogs() })); } +bool AIGuideWizard::validateCurrentPage() +{ + // Progress page: Next is blocked while the protocol runs; protocolComplete advances it + if (currentId() == 2 && m_Protocol) + { + const auto s = m_Protocol->state(); + const bool running = s != AIGuideProtocol::STATE_IDLE + && s != AIGuideProtocol::STATE_DONE + && s != AIGuideProtocol::STATE_ERROR + && s != AIGuideProtocol::STATE_TRAINING + && s != AIGuideProtocol::STATE_TRAINING_DONE; + if (running) + return false; + } + return QWizard::validateCurrentPage(); +} + void AIGuideWizard::initializePage(int id) { QWizard::initializePage(id); @@ -419,6 +447,14 @@ void AIGuideWizard::initializePage(int id) disconnect(stopButton, &QPushButton::clicked, this, &AIGuideWizard::slotStartProtocol); connect(stopButton, &QPushButton::clicked, this, &AIGuideWizard::slotStopProtocol, Qt::UniqueConnection); + // No skipping ahead: the page auto-advances on protocolComplete. + // Deferred: QWizard re-enables its buttons right after initializePage(). + QTimer::singleShot(0, this, [this]() + { + if (auto *nextBtn = button(QWizard::NextButton)) + nextBtn->setEnabled(false); + }); + m_Protocol->start(mountTypeCombo->currentText()); } } diff --git a/kstars/ekos/guide/aiguidewizard.h b/kstars/ekos/guide/aiguidewizard.h index a302701d08..d255494a04 100644 --- a/kstars/ekos/guide/aiguidewizard.h +++ b/kstars/ekos/guide/aiguidewizard.h @@ -47,6 +47,7 @@ class AIGuideWizard : public QWizard, public Ui::AIGuideWizard protected: void initializePage(int id) override; + bool validateCurrentPage() override; void done(int result) override; void showEvent(QShowEvent *event) override; diff --git a/kstars/ekos/guide/aiguidewizard.ui b/kstars/ekos/guide/aiguidewizard.ui index ccf03bff98..77bedd54b1 100644 --- a/kstars/ekos/guide/aiguidewizard.ui +++ b/kstars/ekos/guide/aiguidewizard.ui @@ -124,7 +124,7 @@ <item> <widget class="QLabel" name="labelWormGear"> <property name="text"> - <string><html><head/><body><p><span style=" font-weight:600;">Worm Gear Protocol (~45 mins):</span><br/>1. Slew to High Altitude : Free drift, then standard guiding.<br/>2. Slew to Lower Altitude : Free drift, then standard guiding.<br/>3. Slew to High Altitude : Standard guiding.</p></body></html></string> + <string><html><head/><body><p><span style=" font-weight:600;">Worm Gear Protocol (~65 mins):</span><br/>1. Slew to High Altitude : Standard guiding, free drift, then PID auto-tune (step response).<br/>2. Slew to Lower Altitude : Standard guiding, then free drift.<br/>3. Slew to High Altitude : Standard guiding, then free drift.</p></body></html></string> </property> <property name="wordWrap"> <bool>true</bool> @@ -144,7 +144,7 @@ <item> <widget class="QLabel" name="labelHarmonic"> <property name="text"> - <string><html><head/><body><p><span style=" font-weight:600;">Harmonic Drive Protocol (~35 mins):</span><br/>1. Slew to High Altitude: Free drift, pulse response test, then standard guiding.<br/>2. Slew to Lower Altitude: Free drift, then standard guiding.</p></body></html></string> + <string><html><head/><body><p><span style=" font-weight:600;">Harmonic Drive Protocol (~60 mins):</span><br/>1. Slew to High Altitude: Standard guiding, free drift, then PID auto-tune (step response).<br/>2. Slew to Lower Altitude: Free drift, then standard guiding.</p></body></html></string> </property> <property name="wordWrap"> <bool>true</bool> @@ -164,7 +164,7 @@ <item> <widget class="QLabel" name="labelDirectDrive"> <property name="text"> - <string><html><head/><body><p><span style=" font-weight:600;">Direct Drive Protocol (~20 mins):</span><br/>1. Slew to High, Mid, and Low Altitudes: Free drift at each position.</p></body></html></string> + <string><html><head/><body><p><span style=" font-weight:600;">Direct Drive Protocol (~30 mins):</span><br/>1. Slew to High Altitude: Standard guiding, free drift, then PID auto-tune (step response).<br/>2. Slew to Mid Altitude: Standard guiding, then free drift.<br/>3. Slew to Low Altitude: Standard guiding, then free drift.</p></body></html></string> </property> <property name="wordWrap"> <bool>true</bool> diff --git a/kstars/ekos/guide/guide.cpp b/kstars/ekos/guide/guide.cpp index 647e7e138a..f5222cac9a 100644 --- a/kstars/ekos/guide/guide.cpp +++ b/kstars/ekos/guide/guide.cpp @@ -1948,6 +1948,18 @@ void Guide::clearCalibration() } +void Guide::reloadAIWeights() +{ + InternalGuider *ig = dynamic_cast<InternalGuider *>(m_GuiderInstance); + if (!ig) + { + qCWarning(KSTARS_EKOS_GUIDE) << "AI weight hot-reload requires the Internal Guider."; + return; + } + ig->reloadAIWeights(); + // Success/failure is already logged by cgmath::reloadAIWeights() via qCWarning + newLog(). +} + void Guide::setStatus(Ekos::GuideState newState) { if (newState == m_State) diff --git a/kstars/ekos/guide/guide.h b/kstars/ekos/guide/guide.h index 00fa8c4aaa..59d932653c 100644 --- a/kstars/ekos/guide/guide.h +++ b/kstars/ekos/guide/guide.h @@ -257,6 +257,16 @@ class Guide : public QWidget, public Ui::Guide return m_AIFreeDrift; } + // Used by AIGuideProtocol to pick the correct CaptureAfterPulses mode when firing + // pulse-response test pulses: DontCaptureAfterPulses in streaming mode (frames + // arrive automatically), StartCaptureAfterPulses otherwise (an explicit new + // exposure must be requested) — matching the convention InternalGuider already + // uses for regular guiding pulses. + bool isStreamingGuide() const + { + return m_StreamingGuide; + } + /** DBUS interface function. * Stop any active calibration, guiding, or dithering operation * @return Returns true if operation is stopped successfully, false otherwise. @@ -274,6 +284,15 @@ class Guide : public QWidget, public Ui::Guide */ Q_SCRIPTABLE Q_NOREPLY void clearCalibration(); + /** DBUS interface function. + * @brief Hot-reload the AI Guider weights file from disk into the currently running + * guide session, without stopping guiding or recalibrating. Only effective + * when the Internal Guider is active and the AI algorithm (or Shadow Mode) is + * selected; otherwise a log message explains why nothing happened. Progress + * and failures are reported via the Guide log. + */ + Q_SCRIPTABLE Q_NOREPLY void reloadAIWeights(); + /** DBUS interface function. * @brief dither Starts dithering process in a random direction restricted by the number of pixels specified in dither options * @return True if dither started successfully, false otherwise. diff --git a/kstars/ekos/guide/internalguide/gmath.cpp b/kstars/ekos/guide/internalguide/gmath.cpp index 61cddd2d8f..30869c119b 100644 --- a/kstars/ekos/guide/internalguide/gmath.cpp +++ b/kstars/ekos/guide/internalguide/gmath.cpp @@ -29,6 +29,7 @@ #include <QTextStream> #include <QStandardPaths> #include <QDir> +#include <utility> // Qt version calming #include <qtendl.h> @@ -334,6 +335,59 @@ void cgmath::start() m_aiRequiredButUnavailable = useAIAlgorithm && !(m_AIGuider && m_AIGuider->isLoaded()); } +bool cgmath::reloadAIWeights() +{ + const bool useAIAlgorithm = (raAlgorithmIsAI() || decAlgorithmIsAI()); + const bool shadowRequested = Options::aIShadowMode(); + + if (!useAIAlgorithm && !shadowRequested) + { + qCWarning(KSTARS_EKOS_GUIDE) << ">>> AI GUIDER RELOAD SKIPPED: AI algorithm/Shadow Mode is not selected."; + return false; + } + + const QString weightsPath = Options::aIGuiderWeightsFile().toLocalFile(); + if (weightsPath.isEmpty() || !QFile::exists(weightsPath)) + { + qCWarning(KSTARS_EKOS_GUIDE) << ">>> AI GUIDER RELOAD FAILED: no weights file configured or file missing:" << + weightsPath; + return false; + } + + // Build and validate the candidate before touching m_AIGuider: on failure, guiding + // continues uninterrupted with the previously-loaded guider (unlike start(), where a + // load failure aborts guiding since there is no previous guider to fall back to). + std::unique_ptr<MountSpecificGuider> candidate = MountGuiderFactory::createFromWeights(weightsPath); + if (!candidate || !candidate->loadWeights(weightsPath)) + { + const QString reason = candidate ? candidate->fingerprintError() : QString(); + qCWarning(KSTARS_EKOS_GUIDE) << ">>> AI GUIDER RELOAD FAILED:" << weightsPath << reason; + emit newLog(i18n("AI Guider weight reload failed: %1", reason.isEmpty() + ? i18n("the weights file could not be read or does not match this mount type.") : reason)); + return false; + } + + m_AIGuider = std::move(candidate); + // A plain (non-forced) reset is enough: HarmonicGuider::resetSession() already forces a + // full reset itself when the new weights describe a different PE period than the currently + // active static Kalman state, and otherwise only clears transient tracking state while + // keeping the PE phase lock — the common case when just retuning gains/Q-net mid-session. + m_AIGuider->resetSession(false); + m_aiRequiredButUnavailable = useAIAlgorithm && !(m_AIGuider && m_AIGuider->isLoaded()); + + // Re-arm the per-frame log-emission flags so the existing state-transition messages in + // performProcessing() naturally re-announce WARMUP/ACTIVE, giving a clear marker in both + // the Guide log and the debug CSV timeline for when the reload took effect. + m_AILoggedActive = false; + m_AILoggedFullConfidence = false; + m_AILoggedWarmup = false; + setAIState(useAIAlgorithm ? AIGuideState::WARMUP : AIGuideState::SHADOW); + + qCWarning(KSTARS_EKOS_GUIDE) << ">>> AI GUIDER WEIGHTS RELOADED LIVE:" << weightsPath; + emit newLog(i18n("AI Guider weights reloaded live from %1 — guiding continues uninterrupted.", weightsPath)); + return true; +} + void cgmath::abort() { guideStars.reset(); @@ -510,6 +564,10 @@ void cgmath::processAxis(const int k, const bool dithering, const bool darkGuide GuideDirection pulseDirection = NO_DIR; int pulseLength = 0; // milliseconds GuideDirection dir; + // Breakdown of how the pulse was actually computed this frame; stored into + // m_lastBlend[k] just before updateOutParams() so the AI debug CSV can log + // what was really sent to the mount, not just the AI's internal prediction. + AxisBlendDebug dbg; // Get the drift for this axis const int idx = driftUpto[k]; @@ -554,6 +612,7 @@ void cgmath::processAxis(const int k, const bool dithering, const bool darkGuide { gpg->darkGuiding(&pulseLength, &dir, calibration, timeStep); pulseDirection = dir; + dbg.algorithm = "GPG-Dark"; } else if (useAI && darkGuide && m_AIGuider && m_AIGuider->isLoaded()) { @@ -565,6 +624,8 @@ void cgmath::processAxis(const int k, const bool dithering, const bool darkGuide const double aiGain = Options::aIPredictionGain(); const double aiResponse = ai_pulse_arcsec * pulseConverter; double total = aiGain * ai_out.confidence * aiResponse; + dbg.algorithm = "AI-Dark"; + dbg.aiResponseMs = aiResponse; qCDebug(KSTARS_EKOS_GUIDE) << QString("[AI GUIDER] Dark Guiding [%1] | dt=%2s, conf=%3, AIResponse=%4ms -> Total=%5ms") .arg(k == GUIDE_RA ? "RA" : "DEC").arg(dt_sec, 0, 'f', 1).arg(ai_out.confidence, 0, 'f', 2) @@ -597,6 +658,7 @@ void cgmath::processAxis(const int k, const bool dithering, const bool darkGuide { pulseDirection = dir; pulseLength = std::min(pulseLength, static_cast<int>(maxPulseMilliseconds + 0.5)); + dbg.algorithm = "GPG"; } else if (lGuider != nullptr) { @@ -623,6 +685,8 @@ void cgmath::processAxis(const int k, const bool dithering, const bool darkGuide pulseDirection = pulse > 0 ? DEC_DEC_DIR : DEC_INC_DIR; } pulseLength = std::min(std::abs(pulse), maxPulseMilliseconds); + dbg.algorithm = "Linear"; + dbg.proportionalResponseMs = pulse; } else if (hGuider != nullptr) { @@ -644,6 +708,8 @@ void cgmath::processAxis(const int k, const bool dithering, const bool darkGuide pulseDirection = pulse > 0 ? DEC_DEC_DIR : DEC_INC_DIR; } pulseLength = std::min(std::abs(pulse), maxPulseMilliseconds); + dbg.algorithm = "Hysteresis"; + dbg.proportionalResponseMs = pulse; } else if (useAI && m_AIGuider && m_AIGuider->isLoaded() && m_lastAIPrediction.valid) { @@ -679,6 +745,12 @@ void cgmath::processAxis(const int k, const bool dithering, const bool darkGuide .arg(integralResponse, 0, 'f', 1).arg(aiResponse, 0, 'f', 1).arg(conf, 0, 'f', 2).arg(aiGain, 0, 'f', 2).arg(total, 0, 'f', 1); + dbg.algorithm = "AI"; + dbg.proportionalResponseMs = proportionalResponse; + dbg.integralResponseMs = integralResponse; + dbg.aiResponseMs = aiResponse; + dbg.activePropGain = activePropGain; + pulseLength = std::min(std::abs(total), maxPulseMilliseconds); pulseDirection = (k == GUIDE_RA) ? (total > 0 ? RA_DEC_DIR : RA_INC_DIR) : @@ -727,6 +799,9 @@ void cgmath::processAxis(const int k, const bool dithering, const bool darkGuide const double proportionalResponse = arcsecDrift * in_params.proportional_gain[k] * arcsecPerMsPulse; const double integralResponse = drift_integral[k] * in_params.integral_gain[k] * arcsecPerMsPulse; pulseLength = std::min(std::abs(proportionalResponse + integralResponse), maxPulseMilliseconds); + dbg.algorithm = "Standard"; + dbg.proportionalResponseMs = proportionalResponse; + dbg.integralResponseMs = integralResponse; // calculation of correcting mount pulse // We do not send pulse if direction is disabled completely, or if direction in a specific axis (e.g. N or S) is disabled @@ -764,6 +839,7 @@ void cgmath::processAxis(const int k, const bool dithering, const bool darkGuide qCDebug(KSTARS_EKOS_GUIDE) << i18n("Limited long pulse of %1ms to %2ms", pulseLength, MAX_PULSE_MILLISECONDS); pulseLength = MAX_PULSE_MILLISECONDS; } + m_lastBlend[k] = dbg; updateOutParams(k, arcsecDrift, pulseLength, pulseDirection, !darkGuide); } @@ -897,11 +973,17 @@ void cgmath::performProcessing(Ekos::GuideState state, QSharedPointer<FITSData> const double raDrift = drift[GUIDE_RA][driftUpto[GUIDE_RA]]; const double decDrift = drift[GUIDE_DEC][driftUpto[GUIDE_DEC]]; + // Hoisted to function scope (rather than declared inside the block below) so the AI debug + // CSV writer — which runs after calculatePulses() so it can log the actual blended pulse, + // not just the AI's internal prediction — can still read the frame data and uncorrected + // drift values computed here. + GuideFrameData frameData; + double uncorrected_drift_ra_px = 0.0, uncorrected_drift_dec_px = 0.0; + const bool aiFrameProcessed = (m_AIGuider && m_AIGuider->isLoaded() && state == Ekos::GUIDE_GUIDING); + // --- AI Guider feed-forward prediction --- - if (m_AIGuider && m_AIGuider->isLoaded() && state == Ekos::GUIDE_GUIDING) + if (aiFrameProcessed) { - GuideFrameData frameData; - // Pixel scale is in arcseconds per pixel frameData.pixel_scale = calibration.xPixelsPerArcsecond(); if (frameData.pixel_scale == 0) frameData.pixel_scale = 1.0; @@ -1074,8 +1156,8 @@ void cgmath::performProcessing(Ekos::GuideState state, QSharedPointer<FITSData> double uncorrected_drift_ra_arcsec = raDrift - prev_ra_arcsec - applied_pulse_arcsec_ra; double uncorrected_drift_dec_arcsec = decDrift - prev_dec_arcsec - applied_pulse_arcsec_dec; - double uncorrected_drift_ra_px = uncorrected_drift_ra_arcsec / frameData.pixel_scale; - double uncorrected_drift_dec_px = uncorrected_drift_dec_arcsec / frameData.pixel_scale; + uncorrected_drift_ra_px = uncorrected_drift_ra_arcsec / frameData.pixel_scale; + uncorrected_drift_dec_px = uncorrected_drift_dec_arcsec / frameData.pixel_scale; m_AIGuider->update(ra_px, dec_px, uncorrected_drift_ra_px, uncorrected_drift_dec_px, frameData.snr, applied_pulse_arcsec_ra / frameData.pixel_scale, @@ -1137,7 +1219,17 @@ void cgmath::performProcessing(Ekos::GuideState state, QSharedPointer<FITSData> } } - // --- AI DEBUG FILE LOGGER --- + } + + // make decision by axes + calculatePulses(state, timeStep); + + // --- AI DEBUG FILE LOGGER --- + // Runs after calculatePulses() (which populates m_lastBlend[] and out_params via + // processAxis()) so this log records what was actually blended and sent to the mount + // this frame, not just the AI's internal prediction. + if (aiFrameProcessed) + { if (!m_AIDebugFile) { QString logDir = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) @@ -1164,9 +1256,24 @@ void cgmath::performProcessing(Ekos::GuideState state, QSharedPointer<FITSData> QTextStream out(m_AIDebugFile); if (!m_AIDebugHeaderWritten) { - out << "t_session,dt,altitude_deg,azimuth_deg,parallactic_angle_deg,ra_error_arcsec,uncorrected_ra_delta_px,dec_error_arcsec,uncorrected_dec_delta_px,conf,pred_ra_arcsec,physics_ra_arcsec,mlp_ra_arcsec,pred_dec_arcsec,physics_dec_arcsec,mlp_dec_arcsec,ai_state,pe_statestring\n"; + out << "t_session,dt,altitude_deg,azimuth_deg,parallactic_angle_deg,ra_error_arcsec,uncorrected_ra_delta_px,dec_error_arcsec,uncorrected_dec_delta_px,conf,pred_ra_arcsec,physics_ra_arcsec,mlp_ra_arcsec,pred_dec_arcsec,physics_dec_arcsec,mlp_dec_arcsec,ai_state,pe_statestring," + "ra_algorithm,ra_prop_response_ms,ra_integral_response_ms,ra_ai_response_ms,ra_active_prop_gain,ra_total_pulse_ms,ra_direction,ra_suppressed," + "dec_algorithm,dec_prop_response_ms,dec_integral_response_ms,dec_ai_response_ms,dec_active_prop_gain,dec_total_pulse_ms,dec_direction,dec_suppressed\n"; m_AIDebugHeaderWritten = true; } + // Empty field for NaN (fields not meaningful for the algorithm that actually ran) + // rather than the literal string "nan", which not every CSV reader parses cleanly. + auto csvNum = [](double v) { return std::isnan(v) ? QString() : QString::number(v, 'f', 3); }; + // Signed total pulse actually sent this frame, mirroring the sign convention + // updateOutParams() uses for m_accumulated_pulse_ra/dec (gmath.cpp:signed_pulse). + auto axisTotalMs = [this](int k) -> double + { + if (out_params.pulse_dir[k] == NO_DIR || out_params.pulse_length[k] == 0) + return 0.0; + const bool neg = (k == GUIDE_RA && out_params.pulse_dir[k] == RA_DEC_DIR) + || (k == GUIDE_DEC && out_params.pulse_dir[k] == DEC_DEC_DIR); + return neg ? -out_params.pulse_length[k] : out_params.pulse_length[k]; + }; out << frameData.t_session_sec << "," << frameData.dt << "," << frameData.altitude_deg << "," @@ -1184,14 +1291,27 @@ void cgmath::performProcessing(Ekos::GuideState state, QSharedPointer<FITSData> << m_lastAIPrediction.physics_dec_arcsec << "," << m_lastAIPrediction.mlp_dec_arcsec << "," << aiGuideStateString(m_aiState) << "," - << m_AIGuider->stateString() << "\n"; + << m_AIGuider->stateString() << "," + << m_lastBlend[GUIDE_RA].algorithm << "," + << csvNum(m_lastBlend[GUIDE_RA].proportionalResponseMs) << "," + << csvNum(m_lastBlend[GUIDE_RA].integralResponseMs) << "," + << csvNum(m_lastBlend[GUIDE_RA].aiResponseMs) << "," + << csvNum(m_lastBlend[GUIDE_RA].activePropGain) << "," + << axisTotalMs(GUIDE_RA) << "," + << directionStr(out_params.pulse_dir[GUIDE_RA]) << "," + << (out_params.pulse_dir[GUIDE_RA] == NO_DIR ? 1 : 0) << "," + << m_lastBlend[GUIDE_DEC].algorithm << "," + << csvNum(m_lastBlend[GUIDE_DEC].proportionalResponseMs) << "," + << csvNum(m_lastBlend[GUIDE_DEC].integralResponseMs) << "," + << csvNum(m_lastBlend[GUIDE_DEC].aiResponseMs) << "," + << csvNum(m_lastBlend[GUIDE_DEC].activePropGain) << "," + << axisTotalMs(GUIDE_DEC) << "," + << directionStr(out_params.pulse_dir[GUIDE_DEC]) << "," + << (out_params.pulse_dir[GUIDE_DEC] == NO_DIR ? 1 : 0) << "\n"; out.flush(); } } - // make decision by axes - calculatePulses(state, timeStep); - if (state == Ekos::GUIDE_GUIDING) { calculateRmsError(); diff --git a/kstars/ekos/guide/internalguide/gmath.h b/kstars/ekos/guide/internalguide/gmath.h index df6c7a9c2c..e75cc676fb 100644 --- a/kstars/ekos/guide/internalguide/gmath.h +++ b/kstars/ekos/guide/internalguide/gmath.h @@ -20,6 +20,7 @@ #include <QElapsedTimer> #include <cstdint> +#include <limits> #include <sys/types.h> #include "guidestars.h" #include "calibration.h" @@ -83,6 +84,22 @@ inline QString aiGuideStateString(AIGuideState s) return QStringLiteral("UNKNOWN"); } +/** + * @brief Breakdown of how the guide pulse for one axis was actually computed this frame, + * captured by cgmath::processAxis() for every pulse algorithm branch (AI, GPG, + * Linear, Hysteresis, Standard). Fields not meaningful for a given algorithm are + * left NaN. This exists so the AI debug CSV can record what was actually sent to + * the mount, not just the AI's internal prediction. + */ +struct AxisBlendDebug +{ + QString algorithm; ///< "AI" | "AI-Dark" | "GPG" | "GPG-Dark" | "Linear" | "Hysteresis" | "Standard" + double proportionalResponseMs { std::numeric_limits<double>::quiet_NaN() }; + double integralResponseMs { std::numeric_limits<double>::quiet_NaN() }; + double aiResponseMs { std::numeric_limits<double>::quiet_NaN() }; + double activePropGain { std::numeric_limits<double>::quiet_NaN() }; +}; + // input params class cproc_in_params { @@ -157,6 +174,16 @@ class cgmath : public QObject return m_aiRequiredButUnavailable; } + /** + * @brief Re-reads Options::aIGuiderWeightsFile() and swaps it into the live + * m_AIGuider without touching drift buffers or other algorithms' state. + * Unlike start(), failure here is non-fatal: guiding continues with the + * previously-loaded guider. Intended for live retuning of weights.json + * during a bench session (DBus-triggered via Guide::reloadAIWeights()). + * @return true if a new guider was loaded and swapped in. + */ + bool reloadAIWeights(); + /** * @brief Pointing state of the mount, pushed in by the guider whenever the mount reports new * coordinates. This is the preferred source for the AI feed-forward physics model: it is @@ -307,6 +334,9 @@ class cgmath : public QObject std::unique_ptr < MountSpecificGuider > m_AIGuider; GuideOutput m_lastAIPrediction; + /// Actual blended-pulse breakdown for the last frame, one per axis; populated by + /// processAxis() regardless of which pulse algorithm ran. See AxisBlendDebug. + AxisBlendDebug m_lastBlend[CHANNEL_CNT]; double m_sessionStartTime { 0.0 }; /// Latest mount pointing state; see setMountState(). Stays invalid if no mount is connected. diff --git a/kstars/ekos/guide/internalguide/internalguider.h b/kstars/ekos/guide/internalguide/internalguider.h index c6c0fd233c..d512422a7b 100644 --- a/kstars/ekos/guide/internalguide/internalguider.h +++ b/kstars/ekos/guide/internalguide/internalguider.h @@ -155,6 +155,12 @@ class InternalGuider : public GuideInterface return pmath ? pmath->getAIGuider() : nullptr; } + // Live weight hot-reload, forwarded from Guide::reloadAIWeights() (DBus). + bool reloadAIWeights() + { + return pmath ? pmath->reloadAIWeights() : false; + } + protected Q_SLOTS: void trackingStarSelected(int x, int y); void setDitherSettled(); diff --git a/kstars/ekos/guide/opsaiconfig.ui b/kstars/ekos/guide/opsaiconfig.ui index fdd6d07826..dfe1bee09f 100644 --- a/kstars/ekos/guide/opsaiconfig.ui +++ b/kstars/ekos/guide/opsaiconfig.ui @@ -74,12 +74,12 @@ </widget> </item> <item> - <widget class="QCheckBox" name="kcfg_AIProtocolPulseTest"> + <widget class="QCheckBox" name="kcfg_AIPIDAutoTune"> <property name="text"> - <string>Include Pulse Response Test in Data Collection Protocol</string> + <string>PID Auto-Tune (Step-Response Gain Calibration)</string> </property> <property name="toolTip"> - <string>Measures the mount's elastic response to guide pulses (harmonic drives). Adds about 20 minutes to the protocol and has not produced measurable results on rigs tested so far, so it is off by default.</string> + <string>Sends known pulses at the start of the protocol and measures the mount's step response to recommend a base RA/DEC guiding gain for any mount type (offline trainer only, never applied automatically). Adds roughly 10 minutes to the protocol; enabled by default.</string> </property> </widget> </item> diff --git a/kstars/kstars.kcfg b/kstars/kstars.kcfg index 8b7960fdf2..5393127cbe 100644 --- a/kstars/kstars.kcfg +++ b/kstars/kstars.kcfg @@ -3122,10 +3122,10 @@ <label>Dynamically scale down standard proportional gain when AI is highly confident.</label> <default>false</default> </entry> - <entry name="AIProtocolPulseTest" type="Bool"> - <label>Include the pulse response test in the AI data collection protocol.</label> - <whatsthis>The pulse response test measures the mount's elastic response to guide pulses (harmonic drives). It adds about 20 minutes to the protocol and has not produced measurable results on rigs tested so far, so it is off by default.</whatsthis> - <default>false</default> + <entry name="AIPIDAutoTune" type="Bool"> + <label>Run PID Auto-Tune (step-response gain calibration) as part of the AI data collection protocol.</label> + <whatsthis>Sends known pulses at the start of the protocol and measures the mount's step response to recommend a base RA/DEC guiding gain for any mount type. Advisory only -- never applied automatically. Adds roughly 10 minutes to the protocol. Enabled by default.</whatsthis> + <default>true</default> </entry> <entry name="AIShadowMode" type="Bool"> <label>Run AI model alongside standard guiding, logging predictions without applying them.</label> diff --git a/kstars/org.kde.kstars.Ekos.Guide.xml b/kstars/org.kde.kstars.Ekos.Guide.xml index b8ea9c29c6..28bf0ab25c 100644 --- a/kstars/org.kde.kstars.Ekos.Guide.xml +++ b/kstars/org.kde.kstars.Ekos.Guide.xml @@ -47,7 +47,10 @@ </method> <method name="clearCalibration"> <annotation name="org.freedesktop.DBus.Method.NoReply" value="true"/> - </method> + </method> + <method name="reloadAIWeights"> + <annotation name="org.freedesktop.DBus.Method.NoReply" value="true"/> + </method> <method name="setDarkFrameEnabled"> <arg name="enable" type="b" direction="in"/> <annotation name="org.freedesktop.DBus.Method.NoReply" value="true"/>