[education/kstars/stable-3.8.4] kstars/ekos/guide: AI Guide: distinguish cold vs. warm pulses in PID Auto-Tune

Jasem Mutlaq <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit 930ec119c9fc21d97c721a9f2d30ca4711f14fb6 by Jasem Mutlaq.
Committed on 09/08/2026 at 12:31.
Pushed by mutlaqja into branch 'stable-3.8.4'.

AI Guide: distinguish cold vs. warm pulses in PID Auto-Tune

Every pulse in the PID Auto-Tune pulse-response sequence reversed
direction from the one before it, so there was no way to tell a real
mechanical backlash/dead-time signature (which should only show up on a
direction reversal) apart from latency that would affect any pulse
equally. Each axis/direction/magnitude now fires 3 consecutive pulses in
the same direction instead of one -- only the first is a true reversal
("cold"), the next two are "warm" (ProtocolPhase::pulseWarm, already
engaged) -- and each pulse_response session records pulse_is_reversal so
this can be analyzed later. Same total pulse count and protocol duration
as before, just reordered.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

M  +99   -37   kstars/ekos/guide/aiguideprotocol.cpp
M  +9    -0    kstars/ekos/guide/aiguideprotocol.h

https://invent.kde.org/education/kstars/-/commit/930ec119c9fc21d97c721a9f2d30ca4711f14fb6

diff --git a/kstars/ekos/guide/aiguideprotocol.cpp b/kstars/ekos/guide/aiguideprotocol.cpp
index ff9c706aa4..5c1ed370c1 100644
--- a/kstars/ekos/guide/aiguideprotocol.cpp
+++ b/kstars/ekos/guide/aiguideprotocol.cpp
@@ -223,19 +223,40 @@ void AIGuideProtocol::start(const QString &mountType)
         // 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.
+        // Each axis/direction/magnitude fires 3 consecutive pulses in the same
+        // direction rather than one; only the first is a true reversal (the previous
+        // combo left the mount going the other way), so it's "cold", while the next
+        // two are "warm" (ProtocolPhase::pulseWarm, already engaged, no direction
+        // change since the last pulse). Comparing cold vs. warm dead-time tells real
+        // mechanical backlash apart from latency that would affect both equally --
+        // this needs no extra pulses or protocol time over the old one-pulse-per-combo
+        // design, just this reordering.
         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, 0, false, true, "RA", "EAST",   500, 12, 10});
+            m_Phases.append({65.0, -45.0, 0, false, true, "RA", "EAST",   500, 12, 10, true});
+            m_Phases.append({65.0, -45.0, 0, false, true, "RA", "EAST",   500, 12, 10, true});
+            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", "WEST",   500, 12, 10, true});
+            m_Phases.append({65.0, -45.0, 0, false, true, "RA", "WEST",   500, 12, 10, true});
+            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", "EAST",  1000, 12, 10, true});
+            m_Phases.append({65.0, -45.0, 0, false, true, "RA", "EAST",  1000, 12, 10, true});
+            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, "RA", "WEST",  1000, 12, 10, true});
+            m_Phases.append({65.0, -45.0, 0, false, true, "RA", "WEST",  1000, 12, 10, true});
+            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", "NORTH",  500, 12, 10, true});
+            m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "NORTH",  500, 12, 10, true});
+            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", "SOUTH",  500, 12, 10, true});
+            m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "SOUTH",  500, 12, 10, true});
+            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", "NORTH", 1000, 12, 10, true});
+            m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "NORTH", 1000, 12, 10, true});
+            m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "SOUTH", 1000, 12, 10});
+            m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "SOUTH", 1000, 12, 10, true});
+            m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "SOUTH", 1000, 12, 10, true});
         }
 
         m_Phases.append({65.0, -45.0, 480, false, false, "", "", 0, 0, 0});
@@ -263,20 +284,39 @@ void AIGuideProtocol::start(const QString &mountType)
         // 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).
+        // no new slew (runs at Position 1's sky location). Each axis/direction/
+        // magnitude fires 3 consecutive same-direction pulses instead of one: only the
+        // first is a true reversal ("cold"), the next two are "warm" (ProtocolPhase::
+        // pulseWarm, already engaged) -- same total pulse count and protocol time as
+        // before, just reordered so cold vs. warm dead-time can be compared directly.
+        // See pulse_response_fit.py's KAPPA_MAX comment for why the disabled kappa/tau
+        // fit above couldn't make this distinction on its own.
         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, 0, false, true, "RA", "EAST",   500, 12, 10});
+            m_Phases.append({65.0, -45.0, 0, false, true, "RA", "EAST",   500, 12, 10, true});
+            m_Phases.append({65.0, -45.0, 0, false, true, "RA", "EAST",   500, 12, 10, true});
+            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", "WEST",   500, 12, 10, true});
+            m_Phases.append({65.0, -45.0, 0, false, true, "RA", "WEST",   500, 12, 10, true});
+            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", "EAST",  1000, 12, 10, true});
+            m_Phases.append({65.0, -45.0, 0, false, true, "RA", "EAST",  1000, 12, 10, true});
+            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, "RA", "WEST",  1000, 12, 10, true});
+            m_Phases.append({65.0, -45.0, 0, false, true, "RA", "WEST",  1000, 12, 10, true});
+            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", "NORTH",  500, 12, 10, true});
+            m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "NORTH",  500, 12, 10, true});
+            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", "SOUTH",  500, 12, 10, true});
+            m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "SOUTH",  500, 12, 10, true});
+            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", "NORTH", 1000, 12, 10, true});
+            m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "NORTH", 1000, 12, 10, true});
+            m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "SOUTH", 1000, 12, 10});
+            m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "SOUTH", 1000, 12, 10, true});
+            m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "SOUTH", 1000, 12, 10, true});
         }
 
         // Position 1: 1800s standard guiding (resolves PE to 900s — strain-wave
@@ -299,20 +339,37 @@ void AIGuideProtocol::start(const QString &mountType)
         // 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.
+        // protocol runs under it. On by default. Each axis/direction/magnitude fires 3
+        // consecutive same-direction pulses instead of one -- only the first is a true
+        // reversal ("cold"), the next two are "warm" (ProtocolPhase::pulseWarm) -- so a
+        // confidently-small dead-time finding can also confirm there's no cold-vs-warm
+        // gap, not just that the single-pulse number is small.
         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, 0, false, true, "RA", "EAST",   500, 12, 10});
+            m_Phases.append({70.0, 0.0, 0, false, true, "RA", "EAST",   500, 12, 10, true});
+            m_Phases.append({70.0, 0.0, 0, false, true, "RA", "EAST",   500, 12, 10, true});
+            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", "WEST",   500, 12, 10, true});
+            m_Phases.append({70.0, 0.0, 0, false, true, "RA", "WEST",   500, 12, 10, true});
+            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", "EAST",  1000, 12, 10, true});
+            m_Phases.append({70.0, 0.0, 0, false, true, "RA", "EAST",  1000, 12, 10, true});
+            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, "RA", "WEST",  1000, 12, 10, true});
+            m_Phases.append({70.0, 0.0, 0, false, true, "RA", "WEST",  1000, 12, 10, true});
+            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", "NORTH",  500, 12, 10, true});
+            m_Phases.append({70.0, 0.0, 0, false, true, "DEC", "NORTH",  500, 12, 10, true});
+            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", "SOUTH",  500, 12, 10, true});
+            m_Phases.append({70.0, 0.0, 0, false, true, "DEC", "SOUTH",  500, 12, 10, true});
+            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", "NORTH", 1000, 12, 10, true});
+            m_Phases.append({70.0, 0.0, 0, false, true, "DEC", "NORTH", 1000, 12, 10, true});
+            m_Phases.append({70.0, 0.0, 0, false, true, "DEC", "SOUTH", 1000, 12, 10});
+            m_Phases.append({70.0, 0.0, 0, false, true, "DEC", "SOUTH", 1000, 12, 10, true});
+            m_Phases.append({70.0, 0.0, 0, false, true, "DEC", "SOUTH", 1000, 12, 10, true});
         }
 
         m_Phases.append({70.0,   0.0, 120, false, false, "", "", 0, 0, 0});
@@ -971,8 +1028,9 @@ void AIGuideProtocol::processProtocol()
             m_AbortRetries = 0;
             m_GuideCallGraceTicks = 0;
             ProtocolPhase phase = m_Phases.first();
-            emit protocolLog(QString("Pulse Response: %1 %2 %3ms — preparing...")
-                             .arg(phase.pulseAxis, phase.pulseDirection).arg(phase.pulseMagnitudeMs));
+            emit protocolLog(QString("Pulse Response: %1 %2 %3ms (%4) — preparing...")
+                             .arg(phase.pulseAxis, phase.pulseDirection).arg(phase.pulseMagnitudeMs)
+                             .arg(phase.pulseWarm ? "warm, same dir as previous" : "cold/reversal"));
 
             m_Guide->setAIFreeDrift(true);
             m_PulseFrameCount = 0;
@@ -1047,6 +1105,10 @@ void AIGuideProtocol::processProtocol()
                     pulseSession["pulse_axis"] = p.pulseAxis;
                     pulseSession["pulse_direction"] = p.pulseDirection;
                     pulseSession["pulse_magnitude_ms"] = p.pulseMagnitudeMs;
+                    // false only for a "warm" pulse deliberately fired right after another
+                    // pulse of the same axis/direction/magnitude -- see ProtocolPhase::
+                    // pulseWarm. Every other pulse reverses from whatever came before it.
+                    pulseSession["pulse_is_reversal"] = !p.pulseWarm;
                     pulseSession["altitude_deg"] = m_TargetAlt;
                     pulseSession["azimuth_deg"] = m_TargetAz;
 
diff --git a/kstars/ekos/guide/aiguideprotocol.h b/kstars/ekos/guide/aiguideprotocol.h
index 01b642791c..1eee553359 100644
--- a/kstars/ekos/guide/aiguideprotocol.h
+++ b/kstars/ekos/guide/aiguideprotocol.h
@@ -153,6 +153,15 @@ class AIGuideProtocol : public QObject
             int pulseMagnitudeMs {0};
             int responseFrames {15};
             int settleSeconds {30};
+            // True for a pulse fired immediately after another pulse of the same axis/
+            // direction/magnitude (no reversal in between): the mount should already be
+            // engaged in this direction, so any dead-time/backlash gap that shows up on
+            // the preceding ("cold", reversal) pulse should not reappear here. Comparing
+            // cold vs. warm dead-time is how a real mechanical backlash/windup signature
+            // is told apart from e.g. camera/processing latency, which would affect both
+            // equally. See pulse_response_fit.py's KAPPA_MAX comment for why the previous
+            // paired-differencing approach couldn't distinguish these on its own.
+            bool pulseWarm {false};
         };
         QList<ProtocolPhase> m_Phases;
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.