[education/kstars/stable-3.8.4] kstars/ekos/guide/offlinetrainer: offline_trainer: fix pulse-pairing time-alignment bug, raise kappa bound

Jasem Mutlaq <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit 4b31365c09fe7395c1540dcf6f415fd5c2ebe6c3 by Jasem Mutlaq.
Committed on 09/08/2026 at 12:31.
Pushed by mutlaqja into branch 'stable-3.8.4'.

offline_trainer: fix pulse-pairing time-alignment bug, raise kappa bound

Paired opposite-direction pulse curves always masked to the
positive-direction curve's grid restricted to the negative curve's range,
so whenever the positive pulse's first frame landed a fraction of a frame
before the negative pulse's first frame, that entire leading frame was
discarded -- an alignment coincidence between two independently-fired
pulses, not a real resolution limit. At fast (~1s) cadence this was
throwing away about half of all pulse-response fits. Now bases the shared
grid on whichever curve started later, so no frame is dropped due to pairing
alignment alone.

Also raises the curve fit's kappa bound from 0.9 to 0.98: the pinned-at-
bounds sanity check already judges whether a result is trustworthy, so the
bound itself doesn't need to double as that judgment and risk clipping a
real value before convergence.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

M  +29   -6    kstars/ekos/guide/offlinetrainer/pulse_response_fit.py

https://invent.kde.org/education/kstars/-/commit/4b31365c09fe7395c1540dcf6f415fd5c2ebe6c3

diff --git a/kstars/ekos/guide/offlinetrainer/pulse_response_fit.py b/kstars/ekos/guide/offlinetrainer/pulse_response_fit.py
index c0dd9080f7..389137d048 100644
--- a/kstars/ekos/guide/offlinetrainer/pulse_response_fit.py
+++ b/kstars/ekos/guide/offlinetrainer/pulse_response_fit.py
@@ -25,6 +25,17 @@ SPDX-License-Identifier: GPL-2.0-or-later
 import numpy as np
 import scipy.optimize
 
+# Raised from 0.9: that bound could clip a real, well-determined kappa before
+# curve_fit even gets a chance to converge on it. The pinned-at-bounds check
+# below (kappa_result > KAPPA_MAX - 0.02) is what actually judges whether a
+# result is trustworthy, so the bound itself doesn't need to double as that
+# judgment. Note kappa->1 is degenerate, not just large: d(0) = P*(1-kappa),
+# so once a curve's first sample sits near 0, P and kappa trade off freely and
+# curve_fit's own covariance on kappa blows up (confirmed on SAL-33 data) --
+# pinning at any bound near 1 is a sign of an unidentified fit, not a measured
+# spring constant.
+KAPPA_MAX = 0.98
+
 
 def fit_pulse_response(sysid: dict, axis: str, guide_exp: float, verbose: bool,
                        return_fits: bool = False):
@@ -99,12 +110,12 @@ def fit_pulse_response(sysid: dict, axis: str, guide_exp: float, verbose: bool,
                     return P * (1.0 - kappa * np.exp(-t / tau)) + v * t + c
                 slope0 = (pos_arr[-1] - pos_arr[0]) / max(t_arr[-1] - t_arr[0], 1e-3)
                 p0 = [pos_arr[-1] - slope0 * t_arr[-1], 0.3, 1.5, slope0, 0.0]
-                bounds = ([-50.0, 0.0, 0.1, -2.0, -10.0], [50.0, 0.9, 10.0, 2.0, 10.0])
+                bounds = ([-50.0, 0.0, 0.1, -2.0, -10.0], [50.0, KAPPA_MAX, 10.0, 2.0, 10.0])
             else:
                 def model(t, P, kappa, tau, c):
                     return P * (1.0 - kappa * np.exp(-t / tau)) + c
                 p0 = [pos_arr[-1], 0.3, 1.5, 0.0]
-                bounds = ([-100.0, 0.0, 0.1, -10.0], [100.0, 0.9, 10.0, 10.0])
+                bounds = ([-100.0, 0.0, 0.1, -10.0], [100.0, KAPPA_MAX, 10.0, 10.0])
             popt, _ = scipy.optimize.curve_fit(model, t_arr, pos_arr, p0=p0,
                                                bounds=bounds, maxfev=10000)
             residual_std = float(np.std(pos_arr - model(t_arr, *popt)))
@@ -148,11 +159,23 @@ def fit_pulse_response(sysid: dict, axis: str, guide_exp: float, verbose: bool,
                 continue
             tp, pp = cp
             tn, pn = cn
-            mask = (tp >= tn[0]) & (tp <= tn[-1])
+            # Sample on whichever curve's own grid started later, interpolating the
+            # other one onto it. Always basing this on tp (as a naive tp>=tn[0] mask
+            # would) drops tp's entire leading frame whenever tp merely happened to
+            # start a fraction of a frame before tn -- an alignment coincidence
+            # between two independently-fired pulses, not a real resolution limit --
+            # which was silently halving the usable lead time on roughly half of all
+            # pairs at fast cadence.
+            if tp[0] >= tn[0]:
+                t_arr_full, base_v, other_t, other_v, base_is_pos = tp, pp, tn, pn, True
+            else:
+                t_arr_full, base_v, other_t, other_v, base_is_pos = tn, pn, tp, pp, False
+            mask = (t_arr_full >= other_t[0]) & (t_arr_full <= other_t[-1])
             if mask.sum() < 5:
                 continue
-            t_arr = tp[mask]
-            diff = pp[mask] - np.interp(t_arr, tn, pn)
+            t_arr = t_arr_full[mask]
+            interp_v = np.interp(t_arr, other_t, other_v)
+            diff = (base_v[mask] - interp_v) if base_is_pos else (interp_v - base_v[mask])
             # Sessions are minutes apart so PE does not cancel exactly; v absorbs the leak
             fit = fit_curve(t_arr, diff, with_drift=True)
             if fit is None:
@@ -230,7 +253,7 @@ def fit_pulse_response(sysid: dict, axis: str, guide_exp: float, verbose: bool,
     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:
+    if kappa_result > KAPPA_MAX - 0.02 or tau_result > 9.8:
         if verbose:
             print(f"  [{axis}] WARNING: fit pinned at bounds (κ={kappa_result:.3f}, "
                   f"τ={tau_result:.2f}s) — unphysical. Using defaults (κ=0.2, τ=1.5s).")
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.