[education/kstars] kstars/ekos/guide: Guide: Always calibrate single-frame; stream only while guiding

Jasem Mutlaq <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit f23d23a8141a0bf48e097dd54fa9880b0d746605 by Jasem Mutlaq, on behalf of Andreas R..
Committed on 06/08/2026 at 12:24.
Pushed by mutlaqja into branch 'master'.

Guide: Always calibrate single-frame; stream only while guiding

# Guide: Always calibrate single-frame; stream only while guiding

## Summary

In streaming guide mode, guide **calibration** produces erratic results: scattered
calibration plots, mis-estimated pulse rates, and "Lost track of the guide star"
aborts. This is most visible on cameras run at short exposures (e.g. PlayerOne
Xena 585M at 2×2, 0.7 s) where the default 1000 ms calibration pulse spans more
than one frame. This MR makes calibration always run in single-frame mode and
starts the video stream only when active guiding begins.

## Root cause

Guide calibration is **frame-driven**: each processed frame runs one
`CalibrationProcess::iterate()` that measures the star's drift from the start
point and issues exactly one pulse. The algorithm assumes each frame reflects a
**completed, settled** pulse — its escalation branches double the pulse
(`Options::calibrationPulseDuration() * 2`) when the star moved < 0.5 px between
consecutive frames.

Streaming delivers frames continuously with no synchronization between a pulse
and the frame that measures its effect. With `CalibrationPulseDuration` (default
1000 ms) longer than the exposure:

1. a fresh pulse is issued on nearly every frame, so pulses overlap and the mount
   never settles;
2. the exposure integrates star motion *during* the pulse, smearing the star and
   producing a noisy centroid — the scattered points seen on the plot;
3. erratic frame-to-frame deltas mis-trip the "star barely moved → 2× pulse"
   branch, overshooting and flinging the star out of the tracking box.

Single-frame calibration does not have this problem: its cycle is strictly serial
(expose → read → detect → pulse → settle → delay → expose), i.e. exactly one
settled observation per pulse. The extra readout latency is a *time* cost only;
calibration is an open-loop measurement, so a slower, settled cadence is
harmless (indeed preferable). Latency only hurts *active guiding*, which is a
closed loop — and that is exactly where streaming helps.

## Fix

Streaming provides no benefit during calibration and full benefit during guiding,
so simply don't stream while calibrating:

- `Guide::calibrate()` stops any active stream at the top, **before**
  `buildOperationStack()`, so the whole calibration (including the initial
  capture / star-select and dark/subframe handling) runs on the single-frame
  path:

  ```cpp
  if (guiderType == GUIDE_INTERNAL && m_StreamingGuide)
      stopGuideStreaming();
  ```

- The `GUIDE_CALIBRATING` state handler no longer starts the stream; it stops it
  defensively as a safeguard.
- The `GUIDE_GUIDING` handler is unchanged — it starts the stream on fresh
  guiding entry when the user enabled it.

Because `startGuideStreaming()` is only reached from `Guide::setStatus()`
(`GUIDE_GUIDING`) and `Guide::loop()`, and every guide initiator (manual button,
scheduler D-Bus `Guide::guide()`, EkosLive) funnels through
`guide()` → `calibrate()` → the guider FSM → the single `newStatus` →
`Guide::setStatus()` connection, the result is guaranteed for all of them:
**calibration is always single-frame; guiding streams iff the checkbox is set.**

This obsoletes the calibration carve-out added in !1778 (excluding
`GUIDE_CALIBRATING` from the streaming pulse-guard): calibration no longer
streams at all, so that exclusion is dead and the pulse-guard reverts to its
original form (gating only closed-loop correction pulses).

## Testing

- Builds clean (`KStarsLib` / `guide.cpp`); astyle-clean.
- **On-sky confirmed** (PlayerOne Xena 585M, 2×2, 0.7 s exposure): calibration
  now runs at a steady single-frame cadence with a clean plot, then the stream
  starts automatically for guiding — verified both manually and via a
  scheduler-driven job with the streaming checkbox enabled. Log tell: "Guide
  streaming started" appears only after "Calibration completed", never at
  "Calibration started".

## Files

- `kstars/ekos/guide/guide.cpp`

M  +23   -18   kstars/ekos/guide/guide.cpp

https://invent.kde.org/education/kstars/-/commit/f23d23a8141a0bf48e097dd54fa9880b0d746605

diff --git a/kstars/ekos/guide/guide.cpp b/kstars/ekos/guide/guide.cpp
index f019c03e98..8dae2d0d64 100644
--- a/kstars/ekos/guide/guide.cpp
+++ b/kstars/ekos/guide/guide.cpp
@@ -1543,7 +1543,7 @@ bool Guide::sendMultiPulse(GuideDirection ra_dir, int ra_msecs, GuideDirection d
 
         m_PulseTimer.start(delay);
     }
-    else if (m_StreamingGuide && m_State != GUIDE_CALIBRATING)
+    else if (m_StreamingGuide)
     {
         // In streaming mode frames keep arriving continuously, so we must gate them
         // while the mount is still responding to this pulse.  Without this guard every
@@ -1551,12 +1551,8 @@ bool Guide::sendMultiPulse(GuideDirection ra_dir, int ra_msecs, GuideDirection d
         // rapid-fire overlapping pulses that produce oscillations.
         // The gate duration is the longer of the two pulse lengths plus a small
         // propagation margin, floored by the user's guide delay setting.
-        //
-        // Calibration is deliberately excluded: it is frame-driven and must observe the
-        // star move through every pulse to measure the mount response.  Discarding frames
-        // during calibration loses star tracking and makes the drift look stalled, which
-        // pushes calibration into its "double the pulse" branch and flings the star out
-        // of the box ("Lost track of the guide star").
+        // (Calibration never streams — see Guide::calibrate() — so this only ever gates
+        // closed-loop correction pulses.)
         auto ms = std::max(ra_msecs, dec_msecs) + 100;
         auto delay = std::max(static_cast<int>(guideDelay->value() * 1000), ms);
         qCDebug(KSTARS_EKOS_GUIDE) << "Streaming pulse guard started for" << delay << "ms";
@@ -1610,11 +1606,11 @@ bool Guide::sendSinglePulse(GuideDirection dir, int msecs, CaptureAfterPulses fo
 
         m_PulseTimer.start(delay);
     }
-    else if (m_StreamingGuide && followWithCapture == DontCaptureAfterPulses && m_State != GUIDE_CALIBRATING)
+    else if (m_StreamingGuide && followWithCapture == DontCaptureAfterPulses)
     {
         // Same pulse-in-flight gate as sendMultiPulse() above — correction pulses only.
-        // Calibration is excluded on purpose (see sendMultiPulse): its per-axis pulses need
-        // continuous frames so the star stays tracked while it drifts across the field.
+        // (Calibration never streams — see Guide::calibrate() — so this only gates the
+        // closed-loop guiding correction pulses.)
         auto ms = msecs + 100;
         auto delay = std::max(static_cast<int>(guideDelay->value() * 1000), ms);
         qCDebug(KSTARS_EKOS_GUIDE) << "Streaming pulse guard started for" << delay << "ms (single pulse)";
@@ -1665,6 +1661,17 @@ bool Guide::calibrate()
         }
     }
 
+    // Calibration must always run in single-frame mode. It is frame-driven and issues one pulse
+    // per frame, expecting each measurement to reflect a completed, settled pulse. Streaming
+    // delivers frames continuously with no pulse-to-frame synchronization, so the star is still
+    // moving (and smearing) when the next frame is measured, corrupting the per-step drift and
+    // tripping the "double the pulse" escalation. Stop any active stream here, before the
+    // operation stack is built, so the single-frame capture path (and the correct dark/subframe
+    // handling in buildOperationStack) is used throughout. Streaming is (re)started on entry to
+    // GUIDE_GUIDING if the user enabled it.
+    if (guiderType == GUIDE_INTERNAL && m_StreamingGuide)
+        stopGuideStreaming();
+
     buildOperationStack(GUIDE_CALIBRATING);
 
     executeOperationStack();
@@ -2056,14 +2063,12 @@ void Guide::setStatus(Ekos::GuideState newState)
             appendLogText(i18n("Calibration started."));
             setBusy(true);
             manualPulseB->setEnabled(false);
-            // Start streaming for calibration if enabled and camera supports it.
-            // Guard against double-starting in case we are already streaming.
-            if (guiderType == GUIDE_INTERNAL && m_Camera && !m_StreamingGuide)
-            {
-                auto streamingCheckbox = findChild<QCheckBox *>("guideStreamingEnabled");
-                if (streamingCheckbox && streamingCheckbox->isChecked() && m_Camera->hasVideoStream())
-                    startGuideStreaming();
-            }
+            // Calibration always runs single-frame (see Guide::calibrate()). Do NOT start
+            // streaming here regardless of the streaming checkbox — streaming has no benefit
+            // during calibration and desynchronizes pulses from frames. As a safeguard against
+            // any path that reaches this state with a stream still active, stop it now.
+            if (guiderType == GUIDE_INTERNAL && m_StreamingGuide)
+                stopGuideStreaming();
             break;
 
         case GUIDE_GUIDING:
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.