Skip to content

Commit 4919832

Browse files
committed
io(rtl): lock auto-ppm on sustained sub-deadband residuals
1 parent eb91cb5 commit 4919832

2 files changed

Lines changed: 121 additions & 7 deletions

File tree

src/io/radio/rtl_auto_ppm.cpp

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,26 @@ estimate_snr_db(RtlAutoPpmSource source, const RtlAutoPpmInputs& inputs) {
4848
return std::max(inputs.gate_snr_db, inputs.spec_snr_db);
4949
}
5050

51+
static inline bool
52+
within_zero_lock_window(double ppm, double df_hz, const RtlAutoPpmConfig& config) {
53+
return (std::fabs(ppm) <= config.zero_lock_ppm) && (std::fabs(df_hz) <= config.zero_lock_hz);
54+
}
55+
56+
static inline double
57+
min_correctable_df_hz(uint32_t tuned_freq_hz, const RtlAutoPpmConfig& config) {
58+
return (config.min_correction_ppm * static_cast<double>(tuned_freq_hz)) / 1.0e6;
59+
}
60+
61+
static inline bool
62+
within_acquisition_lock_window(double ppm, double df_hz, uint32_t tuned_freq_hz, const RtlAutoPpmConfig& config) {
63+
/* Lock acquisition must never be stricter than the smallest residual the
64+
* controller can actually correct. Otherwise a sub-deadband residual can
65+
* neither step nor lock, leaving training stuck forever. */
66+
double effective_zero_lock_ppm = std::max(config.zero_lock_ppm, config.min_correction_ppm);
67+
double effective_zero_lock_hz = std::max(config.zero_lock_hz, min_correctable_df_hz(tuned_freq_hz, config));
68+
return (std::fabs(ppm) <= effective_zero_lock_ppm) && (std::fabs(df_hz) <= effective_zero_lock_hz);
69+
}
70+
5171
static bool
5272
tracking_estimate_ready(const RtlAutoPpmSignalMetrics& metrics) {
5373
return metrics.cqpsk_enable && metrics.tracking_enable && metrics.carrier_lock && finite_hz(metrics.nco_cfo_hz);
@@ -266,12 +286,12 @@ RtlAutoPpmController::update(const RtlAutoPpmConfig& config, const RtlAutoPpmInp
266286
double filtered_df_hz = (filtered_ppm * static_cast<double>(inputs.tuned_freq_hz)) / 1.0e6;
267287
int filtered_dir = sign_with_deadband(filtered_ppm, config.min_correction_ppm);
268288
/* Residuals inside the correction deadband cannot generate a tuner
269-
* step, so dropping lock here would strand the controller in an
270-
* unrecoverable training state. Keep the existing lock until the
271-
* drift grows large enough to produce a real correction. */
272-
keep_lock = (filtered_dir == 0)
273-
|| ((std::fabs(filtered_ppm) <= config.zero_lock_ppm)
274-
&& (std::fabs(filtered_df_hz) <= config.zero_lock_hz));
289+
* step, so dropping an already-earned lock here would strand the
290+
* controller in training with no corrective action available.
291+
* Initial lock acquisition still uses the configured zero-lock
292+
* thresholds; this only preserves an existing lock until the drift
293+
* grows large enough to produce a real correction. */
294+
keep_lock = (filtered_dir == 0) || within_zero_lock_window(filtered_ppm, filtered_df_hz, config);
275295
if (keep_lock) {
276296
out.est_ppm = filtered_ppm;
277297
out.df_hz = filtered_df_hz;
@@ -331,7 +351,11 @@ RtlAutoPpmController::update(const RtlAutoPpmConfig& config, const RtlAutoPpmInp
331351
* observation window if the error later grows again. */
332352
observation_since_ms_ = 0;
333353
observation_sign_ = 0;
334-
if (std::fabs(ema_ppm_) <= config.zero_lock_ppm && std::fabs(out.df_hz) <= config.zero_lock_hz) {
354+
/* Lock acquisition still respects the operator-configured zero-lock
355+
* window, but the effective window cannot be tighter than the minimum
356+
* correction deadband or training could get stranded with no legal
357+
* tuner step left to apply. */
358+
if (within_acquisition_lock_window(ema_ppm_, out.df_hz, inputs.tuned_freq_hz, config)) {
335359
if (stable_since_ms_ == 0) {
336360
stable_since_ms_ = inputs.now_ms;
337361
}

tests/io/test_io_rtl_auto_ppm.cpp

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,93 @@ test_locked_session_keeps_sub_deadband_same_channel_drift_locked(void) {
583583
return rc;
584584
}
585585

586+
static int
587+
test_sub_deadband_residual_locks_without_dither_with_default_config(void) {
588+
int rc = 0;
589+
RtlAutoPpmController controller;
590+
RtlAutoPpmConfig config = {};
591+
const uint32_t freq_hz = 769768750U;
592+
const double residual_hz = 100.5;
593+
const double residual_ppm = (residual_hz * 1.0e6) / static_cast<double>(freq_hz);
594+
595+
controller.reset(0, freq_hz);
596+
597+
RtlAutoPpmUpdate update =
598+
controller.update(config, make_inputs(1000, 0, freq_hz, residual_ppm, RtlAutoPpmSource::CarrierTotal));
599+
rc |= expect_int_eq("sub-deadband residual does not apply immediately", update.apply_ppm, 0);
600+
rc |= expect_int_eq("sub-deadband residual starts in training", update.training, 1);
601+
rc |= expect_int_eq("sub-deadband residual starts unlocked", update.locked, 0);
602+
603+
update = controller.update(config, make_inputs(4000, 0, freq_hz, residual_ppm, RtlAutoPpmSource::CarrierTotal));
604+
rc |= expect_int_eq("sub-deadband residual can lock", update.locked, 1);
605+
rc |= expect_int_eq("sub-deadband residual exits training", update.training, 0);
606+
rc |= expect_int_eq("sub-deadband residual keeps ppm at zero", update.lock_ppm, 0);
607+
608+
update = controller.update(config, make_inputs(7000, 0, freq_hz, residual_ppm, RtlAutoPpmSource::CarrierTotal));
609+
rc |= expect_int_eq("locked sub-deadband residual does not dither", update.apply_ppm, 0);
610+
rc |= expect_int_eq("locked sub-deadband residual stays locked", update.locked, 1);
611+
rc |= expect_int_eq("locked sub-deadband residual stays out of training", update.training, 0);
612+
return rc;
613+
}
614+
615+
static int
616+
test_sub_deadband_residual_locks_with_zero_lock_hz_below_deadband_floor(void) {
617+
int rc = 0;
618+
RtlAutoPpmController controller;
619+
RtlAutoPpmConfig config = {};
620+
const uint32_t freq_hz = 769768750U;
621+
const double residual_hz = 100.5;
622+
const double residual_ppm = (residual_hz * 1.0e6) / static_cast<double>(freq_hz);
623+
624+
config.zero_lock_hz = residual_hz - 5.0;
625+
626+
controller.reset(0, freq_hz);
627+
628+
RtlAutoPpmUpdate update =
629+
controller.update(config, make_inputs(1000, 0, freq_hz, residual_ppm, RtlAutoPpmSource::CarrierTotal));
630+
rc |= expect_int_eq("tight zero-lock hz starts in training", update.training, 1);
631+
rc |= expect_int_eq("tight zero-lock hz starts unlocked", update.locked, 0);
632+
633+
update = controller.update(config, make_inputs(4000, 0, freq_hz, residual_ppm, RtlAutoPpmSource::CarrierTotal));
634+
rc |= expect_int_eq("tight zero-lock hz still locks below deadband floor", update.locked, 1);
635+
rc |= expect_int_eq("tight zero-lock hz exits training after lock hold", update.training, 0);
636+
rc |= expect_int_eq("tight zero-lock hz keeps ppm at zero", update.lock_ppm, 0);
637+
638+
update = controller.update(config, make_inputs(7000, 0, freq_hz, residual_ppm, RtlAutoPpmSource::CarrierTotal));
639+
rc |= expect_int_eq("tight zero-lock hz still avoids dithering", update.apply_ppm, 0);
640+
rc |= expect_int_eq("tight zero-lock hz remains locked", update.locked, 1);
641+
return rc;
642+
}
643+
644+
static int
645+
test_sub_deadband_residual_locks_with_zero_lock_ppm_below_deadband_floor(void) {
646+
int rc = 0;
647+
RtlAutoPpmController controller;
648+
RtlAutoPpmConfig config = {};
649+
const uint32_t freq_hz = 769768750U;
650+
const double residual_hz = 100.5;
651+
const double residual_ppm = (residual_hz * 1.0e6) / static_cast<double>(freq_hz);
652+
653+
config.zero_lock_ppm = residual_ppm - 0.01;
654+
655+
controller.reset(0, freq_hz);
656+
657+
RtlAutoPpmUpdate update =
658+
controller.update(config, make_inputs(1000, 0, freq_hz, residual_ppm, RtlAutoPpmSource::CarrierTotal));
659+
rc |= expect_int_eq("tight zero-lock ppm starts in training", update.training, 1);
660+
rc |= expect_int_eq("tight zero-lock ppm starts unlocked", update.locked, 0);
661+
662+
update = controller.update(config, make_inputs(4000, 0, freq_hz, residual_ppm, RtlAutoPpmSource::CarrierTotal));
663+
rc |= expect_int_eq("tight zero-lock ppm still locks below deadband floor", update.locked, 1);
664+
rc |= expect_int_eq("tight zero-lock ppm exits training after lock hold", update.training, 0);
665+
rc |= expect_int_eq("tight zero-lock ppm keeps ppm at zero", update.lock_ppm, 0);
666+
667+
update = controller.update(config, make_inputs(7000, 0, freq_hz, residual_ppm, RtlAutoPpmSource::CarrierTotal));
668+
rc |= expect_int_eq("tight zero-lock ppm still avoids dithering", update.apply_ppm, 0);
669+
rc |= expect_int_eq("tight zero-lock ppm remains locked", update.locked, 1);
670+
return rc;
671+
}
672+
586673
static int
587674
test_frequency_change_during_training_restarts_observation(void) {
588675
int rc = 0;
@@ -714,6 +801,9 @@ main(void) {
714801
rc |= test_deadband_reentry_restarts_observation_window();
715802
rc |= test_locked_session_reenters_training_on_same_channel_drift();
716803
rc |= test_locked_session_keeps_sub_deadband_same_channel_drift_locked();
804+
rc |= test_sub_deadband_residual_locks_without_dither_with_default_config();
805+
rc |= test_sub_deadband_residual_locks_with_zero_lock_hz_below_deadband_floor();
806+
rc |= test_sub_deadband_residual_locks_with_zero_lock_ppm_below_deadband_floor();
717807
rc |= test_frequency_change_during_training_restarts_observation();
718808
rc |= test_frequency_change_rearms_drift_check_after_lock_carry();
719809
rc |= test_locked_session_resets_after_external_ppm_change();

0 commit comments

Comments
 (0)