[education/kstars/stable-3.8.4] kstars/ekos/guide: AI Guide: fit drift per phase instead of per banked segment
Jasem Mutlaq <[email protected]>
| Newsgroups | gmane.comp.kde.cvs |
|---|---|
| Message-ID | <[email protected]> |
Git commit f02a0f28969aa68d7493a00490c6f9eb9e227b6f 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: fit drift per phase instead of per banked segment
M +3 -2 kstars/ekos/guide/aiguideprotocol.cpp
M +69 -27 kstars/ekos/guide/offlinetrainer/train_harmonic.py
https://invent.kde.org/education/kstars/-/commit/f02a0f28969aa68d7493a00490c6f9eb9e227b6f
diff --git a/kstars/ekos/guide/aiguideprotocol.cpp b/kstars/ekos/guide/aiguideprotocol.cpp
index 5c1ed370c1..06e72b28ac 100644
--- a/kstars/ekos/guide/aiguideprotocol.cpp
+++ b/kstars/ekos/guide/aiguideprotocol.cpp
@@ -325,8 +325,9 @@ void AIGuideProtocol::start(const QString &mountType)
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});
+ // Position 2 east of the meridian: parallactic spread for the DEC refraction fit.
+ // Longer than the trainer's minimum drift span so a full-length phase clears it.
+ m_Phases.append({45.0, 45.0, 180, true, false, {}, {}, 0, 15, 20});
m_Phases.append({45.0, 45.0, 300, false, false, {}, {}, 0, 15, 20});
}
else
diff --git a/kstars/ekos/guide/offlinetrainer/train_harmonic.py b/kstars/ekos/guide/offlinetrainer/train_harmonic.py
index f3970e3e10..716c401a18 100644
--- a/kstars/ekos/guide/offlinetrainer/train_harmonic.py
+++ b/kstars/ekos/guide/offlinetrainer/train_harmonic.py
@@ -382,6 +382,9 @@ def _fit_drift_params(sysid: dict, guide_exp: float, verbose: bool):
# set k_ref_dec on real data (see DEC_OFFSET_ROOT_CAUSE.md).
MIN_FIT_SPAN_S = 120.0
+ # A rate uncertain by less than this cannot meaningfully mis-inject either way
+ SCATTER_FLOOR_PX_S = 0.02 / _effective_pixel_scale(sysid)
+
ra_rates = []
dec_rates = []
cos2_alts = [] # 1/cos²(alt) for each session
@@ -389,47 +392,86 @@ def _fit_drift_params(sysid: dict, guide_exp: float, verbose: bool):
q_angles = []
fit_alts = [] # altitudes of the sessions actually used (runtime clamps to these)
+ # Re-centering banks one drift phase as several short segments. Judged individually
+ # they all fail the span floor, so group them back by phase and fit the phase.
+ phases = {}
for s in free_drift_sessions:
- frames = s["frames"]
- if len(frames) < 10:
- continue
+ phases.setdefault(str(s.get("session_id", "?")).rsplit("_", 1)[0], []).append(s)
- # A truncated drift (stopped by the excursion guard) right after a slew measures
- # settling motion, not drift — one such point poisons the whole refraction fit.
- requested = float(s.get("duration_s", 0.0))
- span = sum(f.get("dt", guide_exp) for f in frames[1:])
- if requested > 0.0 and span < 0.5 * requested:
- if verbose:
- print(f" [drift] skipping {s.get('session_id', '?')}: only {span:.0f}s of "
- f"{requested:.0f}s requested — truncated, not a drift measurement")
+ for phase_id, segments in phases.items():
+ seg_fits = []
+ for s in segments:
+ frames = s["frames"]
+ if len(frames) < 10:
+ continue
+
+ # A truncated drift (stopped by the excursion guard) right after a slew measures
+ # settling motion, not drift — one such point poisons the whole refraction fit.
+ requested = float(s.get("duration_s", 0.0))
+ span = sum(f.get("dt", guide_exp) for f in frames[1:])
+ if requested > 0.0 and span < 0.5 * requested:
+ if verbose:
+ print(f" [drift] skipping {s.get('session_id', '?')}: only {span:.0f}s of "
+ f"{requested:.0f}s requested — truncated, not a drift measurement")
+ continue
+
+ t = 0.0
+ t_vals, ra_vals, dec_vals = [], [], []
+ q_sum = 0.0
+ for f in frames:
+ t += f.get("dt", guide_exp)
+ t_vals.append(t)
+ ra_vals.append(f["ra_raw_px"])
+ dec_vals.append(f.get("dec_raw_px", 0.0))
+ q_sum += f.get("parallactic_angle_deg", 0.0)
+
+ ra_fit = scipy.stats.linregress(t_vals, ra_vals)
+ dec_fit = scipy.stats.linregress(t_vals, dec_vals)
+ seg_fits.append((span, ra_fit.slope, ra_fit.stderr, dec_fit.slope, dec_fit.stderr,
+ s.get("altitude_deg", 45.0), q_sum / len(frames)))
+
+ if not seg_fits:
continue
- if span < MIN_FIT_SPAN_S:
+
+ total_span = sum(f[0] for f in seg_fits)
+ if total_span < MIN_FIT_SPAN_S:
if verbose:
- print(f" [drift] skipping {s.get('session_id', '?')}: {span:.0f}s span — "
- f"too short to constrain a rate (need >= {MIN_FIT_SPAN_S:.0f}s)")
+ print(f" [drift] skipping {phase_id}: {total_span:.0f}s over "
+ f"{len(seg_fits)} segment(s) — too short to constrain a rate "
+ f"(need >= {MIN_FIT_SPAN_S:.0f}s)")
continue
- alt = s.get("altitude_deg", 45.0)
+ # Inverse-variance weighting: the phase rate is better determined than any one segment
+ def _combine(slope_idx, err_idx):
+ slopes = [f[slope_idx] for f in seg_fits]
+ w = np.array([1.0 / max(f[err_idx], 1e-9) ** 2 for f in seg_fits])
+ rate = float(np.dot(slopes, w) / w.sum())
+ # Segments sample different phases of any PE, so on a strong-PE axis their
+ # slopes scatter far beyond the drift itself — that mean is aliasing, not a rate.
+ # Only worth rejecting when the scatter is also large enough to do damage.
+ if (len(slopes) > 1 and np.std(slopes) > 0.5 * abs(rate)
+ and np.std(slopes) > SCATTER_FLOOR_PX_S):
+ if verbose:
+ print(f" [drift] {phase_id}: segment rates scatter {np.std(slopes):.4g} px/s "
+ f"about {rate:.4g} px/s — periodic error, not drift. Rate = 0.")
+ return 0.0
+ return rate
+
+ ra_slope = _combine(1, 2)
+ dec_slope = _combine(3, 4)
+ alt = float(np.mean([f[5] for f in seg_fits]))
+ avg_q_deg = float(np.mean([f[6] for f in seg_fits]))
- t = 0.0
- t_vals, ra_vals, dec_vals = [], [], []
- q_sum = 0.0
- for f in frames:
- t += f.get("dt", guide_exp)
- t_vals.append(t)
- ra_vals.append(f["ra_raw_px"])
- dec_vals.append(f.get("dec_raw_px", 0.0))
- q_sum += f.get("parallactic_angle_deg", 0.0)
+ if verbose and len(seg_fits) > 1:
+ print(f" [drift] {phase_id}: {len(seg_fits)} banked segments stitched, "
+ f"{total_span:.0f}s total")
- ra_slope, _, _, _, _ = scipy.stats.linregress(t_vals, ra_vals)
- dec_slope, _, _, _, _ = scipy.stats.linregress(t_vals, dec_vals)
ra_rates.append(ra_slope)
dec_rates.append(dec_slope)
cos_alt = max(abs(np.cos(np.radians(alt))), 1e-4)
cos2_alts.append(1.0 / (cos_alt ** 2))
- avg_q_deg = q_sum / len(frames)
q_angles.append(avg_q_deg)
q_rad = np.radians(avg_q_deg)
q_factors.append(np.sin(q_rad) / (cos_alt ** 2))