[education/kstars] kstars: Ai guider improvements
Jasem Mutlaq <[email protected]>
| Newsgroups | gmane.comp.kde.cvs |
|---|---|
| Message-ID | <[email protected]> |
Git commit d721cca0d3c426bc2b625d29b84965d1c85ec3a2 by Jasem Mutlaq, on behalf of Pavan Kumar S G.
Committed on 30/07/2026 at 18:03.
Pushed by mutlaqja into branch 'master'.
Ai guider improvements
M +1 -0 kstars/CMakeLists.txt
M +48 -11 kstars/ekos/guide/aiguideprotocol.cpp
M +7 -0 kstars/ekos/guide/aiguideprotocol.h
A +123 -0 kstars/ekos/guide/internalguide/assistant_stats.cpp [License: GPL(v2.0+)]
A +67 -0 kstars/ekos/guide/internalguide/assistant_stats.h [License: GPL(v2.0+)]
M +18 -1 kstars/ekos/guide/offlinetrainer/train_direct_drive.py
M +132 -43 kstars/ekos/guide/offlinetrainer/train_harmonic.py
M +19 -2 kstars/ekos/guide/offlinetrainer/train_worm_gear.py
M +10 -0 kstars/ekos/guide/opsaiconfig.ui
M +5 -0 kstars/kstars.kcfg
https://invent.kde.org/education/kstars/-/commit/d721cca0d3c426bc2b625d29b84965d1c85ec3a2
diff --git a/kstars/CMakeLists.txt b/kstars/CMakeLists.txt
index c9861e976d..5cd95925c7 100644
--- a/kstars/CMakeLists.txt
+++ b/kstars/CMakeLists.txt
@@ -401,6 +401,7 @@ if (INDI_FOUND)
ekos/guide/guidetargetplot.cpp
ekos/guide/manualpulse.cpp
# Internal Guide
+ ekos/guide/internalguide/assistant_stats.cpp
ekos/guide/internalguide/gmath.cpp
ekos/guide/internalguide/worm_gear_guider.cpp
ekos/guide/internalguide/direct_drive_guider.cpp
diff --git a/kstars/ekos/guide/aiguideprotocol.cpp b/kstars/ekos/guide/aiguideprotocol.cpp
index 5c42ec7ca6..0b387709a2 100644
--- a/kstars/ekos/guide/aiguideprotocol.cpp
+++ b/kstars/ekos/guide/aiguideprotocol.cpp
@@ -108,7 +108,9 @@ void AIGuideProtocol::start(const QString &mountType)
equipment["mount_name"] = m_Guide->mount()->getDeviceName();
if (m_Guide->focalLength() > 0)
{
- equipment["pixel_scale_arcsec_per_px"] = (206.265 * m_Guide->pixelSizeX()) / m_Guide->focalLength();
+ const int binning = std::max(1, Options::guideBinning().left(1).toInt());
+ equipment["pixel_scale_arcsec_per_px"] = (206.265 * m_Guide->pixelSizeX() * binning) / m_Guide->focalLength();
+ equipment["pixel_scale_includes_binning"] = true;
if (std::abs(m_Guide->focalLength() - Options::telescopeFocalLength()) < 1.0)
equipment["guide_optics_type"] = "OAG";
else
@@ -158,17 +160,21 @@ void AIGuideProtocol::start(const QString &mountType)
m_Phases.append({65.0, -45.0, 720, false, false, {}, {}, 0, 15, 20});
m_Phases.append({65.0, -45.0, 480, true, false, {}, {}, 0, 15, 20});
- // Pulse response: large pulses, alternating direction so drift/PE cancel in pairing
- for (int rep = 0; rep < 3; rep++)
+ // Pulse response: large pulses, alternating direction so drift/PE cancel in pairing.
+ // Off by default: the spring response has not been measurable on rigs tested so far.
+ if (Options::aIProtocolPulseTest())
{
- m_Phases.append({65.0, -45.0, 0, false, true, "RA", "EAST", 500, 15, 20});
- m_Phases.append({65.0, -45.0, 0, false, true, "RA", "WEST", 500, 15, 20});
- m_Phases.append({65.0, -45.0, 0, false, true, "RA", "EAST", 1000, 15, 20});
- m_Phases.append({65.0, -45.0, 0, false, true, "RA", "WEST", 1000, 15, 20});
- m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "NORTH", 500, 15, 20});
- m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "SOUTH", 500, 15, 20});
- m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "NORTH", 1000, 15, 20});
- m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "SOUTH", 1000, 15, 20});
+ for (int rep = 0; rep < 3; rep++)
+ {
+ m_Phases.append({65.0, -45.0, 0, false, true, "RA", "EAST", 500, 15, 20});
+ m_Phases.append({65.0, -45.0, 0, false, true, "RA", "WEST", 500, 15, 20});
+ m_Phases.append({65.0, -45.0, 0, false, true, "RA", "EAST", 1000, 15, 20});
+ m_Phases.append({65.0, -45.0, 0, false, true, "RA", "WEST", 1000, 15, 20});
+ m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "NORTH", 500, 15, 20});
+ m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "SOUTH", 500, 15, 20});
+ m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "NORTH", 1000, 15, 20});
+ m_Phases.append({65.0, -45.0, 0, false, true, "DEC", "SOUTH", 1000, 15, 20});
+ }
}
// Position 2 east of the meridian: parallactic spread for the DEC refraction fit
@@ -424,6 +430,10 @@ void AIGuideProtocol::processProtocol()
m_AbortRetries = 0;
m_FreeDriftOverflow = false;
+ m_NoiseHPF.configure(1.0, m_Guide ? m_Guide->exposure() : 1.0);
+ m_NoiseStats.reset();
+ m_NoiseFrameCount = 0;
+
m_Guide->setAIFreeDrift(phase.freeDrift);
m_PhaseData = QJsonArray();
@@ -532,6 +542,19 @@ void AIGuideProtocol::processProtocol()
}
}
+ if (m_NoiseStats.count() > 30)
+ {
+ const double hf = m_NoiseStats.sigma();
+ phaseRecord["hf_motion_arcsec"] = hf;
+ // Free drift is the clean (unguided) measurement; prefer it globally
+ if (phase.freeDrift || !m_SysIdData.contains("noise_floor_arcsec"))
+ m_SysIdData["noise_floor_arcsec"] = hf;
+ emit protocolLog(QString("Phase noise floor: %1\" HF star motion (%2).")
+ .arg(hf, 0, 'f', 2)
+ .arg(phase.freeDrift ? "unguided — clean measurement"
+ : "guided — upper bound"));
+ }
+
phaseRecord["frames"] = m_PhaseData;
QJsonArray sessions = m_SysIdData["sessions"].toArray();
@@ -808,6 +831,20 @@ void AIGuideProtocol::onGuideStats(double raErr, double decErr, int raPulse, int
frame["ra_pulse_ms"] = raPulse;
frame["dec_pulse_ms"] = decPulse;
m_PhaseData.append(frame);
+
+ // Live noise floor: HPF removes drift/PE, sigma of the remainder is the
+ // unguidable high-frequency star motion (seeing + centroid error).
+ if (snr > 0.0)
+ {
+ const double hf = m_NoiseHPF.addValue(raErr);
+ m_NoiseFrameCount++;
+ if (m_NoiseFrameCount > 5)
+ m_NoiseStats.add(m_NoiseFrameCount, hf);
+ if (m_NoiseFrameCount % 60 == 0 && m_NoiseStats.count() > 30)
+ emit protocolLog(QString("Measured noise floor (HF star motion): %1\" — "
+ "guiding cannot correct below this.")
+ .arg(m_NoiseStats.sigma(), 0, 'f', 2));
+ }
}
// Pre-pulse settle frames become the fit's baseline
diff --git a/kstars/ekos/guide/aiguideprotocol.h b/kstars/ekos/guide/aiguideprotocol.h
index e462bff13d..251641d261 100644
--- a/kstars/ekos/guide/aiguideprotocol.h
+++ b/kstars/ekos/guide/aiguideprotocol.h
@@ -6,6 +6,8 @@
#pragma once
+#include "internalguide/assistant_stats.h"
+
#include <QObject>
#include <QTimer>
#include <QFile>
@@ -147,6 +149,11 @@ class AIGuideProtocol : public QObject
bool m_OrigSouthEnabled { true };
double m_OrigMaxDeltaRMS { 2.0 };
+ // Live noise-floor (seeing) measurement: PHD2-style HPF sigma of the RA error
+ HighPassFilter m_NoiseHPF;
+ AxisStats m_NoiseStats;
+ int m_NoiseFrameCount { 0 };
+
int m_PulseFrameCount { 0 };
int m_PulseSettleTimer { 0 };
int m_PulseWatchdog { 0 };
diff --git a/kstars/ekos/guide/internalguide/assistant_stats.cpp b/kstars/ekos/guide/internalguide/assistant_stats.cpp
new file mode 100644
index 0000000000..3823e28c11
--- /dev/null
+++ b/kstars/ekos/guide/internalguide/assistant_stats.cpp
@@ -0,0 +1,123 @@
+/*
+ SPDX-FileCopyrightText: 2026 Pavan <[email protected]>
+
+ SPDX-License-Identifier: GPL-2.0-or-later
+
+ Filter and statistics classes adapted from PHD2's guiding_stats.cpp,
+ Copyright (c) 2018 Bruce Waddington, distributed under the BSD 3-Clause license.
+*/
+
+#include "assistant_stats.h"
+
+#include <algorithm>
+#include <cmath>
+
+namespace Ekos
+{
+
+void HighPassFilter::configure(double cutoffPeriodSec, double samplePeriodSec)
+{
+ // Sample periods below 1 s are clamped: the sensor already averages faster motion.
+ m_Alpha = cutoffPeriodSec / (cutoffPeriodSec + std::max(1.0, samplePeriodSec));
+ m_PrevValue = 0.0;
+ m_Result = 0.0;
+ m_Count = 0;
+}
+
+double HighPassFilter::addValue(double value)
+{
+ if (m_Count == 0)
+ m_Result = value;
+ else
+ m_Result = m_Alpha * (m_Result + value - m_PrevValue);
+ m_PrevValue = value;
+ m_Count++;
+ return m_Result;
+}
+
+void LowPassFilter::configure(double cutoffPeriodSec, double samplePeriodSec)
+{
+ m_Alpha = 1.0 - (cutoffPeriodSec / (cutoffPeriodSec + std::max(1.0, samplePeriodSec)));
+ m_Result = 0.0;
+ m_Count = 0;
+}
+
+double LowPassFilter::addValue(double value)
+{
+ if (m_Count == 0)
+ m_Result = value;
+ else
+ m_Result += m_Alpha * (value - m_Result);
+ m_Count++;
+ return m_Result;
+}
+
+void AxisStats::reset()
+{
+ m_Times.clear();
+ m_Values.clear();
+}
+
+void AxisStats::add(double timeSec, double value)
+{
+ m_Times.append(timeSec);
+ m_Values.append(value);
+}
+
+double AxisStats::sigma() const
+{
+ const int n = m_Values.size();
+ if (n < 2)
+ return 0.0;
+ double mean = 0.0;
+ for (double v : m_Values)
+ mean += v;
+ mean /= n;
+ double ss = 0.0;
+ for (double v : m_Values)
+ ss += (v - mean) * (v - mean);
+ return std::sqrt(ss / (n - 1));
+}
+
+bool AxisStats::linearFit(double *slope, double *intercept) const
+{
+ const int n = m_Values.size();
+ if (n < 3)
+ return false;
+ double sumX = 0.0, sumY = 0.0, sumXY = 0.0, sumXSq = 0.0;
+ for (int i = 0; i < n; i++)
+ {
+ sumX += m_Times[i];
+ sumY += m_Values[i];
+ sumXY += m_Times[i] * m_Values[i];
+ sumXSq += m_Times[i] * m_Times[i];
+ }
+ const double denom = n * sumXSq - sumX * sumX;
+ if (std::abs(denom) < 1e-12)
+ return false;
+ *slope = (n * sumXY - sumX * sumY) / denom;
+ *intercept = (sumY - *slope * sumX) / n;
+ return true;
+}
+
+double AxisStats::driftCorrectedSigma() const
+{
+ double slope = 0.0, intercept = 0.0;
+ if (!linearFit(&slope, &intercept))
+ return sigma();
+ const int n = m_Values.size();
+ double mean = 0.0;
+ QVector<double> residuals(n);
+ for (int i = 0; i < n; i++)
+ {
+ residuals[i] = m_Values[i] - (slope * m_Times[i] + intercept);
+ mean += residuals[i];
+ }
+ mean /= n;
+ double ss = 0.0;
+ for (double r : residuals)
+ ss += (r - mean) * (r - mean);
+ return std::sqrt(ss / (n - 1));
+}
+
+}
diff --git a/kstars/ekos/guide/internalguide/assistant_stats.h b/kstars/ekos/guide/internalguide/assistant_stats.h
new file mode 100644
index 0000000000..ed98986b89
--- /dev/null
+++ b/kstars/ekos/guide/internalguide/assistant_stats.h
@@ -0,0 +1,67 @@
+/*
+ SPDX-FileCopyrightText: 2026 Pavan <[email protected]>
+
+ SPDX-License-Identifier: GPL-2.0-or-later
+
+ Filter and statistics classes adapted from PHD2's guiding_stats.cpp,
+ Copyright (c) 2018 Bruce Waddington, distributed under the BSD 3-Clause license.
+*/
+
+#pragma once
+
+#include <QVector>
+
+namespace Ekos
+{
+
+// Single-pole high-pass filter: passes star motion faster than the cutoff period.
+class HighPassFilter
+{
+ public:
+ void configure(double cutoffPeriodSec, double samplePeriodSec);
+ double addValue(double value);
+
+ private:
+ double m_Alpha { 0.5 };
+ double m_PrevValue { 0.0 };
+ double m_Result { 0.0 };
+ int m_Count { 0 };
+};
+
+// Single-pole low-pass filter (EMA): the slow drift component of star motion.
+class LowPassFilter
+{
+ public:
+ void configure(double cutoffPeriodSec, double samplePeriodSec);
+ double addValue(double value);
+ double current() const
+ {
+ return m_Result;
+ }
+
+ private:
+ double m_Alpha { 0.5 };
+ double m_Result { 0.0 };
+ int m_Count { 0 };
+};
+
+// Time series statistics: sigma, linear fit, and drift-corrected sigma.
+class AxisStats
+{
+ public:
+ void reset();
+ void add(double timeSec, double value);
+ int count() const
+ {
+ return m_Values.size();
+ }
+ double sigma() const;
+ bool linearFit(double *slope, double *intercept) const;
+ double driftCorrectedSigma() const;
+
+ private:
+ QVector<double> m_Times;
+ QVector<double> m_Values;
+};
+
+}
diff --git a/kstars/ekos/guide/offlinetrainer/train_direct_drive.py b/kstars/ekos/guide/offlinetrainer/train_direct_drive.py
index dc2a6a9d08..0d22dfcff6 100644
--- a/kstars/ekos/guide/offlinetrainer/train_direct_drive.py
+++ b/kstars/ekos/guide/offlinetrainer/train_direct_drive.py
@@ -19,6 +19,23 @@ from typing import Optional
from datetime import datetime
+
+def _effective_pixel_scale(sysid):
+ """Pixel scale in arcsec/px. Older exports recorded it without the binning factor."""
+ eq = sysid.get("equipment", {})
+ ps = float(eq.get("pixel_scale_arcsec_per_px", 1.0) or 1.0)
+ if not eq.get("pixel_scale_includes_binning", False):
+ b = str(sysid.get("model_fingerprint", {}).get("guide_binning", "1x1"))
+ try:
+ bf = max(1, int(b.split("x")[0]))
+ except ValueError:
+ bf = 1
+ if bf > 1:
+ print(f" [scale] correcting pixel scale for binning {bf}x: "
+ f"{ps:.3f} -> {ps * bf:.3f} arcsec/px")
+ ps *= bf
+ return ps
+
def train_direct_drive(sysid: dict,
verbose: bool = False) -> dict:
"""
@@ -27,7 +44,7 @@ def train_direct_drive(sysid: dict,
Returns a weights dict compatible with DirectDriveGuider::loadWeights().
"""
eq = sysid["equipment"]
- pixel_scale = eq["pixel_scale_arcsec_per_px"] # arcsec/px
+ pixel_scale = _effective_pixel_scale(sysid) # arcsec/px
guide_exp = eq["guide_exposure_ms"] / 1000.0 # seconds
# Collect all free-drift frames across all positions
diff --git a/kstars/ekos/guide/offlinetrainer/train_harmonic.py b/kstars/ekos/guide/offlinetrainer/train_harmonic.py
index 2982a3dd2b..345102e44c 100644
--- a/kstars/ekos/guide/offlinetrainer/train_harmonic.py
+++ b/kstars/ekos/guide/offlinetrainer/train_harmonic.py
@@ -27,6 +27,23 @@ except ImportError:
TORCH_AVAILABLE = False
+
+def _effective_pixel_scale(sysid):
+ """Pixel scale in arcsec/px. Older exports recorded it without the binning factor."""
+ eq = sysid.get("equipment", {})
+ ps = float(eq.get("pixel_scale_arcsec_per_px", 1.0) or 1.0)
+ if not eq.get("pixel_scale_includes_binning", False):
+ b = str(sysid.get("model_fingerprint", {}).get("guide_binning", "1x1"))
+ try:
+ bf = max(1, int(b.split("x")[0]))
+ except ValueError:
+ bf = 1
+ if bf > 1:
+ print(f" [scale] correcting pixel scale for binning {bf}x: "
+ f"{ps:.3f} -> {ps * bf:.3f} arcsec/px")
+ ps *= bf
+ return ps
+
def train_harmonic(sysid: dict,
gpu: bool = False,
epochs: int = None,
@@ -37,7 +54,7 @@ def train_harmonic(sysid: dict,
Returns a weights dict compatible with HarmonicGuider::loadWeights().
"""
eq = sysid["equipment"]
- pixel_scale = eq["pixel_scale_arcsec_per_px"]
+ pixel_scale = _effective_pixel_scale(sysid)
guide_exp = eq.get("guide_exposure_ms", 1000.0) / 1000.0
if verbose:
@@ -53,7 +70,7 @@ def train_harmonic(sysid: dict,
print(f"\n--- Phase 2: PE Period Detection ---")
# ── Step 2: Detect PE period from free-drift data ──────────────────────
- pe_period, pe_amplitude = _estimate_pe(sysid, guide_exp, verbose)
+ pe_period, pe_amplitude, pe_lines = _estimate_pe(sysid, guide_exp, verbose)
if verbose:
if pe_period > 0:
@@ -97,6 +114,7 @@ def train_harmonic(sysid: dict,
"tau_dec": float(tau_dec),
"pe_period": float(pe_period),
"pe_amplitude": float(pe_amplitude),
+ "pe_lines": pe_lines,
"drift_ra": float(drift_ra),
"drift_dec": float(drift_dec),
"k_ref": float(k_ref),
@@ -289,7 +307,7 @@ def _fit_spring_params(sysid: dict, axis: str, guide_exp: float, verbose: bool):
return DEFAULTS
kappa_result = float(np.median(kappas))
- tau_result = float(np.median(taus)) if taus else DEFAULTS[1]
+ tau_result = float(np.median(taus)) if taus and kappa_result > 0.0 else DEFAULTS[1]
# A median within ~2% of the fit bounds means the model chased noise/drift, not physics.
if kappa_result > 0.88 or tau_result > 9.8:
@@ -326,7 +344,7 @@ def _pe_candidate_series(sysid: dict, guide_exp: float):
adding the applied pulses back (same compensated formula as train_worm_gear:
JSON RA pulses are ADDED, DEC pulses SUBTRACTED).
"""
- pixel_scale = sysid["equipment"].get("pixel_scale_arcsec_per_px", 1.0)
+ pixel_scale = _effective_pixel_scale(sysid)
series = []
for s in sysid["sessions"]:
frames = s.get("frames", [])
@@ -358,22 +376,28 @@ def _pe_candidate_series(sysid: dict, guide_exp: float):
def _estimate_pe(sysid: dict, guide_exp: float, verbose: bool):
"""
- Detect PE period and amplitude via Lomb-Scargle over every usable session.
- Band per series: from 2 cycles per observation span up to Nyquist, so long
- standard-guiding sessions expose the strain-wave fundamental (~300-900s)
- and short free drifts still cover fast components.
- Returns (period_seconds, amplitude_pixels) or (0.0, 0.0) if none significant.
+ Detect PE lines via Lomb-Scargle over every usable session.
+ Collects every significant peak, applies the band-edge and free-drift
+ confirmation guards per peak, and selects the STRONGEST surviving line
+ (by amplitude) as the primary — not merely the first the window resolves.
+ Returns (period_seconds, amplitude_pixels, lines) where lines is a list of
+ {"period_s", "amplitude_px", "snr"} for all surviving lines, primary first.
"""
candidates = _pe_candidate_series(sysid, guide_exp)
if not candidates:
if verbose:
print(" No usable sessions found. PE detection skipped.")
- return 0.0, 0.0
+ return 0.0, 0.0, []
- best = None # (snr, period, amplitude, label)
+ peaks = [] # (snr, period, amplitude, label, at_edge)
+ drift_peaks = [] # (span, period, snr) from free-drift series
+ series_bands = [] # (label, band_min_s, band_max_s, [peak periods]) per series
for t_arr, ra_arr, label in candidates:
span = t_arr[-1] - t_arr[0]
- nyquist = 0.5 / guide_exp
+ # Nyquist from the real frame cadence, not the exposure: download/processing
+ # overhead makes dt >> exposure and sub-cadence peaks are aliases.
+ dt_med = float(np.median(np.diff(t_arr))) if len(t_arr) > 1 else guide_exp
+ nyquist = 0.5 / max(dt_med, guide_exp)
f_min = max(2.0 / span, 0.002) # need >= 2 observed cycles
f_max = min(0.5, nyquist * 0.9)
if f_min >= f_max or span <= 0:
@@ -385,44 +409,109 @@ def _estimate_pe(sysid: dict, guide_exp: float, verbose: bool):
f_search = np.geomspace(f_min, f_max, 4000)
omega = 2 * np.pi * f_search
Pxx = scipy.signal.lombscargle(t_arr, ra_detrended, omega, precenter=True)
-
- peak_idx = np.argmax(Pxx)
- peak_freq = f_search[peak_idx]
- noise_floor = np.median(Pxx)
- snr = Pxx[peak_idx] / (noise_floor + 1e-10)
- amplitude = np.sqrt(4 * Pxx[peak_idx] / len(t_arr))
-
- at_edge = peak_freq <= f_min * 1.05
+ noise_floor = np.median(Pxx) + 1e-10
+
+ # Every significant local maximum, not just the argmax
+ series_peaks = []
+ # A line longer than the band rises monotonically to the edge and never forms
+ # a local maximum — record the endpoint so the leakage guard can see it.
+ if Pxx[0] / noise_floor >= 10.0 and Pxx[0] >= Pxx[1]:
+ series_peaks.append((Pxx[0] / noise_floor, 1.0 / f_search[0],
+ np.sqrt(4 * Pxx[0] / len(t_arr)), label, True))
+ for i in range(1, len(Pxx) - 1):
+ if Pxx[i] > Pxx[i - 1] and Pxx[i] > Pxx[i + 1] and Pxx[i] / noise_floor >= 10.0:
+ period = 1.0 / f_search[i]
+ snr = Pxx[i] / noise_floor
+ amplitude = np.sqrt(4 * Pxx[i] / len(t_arr))
+ at_edge = f_search[i] <= f_min * 1.05
+ series_peaks.append((snr, period, amplitude, label, at_edge))
+ if label.startswith("free_drift"):
+ drift_peaks.append((span, period, snr))
+ peaks.extend(series_peaks)
+ series_bands.append((label, 1.0 / f_max, min(1.0 / f_min, span / 2.0),
+ [(p[1], p[2]) for p in series_peaks]))
if verbose:
+ tops = sorted(series_peaks, key=lambda p: -p[2])[:3]
+ desc = ", ".join(f"{p[1]:.0f}s (amp {p[2]:.2f}px, SNR {p[0]:.0f})" for p in tops)
print(f" [LS] {label}: span={span:.0f}s, band {1/f_max:.1f}-{1/f_min:.0f}s, "
- f"peak {1/peak_freq:.1f}s, SNR {snr:.1f}, amp {amplitude:.3f}px"
- f"{' [AT BAND EDGE]' if at_edge else ''}")
-
- if best is None or snr > best[0]:
- best = (snr, 1.0 / peak_freq, amplitude, label, at_edge)
+ f"peaks: {desc if desc else 'none significant'}")
+
+ drift_spans = [s for s, _, _ in drift_peaks]
+ survivors = []
+ edge_max_amp = 0.0
+ edge_max_period = 0.0
+ for snr, period, amplitude, label, at_edge in peaks:
+ if at_edge:
+ if amplitude > edge_max_amp:
+ edge_max_amp, edge_max_period = amplitude, period
+ if verbose:
+ print(f" [LS] dropping {period:.0f}s from {label}: at band edge — true "
+ f"period unresolved (need a session of {2.5 * period:.0f}s+)")
+ continue
+ # Cross-series consistency: real mount PE must appear in EVERY other series
+ # whose band covers its period; a line only one series sees is an artifact
+ # of that block (guiding oscillation, wind, settling).
+ coverers = [b for b in series_bands
+ if b[0] != label and b[1] <= period <= b[2]]
+ if coverers:
+ # Confirmation needs matching period AND compatible amplitude (within 3x):
+ # a 20x amplitude mismatch is two different phenomena, not one line.
+ confirmed_x = any(any(abs(p - period) / period < 0.15
+ and max(a, amplitude) / max(min(a, amplitude), 1e-6) <= 3.0
+ for p, a in b[3])
+ for b in coverers)
+ if not confirmed_x:
+ if verbose:
+ print(f" [LS] dropping {period:.0f}s (amp {amplitude:.2f}px) from {label}: "
+ f"not consistently seen by other series covering that band")
+ continue
+ # Reconstructed (standard-guiding) series lie when pulses did not physically
+ # act; a peak a free drift could have resolved must be confirmed by one.
+ if not label.startswith("free_drift"):
+ coverable = [s for s in drift_spans if period <= s / 2.0]
+ confirmed = any(abs(p - period) / period < 0.2 and dsnr >= 10.0
+ for _, p, dsnr in drift_peaks)
+ if coverable and not confirmed:
+ if verbose:
+ print(f" [LS] dropping {period:.0f}s (SNR {snr:.0f}): reconstruction-only, "
+ f"not confirmed by free drift — likely pulse back-out artifact")
+ continue
+ survivors.append((snr, period, amplitude))
- if best is None or best[0] < 10.0:
+ if not survivors:
if verbose:
- snr = 0.0 if best is None else best[0]
- print(f" [LS] PE not significant (best SNR {snr:.1f} < 10.0). Disabling PE states.")
- return 0.0, 0.0
-
- snr, peak_period, amplitude, label, at_edge = best
- if at_edge:
- # True period is longer than the session can resolve: disable PE, tell the user
+ print(" [LS] No significant PE line survived the guards. Disabling PE states.")
+ return 0.0, 0.0, []
+
+ # Dedupe lines within 15% of each other (keep the strongest), order by amplitude
+ survivors.sort(key=lambda p: -p[2])
+ lines = []
+ for snr, period, amplitude in survivors:
+ if any(abs(period - l["period_s"]) / l["period_s"] < 0.15 for l in lines):
+ continue
+ lines.append({"period_s": float(np.clip(period, 1.5, 1500.0)),
+ "amplitude_px": float(np.clip(amplitude, 0.01, 50.0)),
+ "snr": float(snr)})
+ if len(lines) >= 4:
+ break
+
+ primary = lines[0]
+ # A dominant unresolved line leaks sidelobes into the band; when it dwarfs every
+ # resolved line, the resolved ones cannot be trusted either.
+ if edge_max_amp > 1.5 * primary["amplitude_px"]:
if verbose:
- print(f" [LS] WARNING: dominant PE ({amplitude:.2f}px, SNR {snr:.0f}) sits at the "
- f"band edge ({peak_period:.0f}s) — true period is longer than the session "
- f"can resolve. Collect a session of at least {2.5 * peak_period:.0f}s "
- f"(free drift or standard guiding) and retrain. Disabling PE states.")
- return 0.0, 0.0
-
- peak_period = np.clip(peak_period, 1.5, 1500.0)
- amplitude = np.clip(amplitude, 0.01, 50.0)
+ print(f" [LS] WARNING: unresolved PE at the band edge (~{edge_max_period:.0f}s, "
+ f"amp {edge_max_amp:.2f}px) dominates every resolved line — its leakage "
+ f"would masquerade as PE. Collect a session of at least "
+ f"{2.5 * edge_max_period:.0f}s and retrain. Disabling PE states.")
+ return 0.0, 0.0, []
if verbose:
- print(f" [LS] Selected: {peak_period:.1f}s, amp {amplitude:.3f}px (from {label})")
+ print(f" [LS] Selected strongest line: {primary['period_s']:.1f}s, "
+ f"amp {primary['amplitude_px']:.3f}px"
+ + (f"; secondary lines: " + ", ".join(f"{l['period_s']:.0f}s" for l in lines[1:])
+ if len(lines) > 1 else ""))
- return float(peak_period), float(amplitude)
+ return primary["period_s"], primary["amplitude_px"], lines
# ═══════════════════════════════════════════════════════════════════════════════
diff --git a/kstars/ekos/guide/offlinetrainer/train_worm_gear.py b/kstars/ekos/guide/offlinetrainer/train_worm_gear.py
index 8e05706b33..293f83ca03 100644
--- a/kstars/ekos/guide/offlinetrainer/train_worm_gear.py
+++ b/kstars/ekos/guide/offlinetrainer/train_worm_gear.py
@@ -28,6 +28,23 @@ except ImportError:
TORCH_AVAILABLE = False
+
+def _effective_pixel_scale(sysid):
+ """Pixel scale in arcsec/px. Older exports recorded it without the binning factor."""
+ eq = sysid.get("equipment", {})
+ ps = float(eq.get("pixel_scale_arcsec_per_px", 1.0) or 1.0)
+ if not eq.get("pixel_scale_includes_binning", False):
+ b = str(sysid.get("model_fingerprint", {}).get("guide_binning", "1x1"))
+ try:
+ bf = max(1, int(b.split("x")[0]))
+ except ValueError:
+ bf = 1
+ if bf > 1:
+ print(f" [scale] correcting pixel scale for binning {bf}x: "
+ f"{ps:.3f} -> {ps * bf:.3f} arcsec/px")
+ ps *= bf
+ return ps
+
def train_worm_gear(sysid: dict,
gpu: bool = False,
epochs: int = None,
@@ -42,7 +59,7 @@ def train_worm_gear(sysid: dict,
sys.exit(1)
eq = sysid["equipment"]
- pixel_scale = eq["pixel_scale_arcsec_per_px"]
+ pixel_scale = _effective_pixel_scale(sysid)
guide_exp = eq.get("guide_exposure_ms", 2000.0) / 1000.0
if verbose:
@@ -329,7 +346,7 @@ def _build_training_dataset(sysid, pe_period, pe_amplitude, k_ref, d_ra_extra, d
X_all = []
Y_all = []
- pixel_scale = sysid["equipment"]["pixel_scale_arcsec_per_px"]
+ pixel_scale = _effective_pixel_scale(sysid)
# Track saturation stats
total_frames = 0
diff --git a/kstars/ekos/guide/opsaiconfig.ui b/kstars/ekos/guide/opsaiconfig.ui
index 610a347eb8..fdd6d07826 100644
--- a/kstars/ekos/guide/opsaiconfig.ui
+++ b/kstars/ekos/guide/opsaiconfig.ui
@@ -73,6 +73,16 @@
</property>
</widget>
</item>
+ <item>
+ <widget class="QCheckBox" name="kcfg_AIProtocolPulseTest">
+ <property name="text">
+ <string>Include Pulse Response Test in Data Collection Protocol</string>
+ </property>
+ <property name="toolTip">
+ <string>Measures the mount's elastic response to guide pulses (harmonic drives). Adds about 20 minutes to the protocol and has not produced measurable results on rigs tested so far, so it is off by default.</string>
+ </property>
+ </widget>
+ </item>
<item>
<widget class="QCheckBox" name="kcfg_AIDarkGuiding">
<property name="text">
diff --git a/kstars/kstars.kcfg b/kstars/kstars.kcfg
index cba92d6fcd..28c458d7ed 100644
--- a/kstars/kstars.kcfg
+++ b/kstars/kstars.kcfg
@@ -3122,6 +3122,11 @@
<label>Dynamically scale down standard proportional gain when AI is highly confident.</label>
<default>false</default>
</entry>
+ <entry name="AIProtocolPulseTest" type="Bool">
+ <label>Include the pulse response test in the AI data collection protocol.</label>
+ <whatsthis>The pulse response test measures the mount's elastic response to guide pulses (harmonic drives). It adds about 20 minutes to the protocol and has not produced measurable results on rigs tested so far, so it is off by default.</whatsthis>
+ <default>false</default>
+ </entry>
<entry name="AIShadowMode" type="Bool">
<label>Run AI model alongside standard guiding, logging predictions without applying them.</label>
<whatsthis>When enabled and a valid AI weights file is configured, the AI model runs silently in parallel with the selected guiding algorithm. Predictions are written to the AI debug CSV log but are never blended into guide pulses. Use this to evaluate AI model quality before enabling full AI guiding.</whatsthis>