[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>