[education/kstars/stable-3.8.4] kstars: Ai guide robustness

Jasem Mutlaq <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit 9a0a8256f4645e4e0de41c961e71acc2e9dd863c by Jasem Mutlaq, on behalf of Pavan Kumar S G.
Committed on 15/08/2026 at 11:41.
Pushed by mutlaqja into branch 'stable-3.8.4'.

Ai guide robustness

M  +20   -0    kstars/ekos/guide/internalguide/gmath.cpp
M  +3    -0    kstars/ekos/guide/internalguide/gmath.h
M  +21   -5    kstars/ekos/guide/internalguide/worm_gear_guider.cpp
M  +5    -0    kstars/ekos/guide/internalguide/worm_gear_guider.h
M  +29   -12   kstars/ekos/guide/offlinetrainer/train_worm_gear.py
M  +35   -0    kstars/ekos/guide/offlinetrainer/validate_sysid.py
M  +1    -1    kstars/kstars.kcfg

https://invent.kde.org/education/kstars/-/commit/9a0a8256f4645e4e0de41c961e71acc2e9dd863c

diff --git a/kstars/ekos/guide/internalguide/gmath.cpp b/kstars/ekos/guide/internalguide/gmath.cpp
index c442457cc2..46a7df5686 100644
--- a/kstars/ekos/guide/internalguide/gmath.cpp
+++ b/kstars/ekos/guide/internalguide/gmath.cpp
@@ -566,6 +566,14 @@ void cgmath::outputGuideLog()
     }
 }
 
+double cgmath::recentDriftRms(int k) const
+{
+    double sumSq = 0;
+    for (int i = 0; i < CIRCULAR_BUFFER_SIZE; ++i)
+        sumSq += drift[k][i] * drift[k][i];
+    return std::sqrt(sumSq / CIRCULAR_BUFFER_SIZE);
+}
+
 void cgmath::processAxis(const int k, const bool dithering, const bool darkGuide, const Seconds &timeStep,
                          const QString &label)
 {
@@ -630,6 +638,9 @@ void cgmath::processAxis(const int k, const bool dithering, const bool darkGuide
         if (ai_out.valid)
         {
             double ai_pulse_arcsec = (k == GUIDE_RA) ? ai_out.ra_correction_arcsec : ai_out.dec_correction_arcsec;
+            const double aiLimit = std::max(3.0 * recentDriftRms(k), 1.0);
+            if (std::abs(ai_pulse_arcsec) > aiLimit)
+                ai_pulse_arcsec = std::copysign(aiLimit, ai_pulse_arcsec);
             const double aiGain = Options::aIPredictionGain();
             const double aiResponse = ai_pulse_arcsec * pulseConverter;
             double total = aiGain * ai_out.confidence * aiResponse;
@@ -725,6 +736,15 @@ void cgmath::processAxis(const int k, const bool dithering, const bool darkGuide
         double ai_pulse_arcsec = (k == GUIDE_RA) ?
                                  m_lastAIPrediction.ra_correction_arcsec :
                                  m_lastAIPrediction.dec_correction_arcsec;
+        // Never trust a prediction far beyond the scale of anything recently measured.
+        const double aiLimit = std::max(3.0 * recentDriftRms(k), 1.0);
+        if (std::abs(ai_pulse_arcsec) > aiLimit)
+        {
+            qCDebug(KSTARS_EKOS_GUIDE) << QString("[AI GUIDER] Clamping %1 prediction %2\" to %3\"")
+                                       .arg(k == GUIDE_RA ? "RA" : "DEC")
+                                       .arg(ai_pulse_arcsec, 0, 'f', 1).arg(aiLimit, 0, 'f', 1);
+            ai_pulse_arcsec = std::copysign(aiLimit, ai_pulse_arcsec);
+        }
         const double conf = m_lastAIPrediction.confidence;
         const double aiGain = Options::aIPredictionGain();
 
diff --git a/kstars/ekos/guide/internalguide/gmath.h b/kstars/ekos/guide/internalguide/gmath.h
index e75cc676fb..7607b5580f 100644
--- a/kstars/ekos/guide/internalguide/gmath.h
+++ b/kstars/ekos/guide/internalguide/gmath.h
@@ -314,6 +314,9 @@ class cgmath : public QObject
 
         double drift_integral[2];
 
+        // RMS of the recent drift buffer, used to sanity-limit AI feed-forward.
+        double recentDriftRms(int k) const;
+
         // overlays...
         cproc_in_params in_params;
         cproc_out_params out_params;
diff --git a/kstars/ekos/guide/internalguide/worm_gear_guider.cpp b/kstars/ekos/guide/internalguide/worm_gear_guider.cpp
index ee56afa9e8..e5bf4a1d6e 100644
--- a/kstars/ekos/guide/internalguide/worm_gear_guider.cpp
+++ b/kstars/ekos/guide/internalguide/worm_gear_guider.cpp
@@ -129,6 +129,7 @@ bool WormGearGuider::loadWeights(const QString &weightsPath)
 void WormGearGuider::resetSession(bool forceReset)
 {
     m_confidence = 0.0;
+    m_isStable = false;
     m_lastDt = 2.0;
     m_lastAltRad = M_PI / 4.0;
     m_lastSessionSec = 0.0;
@@ -197,7 +198,8 @@ GuideOutput WormGearGuider::predict(const GuideFrameData &frame)
                                 m_lastPierSide);
 
     GuideOutput out;
-    out.valid      = (m_frameCount > warmupFrames());
+    // Hold the AI out of an unstable loop.
+    out.valid      = (m_frameCount > warmupFrames()) && m_isStable;
     out.confidence = m_confidence;
 
     m_lastPredDriftRA  = phys_ra + mlp_out[0];
@@ -358,11 +360,17 @@ void WormGearGuider::updateConfidence(double innovRA, double innovDec, double sn
     const double dec_rms = axisRms(m_innovDec);
     const double innov_rms = std::sqrt((ra_rms * ra_rms + dec_rms * dec_rms) / 2.0);
 
+    // Absolute innovation rate, independent of the adaptive baseline.
+    const double innov_arcsec_per_sec = innov_rms * m_lastPixelScale / std::max(0.5, m_lastDt);
+    m_isStable = innov_arcsec_per_sec < INSTABILITY_ARCSEC_PER_SEC &&
+                 m_innovRA.size() >= INNOV_WINDOW / 2;
+
     // Use EMA after warmup so m_typicalRMS adapts to changing conditions
     // (seeing changes, altitude changes) instead of staying frozen from warmup.
+    // Adapt on calm frames only, so the baseline never learns instability as normal.
     if (m_frameCount <= warmupFrames())
         m_typicalRMS = std::max(0.05, innov_rms);
-    else
+    else if (innov_rms < m_typicalRMS * 1.5)
         m_typicalRMS = 0.99 * m_typicalRMS + 0.01 * innov_rms;
 
     const double error_ratio = innov_rms / (m_typicalRMS + 1e-6);
@@ -381,8 +389,16 @@ void WormGearGuider::updateConfidence(double innovRA, double innovDec, double sn
     // Lorentzian confidence instead of exponential.
     // exp(-r) gives ~0.37 at r=1, crushing the AI contribution.
     // 1/(1+r²) gives ~0.50 at r=1, allowing meaningful feed-forward.
-    const double prediction_quality = 1.0 / (1.0 + error_ratio * error_ratio);
-    m_confidence = std::clamp(warmup_factor * snr_factor * prediction_quality, 0.0, 1.0);
+    double prediction_quality = 1.0 / (1.0 + error_ratio * error_ratio);
+
+    // Collapse on absolute innovation too; the ratio is blind to a poisoned baseline.
+    const double instability = innov_arcsec_per_sec / INSTABILITY_ARCSEC_PER_SEC;
+    if (instability > 1.0)
+        prediction_quality /= instability * instability;
+
+    // Falls freely, rebuilds slowly: a briefly quiet star must not restore full trust.
+    const double target = std::clamp(warmup_factor * snr_factor * prediction_quality, 0.0, 1.0);
+    m_confidence = std::min(target, m_confidence + CONF_RISE_PER_FRAME);
 }
 
 GuideOutput WormGearGuider::darkPredict(double dt_sec)
@@ -403,7 +419,7 @@ GuideOutput WormGearGuider::darkPredict(double dt_sec)
                                 m_lastPierSide);
 
     GuideOutput out;
-    out.valid      = (m_frameCount > warmupFrames());
+    out.valid      = (m_frameCount > warmupFrames()) && m_isStable;
     out.confidence = m_confidence; // Use last known confidence
 
     // We do NOT update m_lastPredDriftRA because there's no actual frame
diff --git a/kstars/ekos/guide/internalguide/worm_gear_guider.h b/kstars/ekos/guide/internalguide/worm_gear_guider.h
index bf6f4bf8c7..3fe8b46e44 100644
--- a/kstars/ekos/guide/internalguide/worm_gear_guider.h
+++ b/kstars/ekos/guide/internalguide/worm_gear_guider.h
@@ -104,9 +104,14 @@ class WormGearGuider : public MountSpecificGuider
         double m_lastDECPulseMs   { 0.0 };
         float  m_lastPierSide     { 1.0f };
         bool   m_hasLastPred      { false };
+        bool   m_isStable         { false };
         std::deque<double> m_innovRA;
         std::deque<double> m_innovDec;
         static constexpr int INNOV_WINDOW = 20;
+        /// Windowed innovation above this rate means the loop is unstable, not noisy.
+        static constexpr double INSTABILITY_ARCSEC_PER_SEC = 1.5;
+        /// Confidence falls freely but may rise at most this much per frame.
+        static constexpr double CONF_RISE_PER_FRAME = 0.05;
 
         // ── Helpers ───────────────────────────────────────────────────────────
         double physicsRA(double t_sec, double altitude_deg) const;
diff --git a/kstars/ekos/guide/offlinetrainer/train_worm_gear.py b/kstars/ekos/guide/offlinetrainer/train_worm_gear.py
index 3c8b474d54..d5301900ce 100644
--- a/kstars/ekos/guide/offlinetrainer/train_worm_gear.py
+++ b/kstars/ekos/guide/offlinetrainer/train_worm_gear.py
@@ -214,6 +214,13 @@ def _estimate_pe_from_fft(sysid: dict, guide_exp: float, verbose: bool):
     peak_idx = np.argmax(Pxx_valid)
     best_f = f_valid[peak_idx]
     best_period = 1.0 / best_f
+
+    # A weak peak means no usable PE line; the argmax is noise or a harmonic.
+    far = np.abs(f_valid - best_f) > 0.2 * best_f
+    dominance = Pxx_valid[peak_idx] / max(Pxx_valid[far].max(), 1e-9)
+    if dominance < 2.0:
+        print(f"  [FFT] WARNING: no dominant PE line (peak/far-field ratio {dominance:.2f}) -- "
+              f"period {best_period:.1f}s is unreliable; free drift may not have truly drifted.")
     
     if known_period is not None and known_period > 0:
         if verbose:
@@ -371,6 +378,7 @@ def _build_training_dataset(sysid, pe_period, pe_amplitude, k_ref, d_ra_extra, d
     
     X_all = []
     Y_all = []
+    W_all = []  # per-sample [ra, dec] loss weights; dec=0 drops contaminated supervision
     
     pixel_scale = _effective_pixel_scale(sysid)
     
@@ -391,6 +399,13 @@ def _build_training_dataset(sysid, pe_period, pe_amplitude, k_ref, d_ra_extra, d
         # Load calibration rates (fallback to large number to prevent division by zero)
         ra_cal = s.get("ra_ms_per_arcsec", 1000.0)
         dec_cal = s.get("dec_ms_per_arcsec", 1000.0)
+
+        # DEC rate far below RA means the calibration ate backlash; drop its DEC targets.
+        dec_suspect = ("dec_ms_per_arcsec" in s and "ra_ms_per_arcsec" in s
+                       and dec_cal > 2.0 * ra_cal)
+        if dec_suspect:
+            print(f"  [Session {s_idx}] WARNING: dec_ms_per_arcsec={dec_cal:.0f} vs ra={ra_cal:.0f} "
+                  f"-- backlash-poisoned DEC calibration, dropping this session's DEC targets.")
         
         max_ra_pulse_ms = s.get("max_pulse_ra_arcsec", 2.5) * ra_cal
         max_dec_pulse_ms = s.get("max_pulse_dec_arcsec", 2.5) * dec_cal
@@ -513,6 +528,7 @@ def _build_training_dataset(sysid, pe_period, pe_amplitude, k_ref, d_ra_extra, d
             
             X_all.append(x)
             Y_all.append([target_ra, target_dec])
+            W_all.append([1.0, 0.0 if dec_suspect else 1.0])
             
     if total_frames > 0:
         sat_pct = (saturated_frames / total_frames) * 100.0
@@ -523,28 +539,29 @@ def _build_training_dataset(sysid, pe_period, pe_amplitude, k_ref, d_ra_extra, d
             print("Your mount is constantly struggling. Please increase your Max Pulse setting in")
             print("the Guide Options, or check your polar alignment and balance.\n")
 
-    return np.array(X_all, dtype=np.float32), np.array(Y_all, dtype=np.float32)
+    return np.array(X_all, dtype=np.float32), np.array(Y_all, dtype=np.float32), np.array(W_all, dtype=np.float32)
 
 def _train_residual_mlp(sysid, pe_period, pe_amplitude, k_ref, d_ra_extra, d_polar, k_ref_dec, gpu, epochs, verbose) -> dict:
     epochs = epochs or 300
     
-    X_np, Y_np = _build_training_dataset(sysid, pe_period, pe_amplitude, k_ref, d_ra_extra, d_polar, k_ref_dec, verbose)
+    X_np, Y_np, W_np = _build_training_dataset(sysid, pe_period, pe_amplitude, k_ref, d_ra_extra, d_polar, k_ref_dec, verbose)
     if len(X_np) < 50:
         if verbose: print("[WARNING] Insufficient data for MLP training. Returning zero weights.")
         return _zero_weights()
-        
+
     # Simple split (last 20% is validation)
     split_idx = int(0.8 * len(X_np))
-    X_train, Y_train = torch.tensor(X_np[:split_idx]), torch.tensor(Y_np[:split_idx])
-    X_val, Y_val = torch.tensor(X_np[split_idx:]), torch.tensor(Y_np[split_idx:])
-    
+    X_train, Y_train, W_train = torch.tensor(X_np[:split_idx]), torch.tensor(Y_np[:split_idx]), torch.tensor(W_np[:split_idx])
+    X_val, Y_val, W_val = torch.tensor(X_np[split_idx:]), torch.tensor(Y_np[split_idx:]), torch.tensor(W_np[split_idx:])
+
     device = torch.device("cuda" if gpu and torch.cuda.is_available() else "cpu")
     model = ResidualMLP().to(device)
-    X_train, Y_train = X_train.to(device), Y_train.to(device)
-    X_val, Y_val = X_val.to(device), Y_val.to(device)
-    
+    X_train, Y_train, W_train = X_train.to(device), Y_train.to(device), W_train.to(device)
+    X_val, Y_val, W_val = X_val.to(device), Y_val.to(device), W_val.to(device)
+
     optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4)
-    criterion = nn.MSELoss()
+    # MSE with per-sample [ra, dec] weights so contaminated DEC targets carry no gradient.
+    criterion = lambda pred, target, w: ((pred - target) ** 2 * w).sum() / w.sum().clamp_min(1.0)
     scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)
     
     best_val_loss = float('inf')
@@ -559,7 +576,7 @@ def _train_residual_mlp(sysid, pe_period, pe_amplitude, k_ref, d_ra_extra, d_pol
         model.train()
         optimizer.zero_grad()
         pred = model(X_train)
-        loss = criterion(pred, Y_train)
+        loss = criterion(pred, Y_train, W_train)
         loss.backward()
         
         # Gradient clipping
@@ -570,7 +587,7 @@ def _train_residual_mlp(sysid, pe_period, pe_amplitude, k_ref, d_ra_extra, d_pol
         model.eval()
         with torch.no_grad():
             val_pred = model(X_val)
-            val_loss = criterion(val_pred, Y_val)
+            val_loss = criterion(val_pred, Y_val, W_val)
             
         if val_loss.item() < best_val_loss:
             best_val_loss = val_loss.item()
diff --git a/kstars/ekos/guide/offlinetrainer/validate_sysid.py b/kstars/ekos/guide/offlinetrainer/validate_sysid.py
index 72d1f679e4..a7cf91b220 100644
--- a/kstars/ekos/guide/offlinetrainer/validate_sysid.py
+++ b/kstars/ekos/guide/offlinetrainer/validate_sysid.py
@@ -225,6 +225,41 @@ def _check_artifacts(sysid, verbose):
             findings.append(("MEDIUM", f"{lost_pct:.1f}% of frames flagged star-lost across the run "
                                         f"-- check guide star SNR/exposure for this rig"))
 
+    # DEC rate far below RA means the calibration ate DEC backlash.
+    suspect_cal = []
+    for s in sessions:
+        if s.get("type") not in ("free_drift", "standard_guiding"):
+            continue
+        ra_cal, dec_cal = s.get("ra_ms_per_arcsec"), s.get("dec_ms_per_arcsec")
+        if ra_cal and dec_cal and dec_cal > 2.0 * ra_cal:
+            suspect_cal.append(f"{s.get('session_id', '?')} (dec={dec_cal:.0f} vs ra={ra_cal:.0f} ms/\")")
+    if suspect_cal:
+        findings.append(("HIGH", f"backlash-poisoned DEC calibration in {len(suspect_cal)} guiding "
+                                 f"session(s): {', '.join(suspect_cal)} -- recalibrate DEC (rate "
+                                 f"should roughly match RA) and re-collect before training"))
+
+    # PE line check on the longest free drift (the one train_worm_gear's FFT uses).
+    fd = [s for s in sessions if s.get("type") == "free_drift" and len(s.get("frames", [])) >= 60]
+    if fd:
+        import scipy.signal
+        import scipy.stats
+        s = max(fd, key=lambda x: len(x["frames"]))
+        t = np.cumsum([f.get("dt", 2.0) for f in s["frames"]])
+        ra = np.array([f["ra_raw_px"] for f in s["frames"]])
+        slope, ic, _, _, _ = scipy.stats.linregress(t, ra)
+        fv = np.linspace(0.001, 0.03, 2000)
+        P = scipy.signal.lombscargle(t, ra - (slope * t + ic), 2 * np.pi * fv, precenter=True)
+        pk = np.argmax(P)
+        far = np.abs(fv - fv[pk]) > 0.2 * fv[pk]
+        dom = P[pk] / max(P[far].max(), 1e-9)
+        print(f"  Free-drift PE line: peak {1.0 / fv[pk]:.0f}s, dominance {dom:.2f} "
+              f"(from {s.get('session_id', '?')})")
+        if dom < 2.0:
+            findings.append(("HIGH", f"no dominant PE line in the longest free drift "
+                                     f"(dominance {dom:.2f}) -- the period estimate is unreliable; "
+                                     f"check whether the drift phase actually guided (pulses "
+                                     f"executed) and re-collect"))
+
     # Pier-side and sky coverage -- directly relevant both to training (drift/refraction fit
     # validity range) and to planning the later live-evaluation position matrix.
     pier_sides = {s.get("pier_side") for s in sessions if s.get("pier_side")}
diff --git a/kstars/kstars.kcfg b/kstars/kstars.kcfg
index e0de84d097..ed38b298de 100644
--- a/kstars/kstars.kcfg
+++ b/kstars/kstars.kcfg
@@ -3120,7 +3120,7 @@
       </entry>
       <entry name="AIProportionalBackoff" type="Bool">
          <label>Dynamically scale down standard proportional gain when AI is highly confident.</label>
-         <default>false</default>
+         <default>true</default>
       </entry>
       <entry name="AIPIDAutoTune" type="Bool">
          <label>Run PID Auto-Tune (step-response gain calibration) as part of the AI data collection protocol.</label>
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.