@@ -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+
586673static int
587674test_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