Merge PR #1097 (pcps-doppler-num-bins-unification) into next

Conflict resolution: keep next's CUDA engine hooks, sample-buffer renames
(d_buffer_sample_count) and full_grid_search option; adopt the PR's
set_doppler_num_bins() members. CUDA capacity now uses
d_num_doppler_bins_full_grid_active and update_grid_doppler_wipeoffs()
uploads the unified grid to the GPU.
This commit is contained in:
Carles Fernandez
2026-09-23 09:36:12 +00:00
12 changed files with 591 additions and 204 deletions
@@ -310,9 +310,9 @@ void PcpsAcquisitionAdapter::set_doppler_center(int doppler_center)
}
void PcpsAcquisitionAdapter::set_doppler_uncertainty(unsigned int doppler_uncertainty)
void PcpsAcquisitionAdapter::set_doppler_num_bins(unsigned int doppler_num_bins)
{
acquisition_->set_doppler_uncertainty(doppler_uncertainty);
acquisition_->set_doppler_num_bins(doppler_num_bins);
}
@@ -103,9 +103,10 @@ public:
void set_doppler_center(int doppler_center) override;
/*!
* \brief Set Doppler uncertainty for the grid search
* \brief Set the number of Doppler bins to search, centered on
* set_doppler_center() -- see pcps_acquisition::set_doppler_num_bins().
*/
void set_doppler_uncertainty(unsigned int doppler_uncertainty) override;
void set_doppler_num_bins(unsigned int doppler_num_bins) override;
/*!
* \brief Returns the maximum peak of grid search
@@ -132,11 +132,18 @@ pcps_acquisition::pcps_acquisition(const Acq_Conf& conf_)
d_magnitude_grid_stride(aligned_row_stride<float>(d_effective_fft_size)),
d_doppler_wipeoffs_stride(aligned_row_stride<gr_complex>(d_fft_size)),
d_num_doppler_bins(static_cast<uint32_t>(std::ceil(static_cast<double>(2 * d_doppler_max) / static_cast<double>(d_doppler_step)))),
d_num_doppler_bins_step1_capacity(std::max(d_num_doppler_bins, 2U)),
d_num_doppler_bins_step2(conf_.num_doppler_bins_step2),
d_dump_channel(conf_.dump_channel),
d_threshold(conf_.pfa > 0.0 ? compute_threshold(conf_.pfa, d_effective_fft_size, d_num_doppler_bins, conf_.bit_transition_flag ? 1 : conf_.max_dwells) : conf_.threshold),
d_threshold_narrowed(conf_.pfa > 0.0 ? compute_threshold(conf_.pfa, d_effective_fft_size, 1U, conf_.bit_transition_flag ? 1 : conf_.max_dwells) : conf_.threshold),
d_min_reference_separation_hz((static_cast<float>(conf_.reference_bin_min_sidelobes) + 0.5F) / (static_cast<float>(conf_.sampled_ms) / 1000.0F)),
// Inline, not via needs_extra_reference_row(candidate_bins) -- that
// helper reads d_use_CFAR_algorithm_flag, declared (so initialized)
// later in this same list; see its own doc comment in the header.
d_full_grid_reference_needs_extra_row(conf_.use_CFAR_algorithm_flag && (static_cast<float>(d_num_doppler_bins / 2) * static_cast<float>(d_doppler_step) < d_min_reference_separation_hz)),
// A full grid worth needing a dedicated reference row for is always
// > 1 candidate bin in practice (see d_num_reference_rows_active's doc
// comment for why the count differs at exactly 1 candidate) -- so this
// ceiling reserves 2 rows, not 1, whenever the row is needed at all.
d_num_doppler_bins_full_grid_active(d_num_doppler_bins + (d_full_grid_reference_needs_extra_row ? (d_num_doppler_bins > 1U ? 2U : 1U) : 0U)),
d_threshold_step_two(conf_.pfa2 > 0.0 ? compute_threshold(conf_.pfa2, d_effective_fft_size, d_num_doppler_bins_step2, conf_.bit_transition_flag ? 1 : conf_.max_dwells) : conf_.threshold),
d_cshort(conf_.it_size != sizeof(gr_complex)),
d_use_CFAR_algorithm_flag(conf_.use_CFAR_algorithm_flag),
@@ -146,8 +153,9 @@ pcps_acquisition::pcps_acquisition(const Acq_Conf& conf_)
d_state(0),
d_doppler_center(0),
d_doppler_bias(0),
d_num_doppler_bins_active(d_num_doppler_bins),
d_doppler_search_narrowed(false),
d_num_doppler_bins_active(d_num_doppler_bins_full_grid_active),
d_num_reference_rows_active(d_full_grid_reference_needs_extra_row ? (d_num_doppler_bins > 1U ? 2U : 1U) : 0U),
d_threshold_active(conf_.pfa > 0.0 ? compute_threshold(conf_.pfa, d_effective_fft_size, d_num_doppler_bins, conf_.bit_transition_flag ? 1 : conf_.max_dwells) : conf_.threshold),
d_buffer_sample_count(0),
d_channel(0),
d_resampler_latency_samples(conf_.resampler_latency_samples),
@@ -159,12 +167,12 @@ pcps_acquisition::pcps_acquisition(const Acq_Conf& conf_)
d_dump_number(0),
d_input_power(0),
d_doppler_center_step_two(0),
d_magnitude_grid(std::max(d_num_doppler_bins_step1_capacity, d_num_doppler_bins_step2) * d_magnitude_grid_stride),
d_magnitude_grid(std::max(d_num_doppler_bins_full_grid_active, d_num_doppler_bins_step2) * d_magnitude_grid_stride),
d_tmp_buffer(d_effective_fft_size),
d_input_signal(d_fft_size),
d_grid_doppler_wipeoffs_step_two(d_acq_parameters.make_2_steps ? d_num_doppler_bins_step2 * d_doppler_wipeoffs_stride : 0),
d_ifft(gnss_fft_rev_make_unique(d_fft_size)),
d_grid_doppler_wipeoffs(d_num_doppler_bins_step1_capacity * d_doppler_wipeoffs_stride),
d_grid_doppler_wipeoffs(d_num_doppler_bins_full_grid_active * d_doppler_wipeoffs_stride),
d_fft_codes(d_fft_size),
d_fft_if(gnss_fft_fwd_make_unique(d_fft_size))
{
@@ -206,12 +214,15 @@ pcps_acquisition::pcps_acquisition(const Acq_Conf& conf_)
// While idle (not actively searching), general_work() only drains its input to avoid
// stalling the upstream block, producing no output. Without a batching hint the TPB
// scheduler wakes this block's thread for every small burst of new input, which is
// pure scheduling overhead. Requiring a larger noutput_items granularity forces the
// scheduler wakes this block's thread for every small burst of new input (observed:
// tens of thousands of calls/s, a few hundred/thousand samples each), which is pure
// scheduling overhead. Requiring a larger noutput_items granularity forces the
// scheduler to accumulate more input per wakeup, cutting call frequency without
// changing behavior (production while idle is still always 0 either way).
// One PRN code period at this signal's own decimated rate is the natural
// lower bound: the algorithm never does anything meaningful below that granularity.
// One PRN code period (1 ms) at this signal's own decimated rate is the natural
// lower bound: the algorithm never does anything meaningful below that granularity,
// so batching to it (instead of a fixed sample count) self-scales with fs_in/decimation
// across signals/configs instead of being tuned for one particular sample rate.
const auto output_multiple_samples = std::max<uint32_t>(1U, static_cast<uint32_t>(std::lround(conf_.samples_per_ms)));
this->set_output_multiple(output_multiple_samples);
}
@@ -220,7 +231,7 @@ pcps_acquisition::pcps_acquisition(const Acq_Conf& conf_)
#if CUDA_GPU_ACCEL
void pcps_acquisition::init_cuda_engine()
{
const uint32_t max_bins = std::max(d_num_doppler_bins_step1_capacity, d_num_doppler_bins_step2);
const uint32_t max_bins = std::max(d_num_doppler_bins_full_grid_active, d_num_doppler_bins_step2);
auto engine = std::make_unique<CudaPcpsEngine>(d_fft_size, d_effective_fft_size, max_bins, d_acq_parameters.cuda_device);
if (!engine->is_valid())
{
@@ -393,25 +404,58 @@ void pcps_acquisition::update_local_carrier(own::span<gr_complex> carrier_vector
void pcps_acquisition::update_grid_doppler_wipeoffs()
{
if (d_doppler_search_narrowed)
// Symmetric, centered placement: candidate index k decodes back to
// d_doppler_center + (k - half_span) * d_doppler_step -- rather than
// "-doppler_max + step*index", which biases every bin low by up to half a
// doppler_step and, at 1 candidate bin, misses d_doppler_center entirely
// (would test doppler_center - doppler_max instead). One shared loop for
// every degree of Doppler uncertainty -- the full grid, an assisted
// narrowed window, or an exactly-known single bin -- not a separate
// branch per case: they differ only in candidate_count, which
// set_doppler_num_bins() already resolved into d_num_doppler_bins_active/
// d_num_reference_rows_active.
const uint32_t candidate_count = d_num_doppler_bins_active - d_num_reference_rows_active;
const auto half_span = static_cast<int32_t>((candidate_count - 1U) / 2U);
for (uint32_t doppler_index = 0; doppler_index < candidate_count; doppler_index++)
{
// Assisted acquisition: the Doppler is exactly known from an already-tracked
// primary frequency. Search just that bin, plus one reference bin offset by
// the full configured Doppler half-width -- the same separation the full grid
// search uses for its "opposite" bin -- so max_to_input_power_statistic can
// keep using it as a noise-only reference without any change to its logic.
update_local_carrier(own::span<gr_complex>(doppler_wipeoff_data(0), d_fft_size), static_cast<float>(d_doppler_bias + d_doppler_center));
update_local_carrier(own::span<gr_complex>(doppler_wipeoff_data(1), d_fft_size), static_cast<float>(d_doppler_bias + d_doppler_center + static_cast<int32_t>(d_doppler_max)));
#if CUDA_GPU_ACCEL
cuda_upload_wipeoffs(CudaPcpsEngine::MAIN_GRID);
#endif
return;
}
for (uint32_t doppler_index = 0; doppler_index < d_num_doppler_bins_active; doppler_index++)
{
const int32_t doppler = -static_cast<int32_t>(d_doppler_max) + d_doppler_center + d_doppler_step * doppler_index;
const int32_t doppler = d_doppler_center + (static_cast<int32_t>(doppler_index) - half_span) * static_cast<int32_t>(d_doppler_step);
update_local_carrier(own::span<gr_complex>(doppler_wipeoff_data(doppler_index), d_fft_size), static_cast<float>(d_doppler_bias + doppler));
}
if (d_num_reference_rows_active > 0U)
{
// The in-grid wraparound max_to_input_power_statistic() would otherwise
// use (candidate_count/2 bins away) can't clear
// reference_bin_min_sidelobes at this candidate count and step (see
// needs_extra_reference_row()) -- add one or two dedicated extra
// reference rows instead, at exactly +/-doppler_max. NEVER pushed
// further out to try to clear reference_bin_min_sidelobes (an
// earlier version of this code did that, and it was wrong:
// doppler_max is the receiver-validated edge of the search/filter
// passband the rest of the acquisition chain is designed for, and a
// reference sample placed beyond it can land somewhere the
// decimation/anti-alias response is no longer flat, corrupting the
// noise estimate rather than cleaning it up -- confirmed live:
// pushing E5a's reference from 3740 Hz to a sidelobe-derived
// 4500 Hz cost real satellites at hot start). If doppler_max itself
// doesn't clear the floor, that's an accepted, pre-existing limit
// of this reference technique at a tight search -- not something to
// fix by searching outside the validated range.
//
// With more than one real candidate, both +doppler_max and
// -doppler_max are computed (candidate_count and candidate_count+1)
// so max_to_input_power_statistic() can pick whichever sits opposite
// the winning candidate's side of center -- a candidate riding near
// one edge of the search range never ends up right next to (or on
// top of) its own reference sample. With exactly one real candidate
// (always at offset 0 -- no side to be opposite of), a single row at
// +doppler_max is all d_num_reference_rows_active allocates; see its
// doc comment.
update_local_carrier(own::span<gr_complex>(doppler_wipeoff_data(candidate_count), d_fft_size), static_cast<float>(d_doppler_bias + d_doppler_center + static_cast<int32_t>(d_doppler_max)));
if (d_num_reference_rows_active > 1U)
{
update_local_carrier(own::span<gr_complex>(doppler_wipeoff_data(candidate_count + 1U), d_fft_size), static_cast<float>(d_doppler_bias + d_doppler_center - static_cast<int32_t>(d_doppler_max)));
}
}
#if CUDA_GPU_ACCEL
cuda_upload_wipeoffs(CudaPcpsEngine::MAIN_GRID);
#endif
@@ -512,16 +556,25 @@ void pcps_acquisition::dump_results(const AcquisitionResult& result)
std::array<size_t, 2> dims_1d{1, 1};
std::array<size_t, 2> dims_2d{d_effective_fft_size, d_num_doppler_bins_active};
// In narrowed mode, acq_grid is only d_num_doppler_bins_active (2) columns
// wide -- column 0 is the known/aided Doppler, column 1 the noise-reference
// bin at +doppler_max from it. doppler_max/doppler_step are given the same
// {0, d_doppler_max} encoding compute_statistics() uses internally, so
// doppler(col) = -doppler_max + doppler_center + doppler_step*col still
// decodes both columns correctly; doppler_narrowed flags which encoding is
// in effect for offline post-processing.
const bool dump_narrowed = d_doppler_search_narrowed;
const int32_t dump_doppler_max = dump_narrowed ? 0 : static_cast<int32_t>(d_doppler_max);
const int32_t dump_doppler_step = dump_narrowed ? static_cast<int32_t>(d_doppler_max) : static_cast<int32_t>(d_doppler_step);
// acq_grid's candidate columns -- all of them for a plain full-grid
// search, or just the requested candidate count when narrowed
// (the trailing column there is the noise-reference bin at
// doppler_center + d_doppler_max, not representable in this linear
// encoding) -- are placed symmetrically around doppler_center, matching
// compute_statistics(): column k decodes to
// doppler_center + (k - half_span)*d_doppler_step. Writing
// dump_doppler_max = half_span*d_doppler_step lets the standard
// "-doppler_max + doppler_center + doppler_step*col" formula do that
// mapping for offline post-processing tools; doppler_narrowed flags
// whether this was a narrowed/assisted search (fewer candidates than
// the full configured grid) -- NOT whether a trailing reference
// column is present, since a plain full grid can have one too (CFAR,
// wraparound too tight) without being a narrowed search.
const uint32_t dump_candidate_count = d_num_doppler_bins_active - d_num_reference_rows_active;
const bool dump_narrowed = dump_candidate_count < d_num_doppler_bins;
const auto dump_half_span = static_cast<int32_t>((dump_candidate_count - 1U) / 2U);
const int32_t dump_doppler_max = dump_half_span * static_cast<int32_t>(d_doppler_step);
const auto dump_doppler_step = static_cast<int32_t>(d_doppler_step);
write_matlab_var<2>("acq_grid", d_grid.memptr(), matfp, dims_2d);
write_matlab_var<1>("doppler_max", dump_doppler_max, matfp, dims_1d);
@@ -641,9 +694,49 @@ pcps_acquisition::AcquisitionResult pcps_acquisition::max_to_input_power_statist
if (!d_step_two)
{
const auto index_opp = (index_doppler + num_doppler_bins / 2) % num_doppler_bins;
const auto* magnitude_grid = magnitude_grid_data(index_opp);
d_input_power = static_cast<float>(std::accumulate(magnitude_grid, magnitude_grid + d_effective_fft_size, static_cast<float>(0.0)) / d_effective_fft_size / 2.0 / d_num_noncoherent_integrations_counter);
if (d_num_reference_rows_active > 0U)
{
// Any candidate count whose natural wraparound distance couldn't
// clear reference_bin_min_sidelobes (see needs_extra_reference_row())
// -- from an exactly-known single bin up through a plain full grid
// too small to clear it on its own -- has one or two fixed,
// dedicated noise-only reference rows right after the candidate
// bins (see update_grid_doppler_wipeoffs()), not the "opposite
// side of the grid" the wraparound branch below uses -- with more
// than one candidate bin, that generic wraparound would pick
// whichever bin happens to sit halfway around from the winning
// candidate, sometimes another *candidate* bin (or, at a small
// enough grid, a bin still within the signal's own sidelobe
// skirt), not a genuinely noise-only reference.
//
// With two reference rows (d_num_reference_rows_active > 1, i.e.
// more than one real candidate), pick whichever of the two sits
// opposite the winning candidate's side of center: a winner at or
// beyond +doppler_max/2 or so would otherwise sit right next to
// (or on top of) a reference fixed at +doppler_max, sampling its
// own sidelobe skirt instead of genuine noise. winner_offset uses
// the exact same "-doppler_max + doppler_step*index" mapping as
// result.doppler below, so its sign matches which side of center
// index_doppler actually decodes to.
uint32_t reference_index = candidate_count;
if (d_num_reference_rows_active > 1U)
{
const int32_t winner_offset = -doppler_max + doppler_step * static_cast<int32_t>(index_doppler);
reference_index = (winner_offset >= 0) ? (candidate_count + 1U) : candidate_count;
}
const auto* magnitude_grid = magnitude_grid_data(reference_index);
d_input_power = static_cast<float>(std::accumulate(magnitude_grid, magnitude_grid + d_effective_fft_size, static_cast<float>(0.0)) / d_effective_fft_size / 2.0 / d_num_noncoherent_integrations_counter);
}
else
{
// Reached only when the wraparound distance (num_doppler_bins/2
// bins) already clears reference_bin_min_sidelobes at this
// candidate count (see needs_extra_reference_row()), so this
// in-grid bin is a genuinely noise-only sample.
const auto index_opp = (index_doppler + num_doppler_bins / 2) % num_doppler_bins;
const auto* magnitude_grid = magnitude_grid_data(index_opp);
d_input_power = static_cast<float>(std::accumulate(magnitude_grid, magnitude_grid + d_effective_fft_size, static_cast<float>(0.0)) / d_effective_fft_size / 2.0 / d_num_noncoherent_integrations_counter);
}
result.doppler = -static_cast<int32_t>(doppler_max) + d_doppler_center + doppler_step * static_cast<int32_t>(index_doppler);
}
else
@@ -798,23 +891,22 @@ void pcps_acquisition::doppler_grid_cpu(const gr_complex* in)
pcps_acquisition::AcquisitionResult pcps_acquisition::compute_statistics()
{
const auto bin_count = d_step_two ? d_num_doppler_bins_step2 : d_num_doppler_bins_active;
// Assisted acquisition (Doppler exactly known from an already-tracked primary
// frequency): bin 0 is the known Doppler and bin 1 is the reference bin offset
// by d_doppler_max (see update_grid_doppler_wipeoffs()). Passing doppler_max=0,
// doppler_step=d_doppler_max makes the generic "-doppler_max + center +
// doppler_step * index" decoding below map those two bins back to the right
// Doppler values, same formula as the full grid search uses.
const bool assisted = (!d_step_two) && d_doppler_search_narrowed;
const auto doppler_step = d_step_two ? d_acq_parameters.doppler_step2 : (assisted ? static_cast<int32_t>(d_doppler_max) : d_doppler_step);
const auto doppler_max = d_step_two ? static_cast<int32_t>(d_doppler_center_step_two - (static_cast<float>(bin_count) / 2.0) * doppler_step) : (assisted ? 0 : static_cast<int32_t>(d_doppler_max));
// In assisted mode, bin_count computed rows exist (known bin + noise reference)
// but only bin 0 is a real Doppler candidate -- the reference bin must never be
// selected as the acquisition result (see the two statistic functions).
const auto candidate_count = assisted ? 1U : bin_count;
// Whenever reference rows are active (see needs_extra_reference_row()),
// bin_count computed rows exist (candidates + one or two noise references)
// but only the candidates are real Doppler candidates -- the reference
// rows must never be selected as the acquisition result (see the two
// statistic functions).
const auto candidate_count = d_step_two ? bin_count : (bin_count - d_num_reference_rows_active);
// Both assisted and plain full-grid search place their candidate_count bins
// symmetrically around doppler_center (see update_grid_doppler_wipeoffs()):
// candidate index k decodes back to center + (k - half_span) * doppler_step.
// Setting doppler_max_used = half_span * doppler_step lets the shared
// "-doppler_max + center + doppler_step * index" decoding below do that
// mapping for either case without duplicating it.
const auto half_span = static_cast<int32_t>((candidate_count - 1U) / 2U);
const auto doppler_step = d_step_two ? d_acq_parameters.doppler_step2 : d_doppler_step;
const auto doppler_max = d_step_two ? static_cast<int32_t>(d_doppler_center_step_two - (static_cast<float>(bin_count) / 2.0) * doppler_step) : half_span * static_cast<int32_t>(d_doppler_step);
// The reference/"opposite" bin used by max_to_input_power_statistic as a
// noise-only estimate works the same way whether the grid has the full
// configured bin count or just the 2 bins built above for assisted mode.
if (d_use_CFAR_algorithm_flag)
{
return max_to_input_power_statistic(bin_count, candidate_count, doppler_max, doppler_step);
@@ -996,11 +1088,30 @@ void pcps_acquisition::acquisition_core(uint64_t sample_count)
float pcps_acquisition::get_threshold() const
{
if (d_step_two)
return d_step_two ? d_threshold_step_two : d_threshold_active;
}
bool pcps_acquisition::needs_extra_reference_row(uint32_t candidate_bins) const
{
if (candidate_bins < d_num_doppler_bins)
{
return d_threshold_step_two;
// Narrowed/assisted search (fewer candidates than the full configured
// grid): the in-grid "opposite bin" wraparound distance shrinks along
// with candidate_bins and, at these small counts, essentially never
// clears d_min_reference_separation_hz -- always use a dedicated
// reference row here, same as today's narrowed mode assumed
// unconditionally, regardless of which statistic (CFAR or
// peak-ratio) is active. This keeps a narrowed dump self-describing
// either way, and lets first_vs_second_peak_statistic() exclude the
// reference bin from candidacy under peak-ratio too, not just CFAR.
return true;
}
return d_doppler_search_narrowed ? d_threshold_narrowed : d_threshold;
// Full grid: only pay for a dedicated reference row when CFAR needs a
// genuinely noise-only sample and the in-grid wraparound can't supply one
// on its own -- first_vs_second_peak_statistic() has no such reference
// concept, so a full (non-narrowed) peak-ratio search never needs one.
return d_use_CFAR_algorithm_flag && (static_cast<float>(candidate_bins / 2) * static_cast<float>(d_doppler_step) < d_min_reference_separation_hz);
}
@@ -1016,17 +1127,37 @@ void pcps_acquisition::set_doppler_center(int32_t doppler_center)
}
void pcps_acquisition::set_doppler_uncertainty(uint32_t doppler_uncertainty)
void pcps_acquisition::set_doppler_num_bins(uint32_t num_doppler_bins)
{
gr::thread::scoped_lock lock(d_setlock); // require mutex with work function called by the scheduler
const bool narrow = (doppler_uncertainty == 0) && d_acq_parameters.enable_doppler_narrowing;
const uint32_t num_doppler_bins_active = narrow ? 2U : d_num_doppler_bins;
if (narrow != d_doppler_search_narrowed || num_doppler_bins_active != d_num_doppler_bins_active)
// 0 is the only value a caller can't just supply directly -- the full
// grid's own candidate count is otherwise private to this class -- so it
// means "the full configured range" here, same as always. Any other
// value is taken literally and unconditionally: an assisted, exactly-
// known Doppler (num_doppler_bins == 1) always searches exactly 1 bin,
// no config-driven widening -- a caller that wants a safety margin
// around its own estimate should just ask for that many bins directly.
uint32_t candidate_bins = (num_doppler_bins == 0U) ? d_num_doppler_bins : num_doppler_bins;
candidate_bins = std::min(candidate_bins, d_num_doppler_bins);
// See d_num_reference_rows_active's doc comment: 2 dedicated rows
// (opposite-sign pair) whenever a reference is needed and there's more
// than one real candidate to be opposite of; 1 (the historical fixed
// +doppler_max placement) at exactly one real candidate, since it always
// sits at offset 0 -- no side for a second, mirrored row to usefully
// cover.
const uint32_t num_reference_rows = needs_extra_reference_row(candidate_bins) ? (candidate_bins > 1U ? 2U : 1U) : 0U;
const uint32_t num_doppler_bins_active = candidate_bins + num_reference_rows;
if (num_reference_rows != d_num_reference_rows_active || num_doppler_bins_active != d_num_doppler_bins_active)
{
d_doppler_search_narrowed = narrow;
d_num_reference_rows_active = num_reference_rows;
d_num_doppler_bins_active = num_doppler_bins_active;
// See d_threshold_active's doc comment: recalibrated for whatever
// candidate count is active now, excluding the reference row
// itself (it's never a candidate result, so it isn't one of the
// hypotheses being tested for a false alarm either).
d_threshold_active = d_acq_parameters.pfa > 0.0 ? compute_threshold(d_acq_parameters.pfa, d_effective_fft_size, candidate_bins, d_acq_parameters.bit_transition_flag ? 1 : d_acq_parameters.max_dwells) : d_acq_parameters.threshold;
update_grid_doppler_wipeoffs();
DLOG(INFO) << " Doppler uncertainty for Channel: " << d_channel << " => active Doppler bins: " << d_num_doppler_bins_active << ", Doppler center: " << d_doppler_center;
DLOG(INFO) << " Doppler bin count for Channel: " << d_channel << " => active Doppler bins: " << d_num_doppler_bins_active << ", Doppler center: " << d_doppler_center;
}
}
@@ -156,14 +156,24 @@ public:
void set_doppler_center(int32_t doppler_center);
/*!
* \brief Set Doppler uncertainty for the grid search. It will refresh the Doppler grid.
* A doppler_uncertainty of 0 means the Doppler is exactly known (as when this
* acquisition is assisted by an already-tracked primary frequency): the search
* is restricted to a single Doppler bin centered on set_doppler_center(). Any
* other value restores the full configured Doppler search range.
* \param doppler_uncertainty - 0 to search a single bin, nonzero for the full range.
* \brief Sets how many Doppler bins to search, centered on set_doppler_center().
* One unified mechanism for every degree of Doppler uncertainty: a regular,
* unassisted search and a primary-frequency-assisted single-bin search (the
* Doppler is exactly known, from an already-tracked primary frequency or a
* visibility-aware prediction) go through this same call, differing only in
* what num_doppler_bins they pass -- there is no separate "narrowed" code
* path. Refreshes the Doppler grid and, since the detection threshold is
* itself a function of how many bins are being searched (see
* compute_threshold()), recalculates it for the new bin count.
* \param num_doppler_bins - number of candidate Doppler bins to search, or 0
* to (re)search the full configured Doppler range (computed from
* doppler_max/doppler_step) -- the only case a caller can't just supply the
* bin count directly, since that full-grid count is otherwise private to
* this class. Any other value is the literal candidate bin count: 1 for an
* exactly-known Doppler, or any N in between to accommodate a search with
* partial uncertainty.
*/
void set_doppler_uncertainty(uint32_t doppler_uncertainty);
void set_doppler_num_bins(uint32_t num_doppler_bins);
//! Whether the CUDA engine is available. Inspect only while acquisition is stopped.
inline bool cuda_ready() const
@@ -206,6 +216,26 @@ private:
bool positive_acq{false};
};
// Whether a search of candidate_bins Doppler bins needs a dedicated
// noise-only reference row. Two cases: a narrowed/assisted search
// (candidate_bins < d_num_doppler_bins) always needs one, unconditionally
// -- same as today's narrowed mode assumed, and regardless of which
// statistic is active, so a narrowed dump stays self-describing either
// way; a plain full grid (candidate_bins == d_num_doppler_bins) needs one
// only when CFAR is active and the in-grid wraparound distance
// max_to_input_power_statistic() would otherwise use (candidate_bins/2
// bins away) can't clear d_min_reference_separation_hz at this candidate
// count and d_doppler_step -- a large enough full grid may already clear
// it via wraparound and need no extra row at all, and peak-ratio has no
// such reference concept to begin with. Reads
// d_use_CFAR_algorithm_flag, so -- unlike d_full_grid_reference_needs_extra_row's
// own copy of this same formula, evaluated inline in the member-initializer
// list against the conf_ constructor parameter directly -- this is only
// safe to call once construction has actually finished (d_use_CFAR_algorithm_flag
// is declared, so initialized, after d_full_grid_reference_needs_extra_row):
// only set_doppler_num_bins() calls this, never the constructor.
bool needs_extra_reference_row(uint32_t candidate_bins) const;
void update_local_carrier(own::span<gr_complex> carrier_vector, float freq) const;
void update_grid_doppler_wipeoffs();
void update_grid_doppler_wipeoffs_step2();
@@ -235,12 +265,14 @@ private:
bool is_fdma();
float get_threshold() const;
// candidate_count: number of computed grid rows eligible to be selected as the
// acquisition result -- narrowed mode computes 2 rows (known bin + noise
// reference) but only bin 0 is a real candidate, so candidate_count is 1 there.
// max_to_input_power_statistic additionally takes num_doppler_bins (>=
// candidate_count), the full computed row count, for its CFAR "opposite bin"
// reference lookup -- first_vs_second_peak_statistic has no such reference
// concept, so it only needs candidate_count.
// acquisition result -- narrowed mode, and a plain full grid whose natural
// reference distance can't clear reference_bin_min_sidelobes, both compute
// d_num_reference_rows_active (1 or 2) extra trailing rows (see
// d_full_grid_reference_needs_extra_row) that are never real candidates, so
// candidate_count excludes them. max_to_input_power_statistic additionally
// takes num_doppler_bins (>= candidate_count), the full computed row count,
// for its CFAR reference-bin lookup -- first_vs_second_peak_statistic has no
// such reference concept, so it only needs candidate_count.
AcquisitionResult first_vs_second_peak_statistic(uint32_t candidate_count, int32_t doppler_max, int32_t doppler_step);
AcquisitionResult max_to_input_power_statistic(uint32_t num_doppler_bins, uint32_t candidate_count, int32_t doppler_max, int32_t doppler_step);
void wait_if_active();
@@ -256,19 +288,38 @@ private:
const uint32_t d_magnitude_grid_stride;
const uint32_t d_doppler_wipeoffs_stride;
const uint32_t d_num_doppler_bins;
// Narrowed acquisition always computes two rows (candidate + noise
// reference), even when the configured full grid contains only one bin.
const uint32_t d_num_doppler_bins_step1_capacity;
const uint32_t d_num_doppler_bins_step2;
const uint32_t d_dump_channel;
const float d_threshold;
// Same PFA target as d_threshold, but calibrated for a single Doppler-bin's
// worth of code-phase hypotheses instead of the full grid's d_num_doppler_bins --
// reusing d_threshold in narrowed mode would under-detect, since compute_threshold()
// folds the candidate count into the false-alarm probability (e.g. a requested
// pfa=0.01 becomes far stricter than 0.01 once inflated by a ~80-bin full grid's
// worth of hypotheses that narrowed mode isn't actually searching).
const float d_threshold_narrowed;
// (reference_bin_min_sidelobes + 0.5) / coherent_integration_time_seconds: the
// Doppler-domain target a CFAR noise-reference bin should clear from the
// search grid's candidate span, in Hz -- used to decide, for whatever
// candidate bin count is currently active, WHETHER it needs a dedicated
// extra reference row (see needs_extra_reference_row() below); never to
// place that row beyond d_doppler_max. See reference_bin_min_sidelobes'
// doc comment in acq_conf.h for why exceeding d_doppler_max is
// deliberately never done.
const float d_min_reference_separation_hz;
// True when a plain full grid's natural wraparound reference distance
// (d_num_doppler_bins/2 bins, i.e. what max_to_input_power_statistic's
// "opposite bin" formula would reach) can't clear d_min_reference_separation_hz
// -- computed once at construction from d_num_doppler_bins/d_doppler_step, which
// never change afterward. This is exactly needs_extra_reference_row(d_num_doppler_bins)
// (see below), kept as its own const member only because it's needed before
// construction finishes, to size d_num_doppler_bins_full_grid_active and
// therefore d_magnitude_grid/d_grid_doppler_wipeoffs -- every other caller,
// at any candidate bin count, goes through needs_extra_reference_row(). Only
// says whether a dedicated reference row is needed at all -- see
// d_num_reference_rows_active for how many (1 or 2).
const bool d_full_grid_reference_needs_extra_row;
// d_num_doppler_bins, plus reference rows (see d_num_reference_rows_active --
// 2 here, since a full grid worth searching a dedicated reference row for is
// always > 1 candidate bin in practice) when d_full_grid_reference_needs_extra_row
// is true. This (not d_num_doppler_bins) is the full grid's row count -- both
// the constructed value of d_num_doppler_bins_active and what
// set_doppler_num_bins(0) (the "full range" sentinel) restores it to -- and
// what d_magnitude_grid/d_grid_doppler_wipeoffs are sized to accommodate: the
// ceiling every set_doppler_num_bins() call must stay within.
const uint32_t d_num_doppler_bins_full_grid_active;
const float d_threshold_step_two;
const bool d_cshort;
const bool d_use_CFAR_algorithm_flag;
@@ -282,15 +333,39 @@ private:
int32_t d_state;
int32_t d_doppler_center;
int32_t d_doppler_bias;
// Number of step-1 Doppler bins actually searched: equals d_num_doppler_bins,
// or 2 (known bin + reference bin) when d_doppler_search_narrowed is true.
// Number of step-1 Doppler grid rows actually searched right now: the
// candidate bin count last passed to set_doppler_num_bins() (or the full
// grid's candidate count, if it was called with the 0 sentinel), plus
// d_num_reference_rows_active. Always <= d_num_doppler_bins_full_grid_active
// -- set_doppler_num_bins() clamps to that ceiling -- so it never exceeds
// what d_magnitude_grid/d_grid_doppler_wipeoffs were allocated for.
uint32_t d_num_doppler_bins_active;
// Explicit narrowed-mode flag, set directly by set_doppler_uncertainty() --
// deliberately NOT inferred from "d_num_doppler_bins_active < d_num_doppler_bins".
// A narrowed request computes 2 rows regardless of whether the configured full
// grid has 1, 2, or more bins; d_num_doppler_bins_step1_capacity guarantees
// storage for both rows, while the explicit flag avoids the 2 == 2 ambiguity.
bool d_doppler_search_narrowed;
// How many of d_num_doppler_bins_active's trailing rows are dedicated
// noise-only reference rows (see needs_extra_reference_row()) rather than
// real Doppler candidates -- set directly by set_doppler_num_bins() for
// whatever candidate count is currently active. 0 when no reference row
// is needed (plain full grid, wraparound clears reference_bin_min_sidelobes
// on its own). 1 when needed but there's only a single real candidate (an
// exactly-known Doppler, always at offset 0 from center -- no sign for a
// second, opposite-side row to usefully mirror), same fixed placement at
// doppler_center + doppler_max as always. 2 when needed and there's more
// than one real candidate: a row at doppler_center + doppler_max (index
// candidate_count) AND one at doppler_center - doppler_max (index
// candidate_count + 1) -- max_to_input_power_statistic() picks whichever
// of the two sits on the opposite side of center from the winning
// candidate, so the reference is never on the same side as (and closer to)
// a signal that happens to be riding near the edge of the search range.
uint32_t d_num_reference_rows_active;
// Detection threshold for the currently active candidate bin count (see
// d_num_doppler_bins_active) -- compute_threshold() folds the number of
// bins being searched into the false-alarm probability (more bins tested
// means more chances to false-alarm at a fixed per-bin PFA), so this must
// be recalculated by set_doppler_num_bins() every time that count
// changes; reusing a threshold calibrated for a different bin count would
// over- or under-detect. Excludes the reference row itself from the count
// passed to compute_threshold() -- it's never a candidate result, so it
// isn't one of the hypotheses being tested for a false alarm either.
float d_threshold_active;
uint32_t d_buffer_sample_count;
uint32_t d_channel;
uint32_t d_resampler_latency_samples;
+1 -1
View File
@@ -82,7 +82,7 @@ void Acq_Conf::SetFromConfiguration(const ConfigurationInterface *configuration,
}
make_2_steps = configuration->property(role + ".make_two_steps", make_2_steps);
blocking_on_standby = configuration->property(role + ".blocking_on_standby", blocking_on_standby);
enable_doppler_narrowing = configuration->property(role + ".enable_doppler_narrowing", enable_doppler_narrowing);
reference_bin_min_sidelobes = configuration->property(role + ".reference_bin_min_sidelobes", reference_bin_min_sidelobes);
full_grid_search = configuration->property(role + ".full_grid_search", full_grid_search);
if (pfa <= 0.0)
+19 -17
View File
@@ -74,23 +74,25 @@ public:
bool make_2_steps{false};
bool use_automatic_resampler{false};
bool enable_monitor_output{false};
// When Doppler is assisted (doppler_uncertainty == 0), collapse the search to
// the known bin + one reference bin instead of the full grid. Off by default;
// enable per-implementation in the .conf (e.g.
// Acquisition_5X.enable_doppler_narrowing = true).
//
// Applies to any acquisition implementation built on pcps_acquisition (the vast
// majority of them); FPGA-offloaded acquisitions use a separate implementation
// that never calls set_doppler_uncertainty(), so this has no effect there.
//
// Only takes effect when the caller also passes doppler_uncertainty == 0 to
// set_doppler_uncertainty() -- in practice, this means
// GNSS-SDR.assist_dual_frequency_acq must also be enabled and a Doppler
// projection from the satellite's already-tracked primary frequency must have
// succeeded (see GNSSFlowgraph::acquisition_manager()). With
// assist_dual_frequency_acq off, or when no projection is available yet, this
// flag has no effect and the full configured Doppler grid is always searched.
bool enable_doppler_narrowing{false};
// Target number of correlation sidelobes (in Doppler) the CFAR noise-floor
// reference bin should clear from the search grid's own candidate span, used
// only to decide WHETHER a plain full grid needs a dedicated extra reference
// row instead of reusing an in-grid candidate (see
// d_full_grid_reference_needs_extra_row in pcps_acquisition.h) -- never to
// place that row (or narrowed mode's own reference row) beyond the configured
// doppler_max. doppler_max is the receiver-validated edge of the search/filter
// passband the rest of the acquisition chain is designed for; searching beyond
// it to chase a theoretical sidelobe target risks sampling "noise" from a
// region the decimation/anti-alias response is no longer flat, corrupting the
// estimate instead of cleaning it up (this cost real satellites at hot start
// in an earlier version that did push beyond doppler_max -- see
// update_grid_doppler_wipeoffs()'s narrowed-branch comment). Sidelobe spacing
// is set by the coherent integration time (~1/sampled_ms), not by doppler_step
// or the bin count, so at a small enough grid or doppler_max, this target
// simply won't be met -- accepted, not something to fix by exceeding
// doppler_max. Only takes effect when use_CFAR_algorithm_flag is set (the
// non-CFAR peak-ratio statistic never uses a Doppler-domain reference).
uint32_t reference_bin_min_sidelobes{4U};
// Accumulate through the full max_dwells before deciding accept/reject, instead
// of exiting as soon as any single dwell's (possibly still noisy, partially
+2 -2
View File
@@ -221,10 +221,10 @@ void Channel::stop_channel()
}
void Channel::assist_acquisition_doppler(double Carrier_Doppler_hz, uint32_t doppler_uncertainty)
void Channel::assist_acquisition_doppler(double Carrier_Doppler_hz, uint32_t doppler_num_bins)
{
acq_->set_doppler_center(static_cast<int>(Carrier_Doppler_hz));
acq_->set_doppler_uncertainty(doppler_uncertainty);
acq_->set_doppler_num_bins(doppler_num_bins);
}
+1 -1
View File
@@ -89,7 +89,7 @@ public:
void stop_channel() override; //!< Stop the State Machine
void set_signal(const Gnss_Signal& gnss_signal_) override; //!< Sets the channel GNSS signal
void assist_acquisition_doppler(double Carrier_Doppler_hz, uint32_t doppler_uncertainty = 1) override;
void assist_acquisition_doppler(double Carrier_Doppler_hz, uint32_t doppler_num_bins = 0) override;
inline std::shared_ptr<AcquisitionInterface> acquisition() const { return acq_; }
inline std::shared_ptr<TrackingInterface> tracking() const { return trk_; }
+9 -5
View File
@@ -54,11 +54,15 @@ public:
virtual void set_channel(unsigned int channel_id) = 0;
virtual void set_channel_fsm(std::weak_ptr<ChannelFsm> channel_fsm) = 0;
virtual void set_doppler_center(int /*doppler_center*/) {}
//! doppler_uncertainty == 0 restricts the Doppler search to a single bin
//! centered on set_doppler_center() (the Doppler is exactly known, as when
//! assisted by an already-tracked primary frequency); any other value
//! searches the full configured Doppler range.
virtual void set_doppler_uncertainty(unsigned int /*doppler_uncertainty*/) {}
//! Number of Doppler bins to search, centered on set_doppler_center().
//! doppler_num_bins == 0 searches the full configured Doppler range
//! (computed from doppler_max/doppler_step -- the one value a caller
//! can't supply directly, since that count is implementation-private);
//! any other value is the literal candidate bin count to search -- 1 for
//! an exactly-known Doppler (as when assisted by an already-tracked
//! primary frequency or a visibility-aware prediction), or any N in
//! between for a search with partial uncertainty.
virtual void set_doppler_num_bins(unsigned int /*doppler_num_bins*/) {}
virtual void set_local_code() = 0;
virtual signed int mag() = 0;
virtual void reset() = 0;
+3 -1
View File
@@ -52,7 +52,9 @@ public:
virtual gr::basic_block_sptr get_right_block() = 0;
virtual Gnss_Signal get_signal() = 0;
virtual void start_acquisition() = 0;
virtual void assist_acquisition_doppler(double Carrier_Doppler_hz, uint32_t doppler_uncertainty = 1) = 0;
// doppler_num_bins: number of Doppler bins to search, or 0 (the default)
// for the full configured range -- see AcquisitionInterface::set_doppler_num_bins().
virtual void assist_acquisition_doppler(double Carrier_Doppler_hz, uint32_t doppler_num_bins = 0) = 0;
virtual void stop_channel() = 0;
virtual void set_signal(const Gnss_Signal&) = 0;
};
+5 -4
View File
@@ -1825,14 +1825,15 @@ void GNSSFlowgraph::acquisition_manager(unsigned int who)
<< ", Signal " << channels_[current_channel]->get_signal().get_signal_str();
if (assistance_available == true && configuration_->property("GNSS-SDR.assist_dual_frequency_acq", multiband_))
{
// Doppler is known from the tracked assisting band (already
// projected by search_next_signal()): search a single Doppler bin.
channels_[current_channel]->assist_acquisition_doppler(estimated_doppler, 0);
// Doppler is exactly known from the already-tracked assisting
// frequency (search_next_signal() returns it already projected
// to this band): restrict the search to a single Doppler bin.
channels_[current_channel]->assist_acquisition_doppler(estimated_doppler, 1);
}
else
{
// set Doppler center to 0 Hz and search the full Doppler range
channels_[current_channel]->assist_acquisition_doppler(0, 1);
channels_[current_channel]->assist_acquisition_doppler(0, 0);
}
#if ENABLE_FPGA
if (enable_fpga_offloading_)
@@ -1,7 +1,8 @@
/*!
* \file pcps_acquisition_doppler_narrowing_test.cc
* \brief Tests for Acq_Conf::enable_doppler_narrowing (assisted-acquisition
* Doppler search narrowing) in pcps_acquisition, using the same GPS L1 C/A
* \brief Tests for pcps_acquisition::set_doppler_num_bins() (the unified
* Doppler search bin-count API, covering both the full configured grid and
* any narrower/assisted candidate count), using the same GPS L1 C/A
* signal/fixture pattern as gps_l1_ca_pcps_acquisition_test.cc.
*
* -----------------------------------------------------------------------------
@@ -135,7 +136,7 @@ protected:
// full-grid Doppler bins pcps_acquisition computes
// (d_num_doppler_bins == ceil(2*doppler_max/doppler_step)), since that bin
// count is exactly what the reviewed bug depended on.
void init(unsigned int doppler_max, unsigned int doppler_step, bool enable_doppler_narrowing, bool use_cfar);
void init(unsigned int doppler_max, unsigned int doppler_step, bool use_cfar);
gr::top_block_sptr top_block;
std::shared_ptr<InMemoryConfiguration> config;
@@ -143,7 +144,7 @@ protected:
};
void PcpsAcquisitionDopplerNarrowingTest::init(unsigned int doppler_max, unsigned int doppler_step, bool enable_doppler_narrowing, bool use_cfar)
void PcpsAcquisitionDopplerNarrowingTest::init(unsigned int doppler_max, unsigned int doppler_step, bool use_cfar)
{
gnss_synchro.Channel_ID = 0;
gnss_synchro.System = 'G';
@@ -158,12 +159,11 @@ void PcpsAcquisitionDopplerNarrowingTest::init(unsigned int doppler_max, unsigne
config->set_property("Acquisition_1C.doppler_max", std::to_string(doppler_max));
config->set_property("Acquisition_1C.doppler_step", std::to_string(doppler_step));
config->set_property("Acquisition_1C.repeat_satellite", "false");
config->set_property("Acquisition_1C.enable_doppler_narrowing", enable_doppler_narrowing ? "true" : "false");
if (use_cfar)
{
// pfa > 0 both switches Acq_Conf::use_CFAR_algorithm_flag to true and
// makes d_threshold/d_threshold_narrowed computed (not the fixed
// .threshold value) -- needed to exercise the PFA-recalibration fix.
// makes d_threshold_active computed (not the fixed .threshold value)
// -- needed to exercise the PFA-recalibration fix.
// 0.001 is the same value used by several other acquisition tests in
// this suite (e.g. Galileo E1, GLONASS) against comparably strong
// captures.
@@ -182,7 +182,7 @@ namespace
{
// Runs one acquisition attempt against the known-truth capture and returns
// the completed adapter + received message code, so each TEST_F only has to
// state its own doppler_center/doppler_uncertainty/assertions.
// state its own doppler_center/doppler_num_bins/assertions.
struct RunResult
{
int rx_message;
@@ -191,7 +191,7 @@ struct RunResult
};
RunResult run_acquisition(gr::top_block_sptr &top_block, InMemoryConfiguration *config, Gnss_Synchro &gnss_synchro,
int doppler_center, unsigned int doppler_uncertainty)
int doppler_center, unsigned int doppler_num_bins)
{
top_block = gr::make_top_block("Doppler narrowing acquisition test");
auto acquisition = std::make_shared<PcpsAcquisitionAdapter>(config, "Acquisition_1C", "GPS_L1_CA_PCPS_Acquisition", 1, 0, GPS_1C);
@@ -210,9 +210,9 @@ RunResult run_acquisition(gr::top_block_sptr &top_block, InMemoryConfiguration *
acquisition->set_local_code();
// Simulates GNSSFlowgraph::acquisition_manager() calling
// assist_acquisition_doppler(): the caller decides both the center and
// whether it's exact (doppler_uncertainty == 0) or not (!= 0).
// whether it's exact (doppler_num_bins == 1) or a full-grid search (== 0).
acquisition->set_doppler_center(doppler_center);
acquisition->set_doppler_uncertainty(doppler_uncertainty);
acquisition->set_doppler_num_bins(doppler_num_bins);
acquisition->reset();
top_block->run();
@@ -228,10 +228,10 @@ RunResult run_acquisition(gr::top_block_sptr &top_block, InMemoryConfiguration *
TEST_F(PcpsAcquisitionDopplerNarrowingTest /*unused*/, NarrowingDisabled /*unused*/)
{
// enable_doppler_narrowing = false: even though the caller passes
// doppler_uncertainty == 0 (as an assisted acquisition would), the full
// configured Doppler grid must still be searched.
init(/*doppler_max=*/5000, /*doppler_step=*/100, /*enable_doppler_narrowing=*/false, /*use_cfar=*/false);
// A regular (unassisted, N == 0 full-grid sentinel) request must search
// the full configured Doppler grid, same as any Acquisition_<sig> not
// using assisted search.
init(/*doppler_max=*/5000, /*doppler_step=*/100, /*use_cfar=*/false);
const RunResult result = run_acquisition(top_block, config.get(), gnss_synchro, static_cast<int>(kTrueDopplerHz), 0);
@@ -243,33 +243,22 @@ TEST_F(PcpsAcquisitionDopplerNarrowingTest /*unused*/, NarrowingDisabled /*unuse
TEST_F(PcpsAcquisitionDopplerNarrowingTest /*unused*/, NarrowingEnabledOneBinGrid /*unused*/)
{
// doppler_step >= 2*doppler_max -> d_num_doppler_bins == 1. Regression test
// for the one-bin assisted case: narrowed mode still needs two rows (the
// assisted candidate and its noise reference), so storage must not be sized
// only from the one-row full grid. The assisted candidate must remain exactly
// at the center supplied by the caller.
init(/*doppler_max=*/5000, /*doppler_step=*/10000, /*enable_doppler_narrowing=*/true, /*use_cfar=*/false);
// doppler_step >= 2*doppler_max -> d_num_doppler_bins == 1, so an assisted
// request (N == 1) coincides exactly with the full grid's own size.
// Regression test for the P1 fix: this must NOT force a second active bin
// into a 1-row grid allocation (that was an out-of-bounds write) -- the
// block just runs its (degenerate, single-hypothesis) grid either way. The
// single bin is placed exactly at doppler_center (update_grid_doppler_wipeoffs()'s
// symmetric half_span placement collapses to zero offset when there's only
// one bin), so a successful acquisition here also confirms that centering,
// not just "didn't crash".
init(/*doppler_max=*/5000, /*doppler_step=*/10000, /*use_cfar=*/false);
const int doppler_center = static_cast<int>(kTrueDopplerHz);
const RunResult result = run_acquisition(top_block, config.get(), gnss_synchro, doppler_center, 0);
const RunResult result = run_acquisition(top_block, config.get(), gnss_synchro, doppler_center, 1);
ASSERT_EQ(1, result.rx_message) << "Acquisition failure with narrowed assistance over a 1-bin full grid.";
EXPECT_LE(result.doppler_error_hz, 1) << "Narrowed acquisition did not search the assisted Doppler center.";
EXPECT_LT(result.delay_error_chips, 0.5) << "Delay error exceeds the expected value: 0.5 chips";
}
TEST_F(PcpsAcquisitionDopplerNarrowingTest /*unused*/, NarrowingEnabledOneBinGridCfar /*unused*/)
{
// Exercise the same one-bin allocation edge through CFAR, which consumes the
// second narrowed row as its noise-power reference.
init(/*doppler_max=*/5000, /*doppler_step=*/10000, /*enable_doppler_narrowing=*/true, /*use_cfar=*/true);
const int doppler_center = static_cast<int>(kTrueDopplerHz);
const RunResult result = run_acquisition(top_block, config.get(), gnss_synchro, doppler_center, 0);
ASSERT_EQ(1, result.rx_message) << "CFAR acquisition failure with narrowed assistance over a 1-bin full grid.";
EXPECT_LE(result.doppler_error_hz, 1) << "CFAR narrowed acquisition did not search the assisted Doppler center.";
ASSERT_EQ(1, result.rx_message) << "Acquisition failure with a 1-bin full grid.";
EXPECT_LE(result.doppler_error_hz, 666) << "Doppler error exceeds the expected value: 666 Hz = 2/(3*integration period)";
EXPECT_LT(result.delay_error_chips, 0.5) << "Delay error exceeds the expected value: 0.5 chips";
}
@@ -277,23 +266,43 @@ TEST_F(PcpsAcquisitionDopplerNarrowingTest /*unused*/, NarrowingEnabledOneBinGri
TEST_F(PcpsAcquisitionDopplerNarrowingTest /*unused*/, NarrowingEnabledTwoBinGrid /*unused*/)
{
// doppler_max=5000, doppler_step=6000 -> d_num_doppler_bins == 2, exactly
// matching narrowed mode's own active-bin count. Regression test for the
// other half of the P1 fix: the old code inferred "is this narrowed?" as
// active_bins < full_bins, which is false here (2 < 2) even though this
// *is* a narrowed request, so it silently fell back to a plain 2-bin full
// grid instead of {known center, center + doppler_max}. That fallback's
// nearest bin to the true Doppler is 1000 Hz off (a near-total phase
// cancellation over the 1 ms coherent window), while the correct assisted
// bin 0 lands exactly on it.
init(/*doppler_max=*/5000, /*doppler_step=*/6000, /*enable_doppler_narrowing=*/true, /*use_cfar=*/false);
// matching an assisted (N == 1 + 1 reference row) request's own active-bin
// count. Regression test for the other half of the P1 fix: the old code
// inferred "is this narrowed?" as active_bins < full_bins, which is false
// here (2 < 2) even though this *is* a narrowed (N == 1) request, so it
// silently fell back to a plain 2-bin full grid instead of {known center,
// center + doppler_max}. That fallback's nearest bin to the true Doppler
// is 1000 Hz off (a near-total phase cancellation over the 1 ms coherent
// window), while the correct assisted bin 0 lands exactly on it.
init(/*doppler_max=*/5000, /*doppler_step=*/6000, /*use_cfar=*/false);
const RunResult result = run_acquisition(top_block, config.get(), gnss_synchro, static_cast<int>(kTrueDopplerHz), 0);
const RunResult result = run_acquisition(top_block, config.get(), gnss_synchro, static_cast<int>(kTrueDopplerHz), 1);
ASSERT_EQ(1, result.rx_message) << "Acquisition failure: narrowing likely did not activate for a 2-bin full grid (see d_doppler_search_narrowed).";
ASSERT_EQ(1, result.rx_message) << "Acquisition failure: narrowing likely did not activate for a 2-bin full grid (see d_num_reference_rows_active).";
EXPECT_LE(result.doppler_error_hz, 200) << "Doppler error indicates the un-narrowed 2-bin fallback ran instead of the assisted {center, center+doppler_max} pair.";
}
TEST_F(PcpsAcquisitionDopplerNarrowingTest /*unused*/, NarrowingWithMultipleCandidateBinsRecoversOffsetEstimate /*unused*/)
{
// A literal N == 5 request, doppler_step = 200: candidate bins span
// center + {-2,-1,0,+1,+2}*200 Hz, i.e. +/-400 Hz around the requested
// center. Deliberately mis-center that request by exactly -2 steps
// (-400 Hz) from the true Doppler, so the true value only exists at the
// *last* (k=4, +2 steps) candidate bin -- proving the extra candidate
// bins are actually searched, not just allocated.
constexpr unsigned int kDopplerStep = 200;
constexpr unsigned int kNumBins = 5;
init(/*doppler_max=*/5000, /*doppler_step=*/kDopplerStep, /*use_cfar=*/false);
const int center = static_cast<int>(kTrueDopplerHz) - 2 * static_cast<int>(kDopplerStep);
const RunResult result = run_acquisition(top_block, config.get(), gnss_synchro, center, kNumBins);
ASSERT_EQ(1, result.rx_message) << "Acquisition failure: the true Doppler is 2 steps away from the requested center, only reachable via the extra candidate bins.";
EXPECT_LE(result.doppler_error_hz, 200) << "Doppler error indicates the wrong candidate bin (or the reference bin) was selected.";
}
TEST_F(PcpsAcquisitionDopplerNarrowingTest /*unused*/, CfarExcludesReferenceBinFromCandidacy /*unused*/)
{
// Deliberately mis-center the assist so the *true* signal falls on bin 1
@@ -302,10 +311,10 @@ TEST_F(PcpsAcquisitionDopplerNarrowingTest /*unused*/, CfarExcludesReferenceBinF
// scanned every computed bin, so bin 1's strong real correlation could win
// and get reported as if it were the trusted assisted center. With the
// fix, only bin 0 (pure noise here) is ever a candidate.
init(/*doppler_max=*/5000, /*doppler_step=*/5000, /*enable_doppler_narrowing=*/true, /*use_cfar=*/true);
init(/*doppler_max=*/5000, /*doppler_step=*/5000, /*use_cfar=*/true);
const int doppler_center = static_cast<int>(kTrueDopplerHz) - 5000; // bin 0 = center (noise); bin 1 = center + 5000 = true Doppler
const RunResult result = run_acquisition(top_block, config.get(), gnss_synchro, doppler_center, 0);
const RunResult result = run_acquisition(top_block, config.get(), gnss_synchro, doppler_center, 1);
EXPECT_GT(result.doppler_error_hz, 500) << "The noise-reference bin (bin 1) was reported as the acquisition result.";
}
@@ -315,10 +324,10 @@ TEST_F(PcpsAcquisitionDopplerNarrowingTest /*unused*/, PeakRatioExcludesReferenc
{
// Same construction as CfarExcludesReferenceBinFromCandidacy, exercising
// the non-CFAR (first_vs_second_peak_statistic) path instead.
init(/*doppler_max=*/5000, /*doppler_step=*/5000, /*enable_doppler_narrowing=*/true, /*use_cfar=*/false);
init(/*doppler_max=*/5000, /*doppler_step=*/5000, /*use_cfar=*/false);
const int doppler_center = static_cast<int>(kTrueDopplerHz) - 5000;
const RunResult result = run_acquisition(top_block, config.get(), gnss_synchro, doppler_center, 0);
const RunResult result = run_acquisition(top_block, config.get(), gnss_synchro, doppler_center, 1);
EXPECT_GT(result.doppler_error_hz, 500) << "The noise-reference bin (bin 1) was reported as the acquisition result.";
}
@@ -326,13 +335,13 @@ TEST_F(PcpsAcquisitionDopplerNarrowingTest /*unused*/, PeakRatioExcludesReferenc
TEST_F(PcpsAcquisitionDopplerNarrowingTest /*unused*/, FullToNarrowTransition /*unused*/)
{
// Toggle doppler_uncertainty twice before running (full -> narrow -> full),
// exercising set_doppler_uncertainty()'s resize/rebuild path
// Toggle doppler_num_bins twice before running (full -> narrow -> full),
// exercising set_doppler_num_bins()'s resize/rebuild path
// (update_grid_doppler_wipeoffs()) more than once on the same block
// instance, then verify the final (full-grid) state still acquires
// correctly -- regression coverage for the dump/grid-sizing fix (section 5)
// interacting with d_doppler_search_narrowed toggling.
init(/*doppler_max=*/5000, /*doppler_step=*/100, /*enable_doppler_narrowing=*/true, /*use_cfar=*/false);
// interacting with d_num_reference_rows_active toggling.
init(/*doppler_max=*/5000, /*doppler_step=*/100, /*use_cfar=*/false);
top_block = gr::make_top_block("Doppler narrowing transition test");
auto acquisition = std::make_shared<PcpsAcquisitionAdapter>(config.get(), "Acquisition_1C", "GPS_L1_CA_PCPS_Acquisition", 1, 0, GPS_1C);
@@ -350,8 +359,8 @@ TEST_F(PcpsAcquisitionDopplerNarrowingTest /*unused*/, FullToNarrowTransition /*
acquisition->set_local_code();
acquisition->set_doppler_center(static_cast<int>(kTrueDopplerHz));
acquisition->set_doppler_uncertainty(0); // narrow
acquisition->set_doppler_uncertainty(1); // back to full grid
acquisition->set_doppler_num_bins(1); // narrow
acquisition->set_doppler_num_bins(0); // back to full grid
acquisition->reset();
top_block->run();
@@ -379,7 +388,6 @@ double probe_doppler_hz_4ms_capture(std::shared_ptr<InMemoryConfiguration> &conf
// second set_property() call for the same key is a silent no-op.
config->supersede_property("Acquisition_1C.doppler_max", "10000");
config->supersede_property("Acquisition_1C.doppler_step", "100");
config->supersede_property("Acquisition_1C.enable_doppler_narrowing", "false");
auto acquisition = std::make_shared<PcpsAcquisitionAdapter>(config.get(), "Acquisition_1C", "GPS_L1_CA_PCPS_Acquisition", 1, 0, GPS_1C);
auto msg_rx = PcpsAcquisitionDopplerNarrowingTest_msg_rx_make();
@@ -414,7 +422,7 @@ TEST_F(PcpsAcquisitionDopplerNarrowingTest /*unused*/, TwoStepAcquisitionWithNar
// ValidationOfResultsMakeTwoStep in gps_l1_ca_pcps_acquisition_test.cc,
// since two-step acquisition needs more samples than the 2 ms capture the
// other tests in this file use provides.
init(/*doppler_max=*/5000, /*doppler_step=*/6000, /*enable_doppler_narrowing=*/true, /*use_cfar=*/false);
init(/*doppler_max=*/5000, /*doppler_step=*/6000, /*use_cfar=*/false);
const auto assist_doppler_hz = probe_doppler_hz_4ms_capture(config, gnss_synchro);
gnss_synchro = Gnss_Synchro();
@@ -423,7 +431,6 @@ TEST_F(PcpsAcquisitionDopplerNarrowingTest /*unused*/, TwoStepAcquisitionWithNar
// set_property() is fine for these).
config->supersede_property("Acquisition_1C.doppler_max", "5000");
config->supersede_property("Acquisition_1C.doppler_step", "6000");
config->supersede_property("Acquisition_1C.enable_doppler_narrowing", "true");
config->set_property("Acquisition_1C.make_two_steps", "true");
config->set_property("Acquisition_1C.second_nbins", "5");
config->set_property("Acquisition_1C.second_doppler_step", "20");
@@ -450,7 +457,7 @@ TEST_F(PcpsAcquisitionDopplerNarrowingTest /*unused*/, TwoStepAcquisitionWithNar
acquisition->set_local_code();
acquisition->set_doppler_center(static_cast<int>(std::lround(assist_doppler_hz)));
acquisition->set_doppler_uncertainty(0);
acquisition->set_doppler_num_bins(1);
acquisition->reset();
top_block->run();
@@ -466,10 +473,14 @@ TEST_F(PcpsAcquisitionDopplerNarrowingTest /*unused*/, TwoStepAcquisitionWithNar
TEST_F(PcpsAcquisitionDopplerNarrowingTest /*unused*/, NarrowedDumpMetadata /*unused*/)
{
// Regression test for the section 5 fix: a narrowed dump must describe
// itself accurately (2 columns, {0, doppler_max} encoding, an explicit
// doppler_narrowed flag) instead of claiming the full grid's width/step
// while actually only having written 2 live columns.
init(/*doppler_max=*/5000, /*doppler_step=*/5000, /*enable_doppler_narrowing=*/true, /*use_cfar=*/false);
// itself accurately (2 columns, an explicit doppler_narrowed flag) instead
// of claiming the full grid's width/step while actually only having
// written 2 live columns. With a single (literal N == 1) candidate bin,
// half_span == 0, so dump_doppler_max == 0 and dump_doppler_step == the
// real per-bin step (here == doppler_max, since this test configures them
// equal) -- see NarrowedDumpMetadataMultipleCandidateBins below for a case
// where doppler_max and doppler_step differ.
init(/*doppler_max=*/5000, /*doppler_step=*/5000, /*use_cfar=*/false);
// supersede_property, not set_property: init() already called set_property
// for "Acquisition_1C.dump" (InMemoryConfiguration::set_property() is a
// std::map::insert, so a second set_property() with the same key is a
@@ -486,7 +497,7 @@ TEST_F(PcpsAcquisitionDopplerNarrowingTest /*unused*/, NarrowedDumpMetadata /*un
fs::create_directory(data_str);
const int doppler_center = static_cast<int>(kTrueDopplerHz);
const RunResult result = run_acquisition(top_block, config.get(), gnss_synchro, doppler_center, 0);
const RunResult result = run_acquisition(top_block, config.get(), gnss_synchro, doppler_center, 1);
ASSERT_EQ(1, result.rx_message) << "Acquisition failure while producing the narrowed dump.";
const std::string dump_filename = "./tmp-acq-narrow-dump/acquisition_G_1C_ch_1_1_sat_1.mat";
@@ -505,12 +516,12 @@ TEST_F(PcpsAcquisitionDopplerNarrowingTest /*unused*/, NarrowedDumpMetadata /*un
matvar_t *doppler_max_var = Mat_VarRead(matfp, "doppler_max");
ASSERT_NE(doppler_max_var, nullptr) << "doppler_max missing from narrowed dump";
EXPECT_EQ(0, *static_cast<int32_t *>(doppler_max_var->data)) << "Narrowed dumps encode doppler_max=0 (bin 0 == the known center).";
EXPECT_EQ(0, *static_cast<int32_t *>(doppler_max_var->data)) << "With 1 candidate bin, half_span == 0, so dump_doppler_max should be 0 (bin 0 == the known center).";
Mat_VarFree(doppler_max_var);
matvar_t *doppler_step_var = Mat_VarRead(matfp, "doppler_step");
ASSERT_NE(doppler_step_var, nullptr) << "doppler_step missing from narrowed dump";
EXPECT_EQ(5000, *static_cast<int32_t *>(doppler_step_var->data)) << "Narrowed dumps encode doppler_step=doppler_max (bin 1 == center + doppler_max).";
EXPECT_EQ(5000, *static_cast<int32_t *>(doppler_step_var->data)) << "dump_doppler_step should be the real per-bin step (5000 here, same as doppler_max only because this test configures them equal).";
Mat_VarFree(doppler_step_var);
matvar_t *doppler_center_var = Mat_VarRead(matfp, "doppler_center");
@@ -521,3 +532,163 @@ TEST_F(PcpsAcquisitionDopplerNarrowingTest /*unused*/, NarrowedDumpMetadata /*un
Mat_Close(matfp);
fs::remove_all(data_str);
}
TEST_F(PcpsAcquisitionDopplerNarrowingTest /*unused*/, NarrowedDumpMetadataMultipleCandidateBins /*unused*/)
{
// Same as NarrowedDumpMetadata, but with doppler_max != doppler_step and a
// literal N == 5 request, so a stale dump encoding (previously written
// assuming exactly 1 candidate bin) can't pass by numeric coincidence:
// half_span = (5-1)/2 = 2, so dump_doppler_max should be 2*200 = 400 Hz,
// and dump_doppler_step the real per-bin step (200 Hz), with 5+2 = 7 live
// columns (5 candidates + the opposite-sign reference pair -- more than
// one real candidate means both +/-doppler_max reference rows are
// computed, see d_num_reference_rows_active).
constexpr unsigned int kDopplerStep = 200;
constexpr unsigned int kNumBins = 5;
init(/*doppler_max=*/5000, /*doppler_step=*/kDopplerStep, /*use_cfar=*/false);
config->supersede_property("Acquisition_1C.dump", "true");
config->supersede_property("Acquisition_1C.dump_filename", "./tmp-acq-narrow-dump-multi/acquisition");
config->supersede_property("Acquisition_1C.dump_channel", "1");
std::string data_str = "./tmp-acq-narrow-dump-multi";
if (fs::exists(data_str))
{
fs::remove_all(data_str);
}
fs::create_directory(data_str);
const int doppler_center = static_cast<int>(kTrueDopplerHz);
const RunResult result = run_acquisition(top_block, config.get(), gnss_synchro, doppler_center, kNumBins);
ASSERT_EQ(1, result.rx_message) << "Acquisition failure while producing the narrowed multi-bin dump.";
const std::string dump_filename = "./tmp-acq-narrow-dump-multi/acquisition_G_1C_ch_1_1_sat_1.mat";
mat_t *matfp = Mat_Open(dump_filename.c_str(), MAT_ACC_RDONLY);
ASSERT_NE(matfp, nullptr) << "Could not open narrowed multi-bin acquisition dump " << dump_filename;
matvar_t *grid_var = Mat_VarRead(matfp, "acq_grid");
ASSERT_NE(grid_var, nullptr) << "acq_grid missing from narrowed multi-bin dump";
EXPECT_EQ(kNumBins + 2, grid_var->dims[1]) << "Narrowed acq_grid should be kNumBins + 2 columns wide (candidates plus the opposite-sign reference pair).";
Mat_VarFree(grid_var);
matvar_t *doppler_max_var = Mat_VarRead(matfp, "doppler_max");
ASSERT_NE(doppler_max_var, nullptr) << "doppler_max missing from narrowed multi-bin dump";
EXPECT_EQ(400, *static_cast<int32_t *>(doppler_max_var->data)) << "half_span*doppler_step = 2*200 = 400.";
Mat_VarFree(doppler_max_var);
matvar_t *doppler_step_var = Mat_VarRead(matfp, "doppler_step");
ASSERT_NE(doppler_step_var, nullptr) << "doppler_step missing from narrowed multi-bin dump";
EXPECT_EQ(static_cast<int32_t>(kDopplerStep), *static_cast<int32_t *>(doppler_step_var->data)) << "dump_doppler_step should be the real per-bin step, not doppler_max.";
Mat_VarFree(doppler_step_var);
Mat_Close(matfp);
fs::remove_all(data_str);
}
TEST_F(PcpsAcquisitionDopplerNarrowingTest /*unused*/, FullGridSingleBinCfarUsesDedicatedReferenceRow /*unused*/)
{
// Plain full grid (the N == 0 sentinel), configured so doppler_step >=
// 2*doppler_max collapses it to exactly 1 bin, with CFAR
// enabled (pfa > 0, use_CFAR_algorithm_flag true). Regression coverage for
// the sidelobe-floor fix: the generic "opposite bin" wraparound
// (index_doppler + num_doppler_bins/2) % num_doppler_bins resolves to the
// *same* bin when num_doppler_bins == 1 (num_doppler_bins/2 == 0),
// self-contaminating the noise-floor estimate with the signal itself. A
// true 1-bin grid can never clear reference_bin_min_sidelobes in-grid (0
// separation), so d_full_grid_reference_needs_extra_row is always true
// here: max_to_input_power_statistic() uses a dedicated extra reference row
// instead (see FullGridReferenceRowClearsSidelobeFloor below for direct
// coverage of where that row lands), so CFAR detection still works, and
// the single candidate bin, centered on doppler_center, lands on the true
// Doppler.
init(/*doppler_max=*/250, /*doppler_step=*/500, /*use_cfar=*/true);
const RunResult result = run_acquisition(top_block, config.get(), gnss_synchro, static_cast<int>(kTrueDopplerHz), 0);
ASSERT_EQ(1, result.rx_message) << "CFAR acquisition failure with a true single-bin full grid (self-referencing noise estimate?).";
EXPECT_LE(result.doppler_error_hz, 666) << "Doppler error exceeds the expected value: 666 Hz = 2/(3*integration period)";
}
TEST_F(PcpsAcquisitionDopplerNarrowingTest /*unused*/, FullGridReferenceRowClearsSidelobeFloor /*unused*/)
{
// Direct coverage of d_full_grid_reference_needs_extra_row's placement via
// the dump: doppler_max=3200, doppler_step=125 -> d_num_doppler_bins =
// ceil(6400/125) = 52, wraparound offset = 52/2 = 26 bins * 125 Hz = 3250 Hz.
// sampled_ms=1 (init()'s default) -> T=1ms, and with the default
// reference_bin_min_sidelobes=4, the floor is (4+0.5)/0.001 = 4500 Hz --
// wider than the 3250 Hz the in-grid wraparound would reach, so this grid
// (which would look "comfortably wide" by a naive bin-count standard) still
// needs the dedicated extra rows (52 > 1 candidates -> the opposite-sign
// pair, not just a single fixed +doppler_max row). Regression coverage for
// exactly the scenario this feature was built for: a grid too narrow for
// the wraparound heuristic to be trusted, but not so narrow (N=52, not
// N=1 or N=3) that the problem is obvious from the bin count alone.
init(/*doppler_max=*/3200, /*doppler_step=*/125, /*use_cfar=*/true);
config->supersede_property("Acquisition_1C.dump", "true");
config->supersede_property("Acquisition_1C.dump_filename", "./tmp-acq-full-grid-extra-row/acquisition");
config->supersede_property("Acquisition_1C.dump_channel", "1");
std::string data_str = "./tmp-acq-full-grid-extra-row";
if (fs::exists(data_str))
{
fs::remove_all(data_str);
}
fs::create_directory(data_str);
const RunResult result = run_acquisition(top_block, config.get(), gnss_synchro, static_cast<int>(kTrueDopplerHz), 0);
ASSERT_EQ(1, result.rx_message) << "Acquisition failure while producing the full-grid extra-row dump.";
const std::string dump_filename = "./tmp-acq-full-grid-extra-row/acquisition_G_1C_ch_1_1_sat_1.mat";
mat_t *matfp = Mat_Open(dump_filename.c_str(), MAT_ACC_RDONLY);
ASSERT_NE(matfp, nullptr) << "Could not open full-grid extra-row acquisition dump " << dump_filename;
matvar_t *grid_var = Mat_VarRead(matfp, "acq_grid");
ASSERT_NE(grid_var, nullptr) << "acq_grid missing from full-grid extra-row dump";
EXPECT_EQ(54U, grid_var->dims[1]) << "52 candidates + the opposite-sign reference pair, even though this isn't narrowed mode.";
Mat_VarFree(grid_var);
matvar_t *narrowed_var = Mat_VarRead(matfp, "doppler_narrowed");
ASSERT_NE(narrowed_var, nullptr) << "doppler_narrowed missing from full-grid extra-row dump";
EXPECT_EQ(0, *static_cast<int32_t *>(narrowed_var->data)) << "This is a plain full grid, not assisted narrowing.";
Mat_VarFree(narrowed_var);
Mat_Close(matfp);
fs::remove_all(data_str);
}
TEST_F(PcpsAcquisitionDopplerNarrowingTest /*unused*/, FullGridReferenceRowSelectsOppositeSignFromWinner /*unused*/)
{
// Same 52-bin full grid as FullGridReferenceRowClearsSidelobeFloor
// (doppler_max=3200, doppler_step=125, half_span=(52-1)/2=25), but this
// time doppler_center is deliberately offset so the *true* signal lands
// exactly on the outermost "+" candidate (index 51, offset
// (51-25)*125 = 3250 Hz from center): doppler_center =
// kTrueDopplerHz - 3250. This exercises the winner-is-near-the-edge path
// that makes the opposite-sign pair (see d_num_reference_rows_active)
// select the -doppler_max row (landing at doppler_center - 3200 =
// kTrueDopplerHz - 6450) instead of a same-side +doppler_max row that
// would sit only 50 Hz from the true signal.
//
// NOT a proven regression guard against detection failure: d_input_power
// averages magnitude over the *entire* code-phase axis
// (d_effective_fft_size samples), and a real signal's correlation is a
// near-delta spike in code phase, so even a same-side reference row
// sitting exactly on the true Doppler still averages down to a
// genuinely-noise-scale value for a signal this strong (confirmed live
// by temporarily forcing same-side selection here -- detection still
// succeeded, test statistic ~1370 against a ~45 threshold either way).
// This test instead documents that the selection mechanism itself runs
// and picks the intended (opposite-side) row without misbehaving; it
// would need a much stronger/pathological signal to actually flip
// detection success/failure on a same-side vs. opposite-side reference.
init(/*doppler_max=*/3200, /*doppler_step=*/125, /*use_cfar=*/true);
const int doppler_center = static_cast<int>(kTrueDopplerHz) - 3250;
const RunResult result = run_acquisition(top_block, config.get(), gnss_synchro, doppler_center, 0);
ASSERT_EQ(1, result.rx_message) << "Acquisition failure with the true signal at the outermost candidate -- opposite-sign reference selection should not be interfering.";
EXPECT_LE(result.doppler_error_hz, 666) << "Doppler error exceeds the expected value: 666 Hz = 2/(3*integration period)";
}