This commit is contained in:
Carles Fernandez
2026-10-01 15:25:57 +02:00
10 changed files with 486 additions and 9 deletions
+2 -2
View File
@@ -388,7 +388,7 @@ jobs:
- name: build
run: cmake --build build && sudo cmake --install build
- name: test
run: ctest -C Release --test-dir build --exclude-regex volk_gnsssdr_32fc_32f_rotator_dotprodxnpuppet_32fc
run: ctest -C Release --test-dir build
volk-gnsssdr-macos-xcode:
runs-on: macos-latest
@@ -408,7 +408,7 @@ jobs:
- name: install
run: sudo cmake --install build
- name: test
run: ctest -C Release --test-dir build --exclude-regex volk_gnsssdr_32fc_32f_rotator_dotprodxnpuppet_32fc
run: ctest -C Release --test-dir build
shellcheck:
runs-on: ubuntu-latest
+11 -6
View File
@@ -127,13 +127,18 @@ All notable changes to GNSS-SDR will be documented in this file.
per bin and supports `high_dyn=true`. The `pull_in_time_s` and
`bit_synchronization_time_limit_s` budgets start after the scan, allowing the
tracking loops their full settling time. Contributed by @joebre.
- Added a CSV dump of the frequency-refinement scan: the tested Doppler
- Added an optional CSV dump of the frequency-refinement scan, enabled with
`Tracking_<Sig>.f_error_dump=true` (default: `false`). The tested Doppler
frequencies, their correlation power, and the selected frequency are written
to `Tracking_<Sig>.f_error_dump_filename` (default: `./f_error_dump.csv`). Set
the filename to an empty value to disable this output. Channels sharing a
filename write to the same file, with scan, satellite and channel identifiers;
the first scan overwrites any previous file, and later scans in the same
receiver run append their results.
to `Tracking_<Sig>.f_error_dump_filename` (default: `./f_error_dump.csv`).
Channels sharing a filename write to the same file, with scan, satellite and
channel identifiers; the first scan overwrites any previous file, and later
scans in the same receiver run append their results. The Octave scripts
`load_f_error_dump.m`, `find_f_error_scans.m` and `plot_f_error_scan.m` (in
`utils/matlab/libs`) and `plot_all_f_error_scans.m` (in `utils/matlab`) load
and plot the dumped scans, and `utils/matlab/libs/f_error_sim.m` provides a
Monte Carlo simulation of the scan for sizing `f_error_step_num`,
`f_error_accumulation` and `f_error_doppler_step` without a live capture.
### Improvements in Efficiency:
@@ -2292,7 +2292,7 @@ void dll_pll_veml_tracking::run_f_error_scan_step()
const auto rank_it = std::find(bins_by_doppler.begin(), bins_by_doppler.end(), best_bin);
const auto best_bin_doppler_rank = static_cast<uint32_t>(std::distance(bins_by_doppler.begin(), rank_it));
if (!d_trk_parameters.f_error_dump_filename.empty())
if (d_trk_parameters.f_error_dump && !d_trk_parameters.f_error_dump_filename.empty())
{
dump_f_error_csv(d_trk_parameters.f_error_dump_filename, g_f_error_scan_id.fetch_add(1),
d_acquisition_gnss_synchro->PRN, d_trk_parameters.system, d_channel, f_error_cn0_dB_hz, d_carrier_doppler_hz,
@@ -60,6 +60,7 @@ void Dll_Pll_Conf::SetFromConfiguration(const ConfigurationInterface *configurat
high_dyn = configuration->property(role + ".high_dyn", high_dyn);
dump = configuration->property(role + ".dump", dump);
dump_filename = configuration->property(role + ".dump_filename", dump_filename);
f_error_dump = configuration->property(role + ".f_error_dump", f_error_dump);
f_error_dump_filename = configuration->property(role + ".f_error_dump_filename", f_error_dump_filename);
dump_mat = configuration->property(role + ".dump_mat", dump_mat);
pll_bw_hz = configuration->property(role + ".pll_bw_hz", pll_bw_hz);
@@ -92,6 +92,7 @@ public:
bool high_dyn{false};
bool dump{false};
bool dump_mat{true};
bool f_error_dump{false};
bool tow_to_trk{false};
bool bs_use_phase_dot_detector{true};
// BeiDou B1C-specific options.
+227
View File
@@ -0,0 +1,227 @@
% f_error_sim.m
%
% Octave Monte Carlo simulation of the tracking "frequency error reduction"
% state (state 5) implemented in dll_pll_veml_tracking.cc: right after
% pull-in, a passive scan tests f_error_step_num Doppler bins centered on the
% pull-in Doppler estimate, spaced f_error_doppler_step Hz apart
% (bin 0 -> +0, then alternating +step, -step, +2*step, -2*step, ...,
% matching dll_pll_veml_tracking::f_error_bin_multiplier()). Each bin is
% incoherently accumulated for f_error_accumulation code periods (|Prompt|^2
% summed), and the bin with the highest accumulated power is kept as the
% corrected Doppler.
%
% This script reproduces that exact bin geometry and accumulation logic on
% simulated noisy correlator samples, for a given C/N0 and initial (pull-in)
% frequency error, fixed to the Galileo E1 code period (Td = 4 ms). It also
% reproduces the CN0 M2M4 estimator (lock_detectors.cc::cn0_m2m4_estimator)
% applied to the winning bin's raw samples, matching the cn0_dBHz column of
% the receiver's f_error_dump CSV (Tracking_<Sig>.f_error_dump=true).
%
% Usage:
% f_error_sim() % all defaults
% f_error_sim(CN0_dBHz, f_error_step_num, f_error_accumulation, ...
% f_error_doppler_step, f_error_init_Hz, n_trials, seed)
%
% f_error_sim(35, 9, 20, 62.5, 150) % 9 bins at 62.5 Hz, 150 Hz initial error
% f_error_sim(30, 5, 10, 125, -300, 5000, 2)
%
% What it prints:
% - the bin table (index, multiplier, Doppler tested, noiseless sinc loss)
% - Monte Carlo statistics: residual Doppler error after the scan, bin
% "capture" probability (did it pick the bin closest to the true
% frequency?), and CN0 estimate bias/spread on the winning bin
%
% What it plots:
% 1) One example trial's per-bin accumulated power vs. Doppler tested,
% with the true frequency marked -- the same data the receiver writes to
% the doppler_hz / power columns of its f_error_dump CSV (there as
% absolute Doppler values rather than offsets from the pull-in estimate).
% 2) Histogram of the residual Doppler error left after the scan, over all
% Monte Carlo trials.
% -------------------------------------------------------------------------
%
% GNSS-SDR is a Global Navigation Satellite System software-defined receiver.
% This file is part of GNSS-SDR.
%
% SPDX-FileCopyrightText: Joel Brenner, 2026. joel.brenner(at)saphyrion.ch
% SPDX-License-Identifier: GPL-3.0-or-later
%
% -------------------------------------------------------------------------
function f_error_sim(CN0_dBHz, f_error_step_num, f_error_accumulation, f_error_doppler_step, f_error_init_Hz, n_trials, seed)
%% ------------------------- PARAMETERS ----------------------------------
if nargin < 1 || isempty(CN0_dBHz), CN0_dBHz = 35; end % [dB-Hz]
if nargin < 2 || isempty(f_error_step_num), f_error_step_num = 9; end % number of Doppler bins
if nargin < 3 || isempty(f_error_accumulation), f_error_accumulation = 20; end % incoherent accum. per bin [code periods]
if nargin < 4 || isempty(f_error_doppler_step), f_error_doppler_step = 62.5; end % [Hz] bin spacing
if nargin < 5 || isempty(f_error_init_Hz), f_error_init_Hz = 150; end % [Hz] true residual Doppler at pull-in
if nargin < 6 || isempty(n_trials), n_trials = 2000; end % Monte Carlo trial count
if nargin < 7 || isempty(seed), seed = 1; end % RNG seed, [] = random
%% ------------------------------------------------------------------------
if ~isempty(seed)
if exist('rng') ~= 0
% MATLAB, and Octave >= 7: seed the Mersenne Twister without
% switching MATLAB to its legacy generators
rng(seed, 'twister');
else
% Octave < 7 has no rng(); this is what rng(seed, 'twister') does there
rand('state', seed);
randn('state', seed);
end
end
Td = 4e-3; % Galileo E1 code period [s] -- fixed per this script's scope
% mirror Dll_Pll_Conf::SetFromConfiguration(): f_error_step_num must be odd
% (a center bin plus a symmetric number of +/- steps); round up if even.
f_error_step_num = max(1, round(f_error_step_num));
if mod(f_error_step_num, 2) == 0
f_error_step_num = f_error_step_num + 1;
fprintf('note: f_error_step_num rounded up to %d (must be odd)\n', f_error_step_num);
end
num_bins = f_error_step_num;
% bin 0 -> 0; then alternating outward: +1, -1, +2, -2, +3, -3, ...
% (matches dll_pll_veml_tracking::f_error_bin_multiplier(), 0-based bin_index)
bin_mult = zeros(1, num_bins);
for idx = 1:num_bins
bin_index = idx - 1;
if bin_index == 0
bin_mult(idx) = 0;
else
half_steps = floor((bin_index + 1) / 2);
if mod(bin_index, 2) == 1
bin_mult(idx) = half_steps;
else
bin_mult(idx) = -half_steps;
end
end
end
bin_doppler_offset = bin_mult * f_error_doppler_step; % [Hz], relative to pull-in estimate
bin_residual_error = f_error_init_Hz - bin_doppler_offset; % [Hz], true - tested, per bin
sinc0 = @(x) (x == 0) * 1 + (x ~= 0) .* (sin(x + (x == 0)) ./ (x + (x == 0)));
A = sqrt(2 * 10^(CN0_dBHz / 10) * Td); % correlator amplitude; SNR = 2*(C/N0)*Td, unit-variance complex noise per branch
fprintf('--- f_error_sim: CN0=%.1f dB-Hz, f_error_step_num=%d, f_error_accumulation=%d, f_error_doppler_step=%.2f Hz, f_error_init=%.1f Hz, Td=%.1f ms (Galileo E1) ---\n', ...
CN0_dBHz, num_bins, f_error_accumulation, f_error_doppler_step, f_error_init_Hz, Td * 1e3);
% noiseless per-bin loss, for the printed table
theta_bins = pi * bin_residual_error * Td;
bin_loss_dB = 20 * log10(abs(sinc0(theta_bins)) + eps);
fprintf('\n bin mult Doppler[Hz] residual[Hz] sinc loss[dB]\n');
[~, ideal_bin] = min(abs(bin_residual_error));
for idx = 1:num_bins
marker = '';
if idx == ideal_bin, marker = ' <- closest to truth'; end
fprintf(' %2d %+3d %8.2f %8.2f %6.2f%s\n', ...
idx - 1, bin_mult(idx), bin_doppler_offset(idx), bin_residual_error(idx), bin_loss_dB(idx), marker);
end
%% --------------------------- Monte Carlo ---------------------------------
winning_bin = zeros(1, n_trials);
residual_after = zeros(1, n_trials);
cn0_est_dBHz = zeros(1, n_trials);
example_power = []; % saved from trial 1, for plotting
for t = 1:n_trials
bin_power = zeros(1, num_bins);
bin_samples = cell(1, num_bins);
for b = 1:num_bins
theta = pi * bin_residual_error(b) * Td;
amp = A * sinc0(theta);
k = (1:f_error_accumulation).';
phase = (2 * k - 1) * theta; % midpoint-sampling phase ramp, one full 2*theta per epoch
samples = amp * exp(1j * phase) + (randn(f_error_accumulation, 1) + 1j * randn(f_error_accumulation, 1));
bin_power(b) = sum(abs(samples) .^ 2);
bin_samples{b} = samples;
end
[~, best_bin] = max(bin_power);
winning_bin(t) = best_bin;
residual_after(t) = f_error_init_Hz - bin_doppler_offset(best_bin);
cn0_est_dBHz(t) = cn0_m2m4(bin_samples{best_bin}, Td);
if t == 1
example_power = bin_power;
end
end
capture_prob = mean(winning_bin == ideal_bin);
fprintf('\nMonte Carlo results (%d trials):\n', n_trials);
fprintf(' Residual Doppler error after scan: mean=%.2f Hz, std=%.2f Hz, RMSE=%.2f Hz (initial error was %.1f Hz)\n', ...
mean(residual_after), std(residual_after), sqrt(mean(residual_after .^ 2)), f_error_init_Hz);
fprintf(' Bin-capture probability (picked bin closest to truth, bin %d): %.1f%%\n', ideal_bin - 1, 100 * capture_prob);
fprintf(' CN0 estimate on winning bin: mean=%.2f dB-Hz, std=%.2f dB-Hz (true CN0=%.1f dB-Hz)\n', ...
mean(cn0_est_dBHz), std(cn0_est_dBHz), CN0_dBHz);
%% ------------------------------ Plots -------------------------------------
figure('Name', 'Frequency error reduction: example bin scan');
bar(bin_doppler_offset, example_power);
hold on;
yl = ylim();
plot([f_error_init_Hz, f_error_init_Hz], yl, 'r--', 'LineWidth', 1.5);
hold off;
xlabel('Doppler tested [Hz]');
ylabel('Accumulated |Prompt|^2 power');
title(sprintf('Example bin scan (trial 1) -- true error = %.1f Hz', f_error_init_Hz));
legend('bin power', 'true frequency', 'Location', 'northeast');
grid on;
figure('Name', 'Frequency error reduction: residual error distribution');
hist(residual_after, min(40, max(10, round(n_trials / 50))));
xlabel('Residual Doppler error after scan [Hz]');
ylabel('Trial count');
title(sprintf('Residual error after %d-bin scan (initial error = %.1f Hz)', num_bins, f_error_init_Hz));
grid on;
end
function cn0_dBHz = cn0_m2m4(prompt_samples, coh_integration_time_s)
% Octave port of lock_detectors.cc::cn0_m2m4_estimator(), applied to a
% column vector of complex Prompt samples, for exact parity with the
% cn0_dBHz column in the receiver's f_error_dump CSV.
n = numel(prompt_samples);
if n == 0 || coh_integration_time_s == 0
cn0_dBHz = -100;
return;
end
Psig = mean(abs(real(prompt_samples)));
Psig = Psig^2;
p2 = abs(prompt_samples) .^ 2;
m2 = mean(p2);
m4 = mean(p2 .^ 2);
% std::sqrt() of a negative argument returns NaN in C++, but a complex
% value in Octave; map it to NaN explicitly so the fallback branch matches.
aux = 2 * m2^2 - m4;
if aux < 0
aux = NaN;
else
aux = sqrt(aux);
end
if isnan(aux)
denominator = m2 - Psig;
if denominator == 0
cn0_dBHz = -100;
return;
end
SNR_aux = Psig / denominator;
else
denominator = m2 - aux;
if denominator == 0
cn0_dBHz = -100;
return;
end
SNR_aux = aux / denominator;
end
if SNR_aux <= 0
cn0_dBHz = -100;
return;
end
cn0_dBHz = 10 * log10(SNR_aux) - 10 * log10(coh_integration_time_s);
end
+29
View File
@@ -0,0 +1,29 @@
% find_f_error_scans.m
%
% Returns the indices into a scans struct array (as returned by
% load_f_error_dump) matching a given PRN, optionally restricted to a
% specific channel.
%
% Usage:
% idxs = find_f_error_scans(scans, 13); % all scans for PRN 13, any channel
% idxs = find_f_error_scans(scans, 13, 2); % PRN 13 on channel 2 only
%
% See also: load_f_error_dump, plot_f_error_scan
% -------------------------------------------------------------------------
%
% GNSS-SDR is a Global Navigation Satellite System software-defined receiver.
% This file is part of GNSS-SDR.
%
% SPDX-FileCopyrightText: Joel Brenner, 2026. joel.brenner(at)saphyrion.ch
% SPDX-License-Identifier: GPL-3.0-or-later
%
% -------------------------------------------------------------------------
function idxs = find_f_error_scans(scans, prn, channel)
mask = [scans.prn] == prn;
if nargin >= 3 && ~isempty(channel)
mask = mask & ([scans.channel] == channel);
end
idxs = find(mask);
end
+78
View File
@@ -0,0 +1,78 @@
% load_f_error_dump.m
%
% Loads the CSV written by dll_pll_veml_tracking's state-5 (frequency error
% reduction) diagnostic -- see Dll_Pll_Conf::f_error_dump_filename -- into an
% Octave struct array, one element per scan (one satellite's pass through
% state 5). Each row of the CSV is one Doppler bin; this groups rows by
% scan_id.
%
% CSV columns (header row is skipped):
% scan_id, prn, system_char, channel, cn0_dBHz, selected_doppler_hz,
% selected_bin, bin_index, doppler_hz, power
%
% Usage:
% scans = load_f_error_dump(); % default: ./f_error_dump.csv
% scans = load_f_error_dump('/path/to/dump.csv');
% load_f_error_dump(); % no output arg: also prints a summary table
%
% Each scans(i) has fields:
% scan_id, prn, system_char, channel, cn0_dBHz, selected_doppler_hz, selected_bin,
% doppler_hz (row vector, increasing Doppler order), power (row vector, same order)
%
% See also: find_f_error_scans, plot_f_error_scan
% -------------------------------------------------------------------------
%
% GNSS-SDR is a Global Navigation Satellite System software-defined receiver.
% This file is part of GNSS-SDR.
%
% SPDX-FileCopyrightText: Joel Brenner, 2026. joel.brenner(at)saphyrion.ch
% SPDX-License-Identifier: GPL-3.0-or-later
%
% -------------------------------------------------------------------------
function scans = load_f_error_dump(filename)
if nargin < 1 || isempty(filename)
filename = 'f_error_dump.csv';
end
raw = csvread(filename, 1, 0); % skip header row
if isempty(raw)
error('load_f_error_dump: no data rows found in %s', filename);
end
scan_id_col = raw(:, 1);
unique_ids = unique(scan_id_col);
n = numel(unique_ids);
scans = struct('scan_id', {}, 'prn', {}, 'system_char', {}, 'channel', {}, 'cn0_dBHz', {}, ...
'selected_doppler_hz', {}, 'selected_bin', {}, ...
'doppler_hz', {}, 'power', {});
for i = 1:n
rows = raw(scan_id_col == unique_ids(i), :);
[~, order] = sort(rows(:, 8)); % bin_index, defensive (writer already emits increasing Doppler order)
rows = rows(order, :);
scans(i).scan_id = rows(1, 1);
scans(i).prn = rows(1, 2);
scans(i).system_char = rows(1, 3);
scans(i).channel = rows(1, 4);
scans(i).cn0_dBHz = rows(1, 5);
scans(i).selected_doppler_hz = rows(1, 6);
scans(i).selected_bin = rows(1, 7);
scans(i).doppler_hz = rows(:, 9)';
scans(i).power = rows(:, 10)';
end
if nargout == 0
fprintf('%6s %5s %4s %9s %14s %8s %6s\n', 'scan', 'PRN', 'ch', 'CN0[dBHz]', 'selDoppler[Hz]', 'nBins', '');
for i = 1:n
fprintf('%6d %5d %4d %9.2f %14.2f %8d\n', scans(i).scan_id, scans(i).prn, scans(i).channel, ...
scans(i).cn0_dBHz, scans(i).selected_doppler_hz, numel(scans(i).doppler_hz));
end
fprintf('\n%d scans loaded from %s\n', n, filename);
fprintf('PRNs present: %s\n', mat2str(unique([scans.prn])));
clear scans; % avoid echoing the struct array to the console when called as a script
end
end
+59
View File
@@ -0,0 +1,59 @@
% plot_f_error_scan.m
%
% Bar-plots one state-5 frequency-error-reduction scan (as loaded by
% load_f_error_dump), with the selected/winning Doppler marked. Optionally
% overlays the theoretical sinc(pi*Delta_f*Td)^2 envelope (normalized to the
% scan's own peak) for comparison, if the coherent integration time Td [s] is
% given -- e.g. Td=0.004 for Galileo E1, Td=0.001 for GPS L1 C/A.
%
% Usage:
% scans = load_f_error_dump();
% plot_f_error_scan(scans, 5); % plot scans(5)
% plot_f_error_scan(scans, 5, 0.004); % + theoretical Galileo E1 sinc^2 overlay
%
% See also: load_f_error_dump, find_f_error_scans
% -------------------------------------------------------------------------
%
% GNSS-SDR is a Global Navigation Satellite System software-defined receiver.
% This file is part of GNSS-SDR.
%
% SPDX-FileCopyrightText: Joel Brenner, 2026. joel.brenner(at)saphyrion.ch
% SPDX-License-Identifier: GPL-3.0-or-later
%
% -------------------------------------------------------------------------
function plot_f_error_scan(scans, idx, Td)
if idx < 1 || idx > numel(scans)
error('plot_f_error_scan: idx %d out of range (1..%d)', idx, numel(scans));
end
s = scans(idx);
figure();
bar(s.doppler_hz, s.power);
hold on;
if nargin >= 3 && ~isempty(Td)
% theoretical envelope centered on the selected (winning) Doppler, since that's
% this scan's best estimate of the true residual frequency
theta = pi * (s.doppler_hz - s.selected_doppler_hz) * Td;
sinc0 = @(x) (x == 0) * 1 + (x ~= 0) .* (sin(x + (x == 0)) ./ (x + (x == 0)));
envelope = (sinc0(theta) .^ 2) * max(s.power);
plot(s.doppler_hz, envelope, 'k--', 'LineWidth', 1.3);
end
yl = ylim();
plot([s.selected_doppler_hz, s.selected_doppler_hz], yl, 'r--', 'LineWidth', 1.5);
hold off;
xlabel('Doppler tested [Hz]');
ylabel('Accumulated correlation power');
if nargin >= 3 && ~isempty(Td)
legend('bin power', 'theoretical sinc^2 (centered on selected)', 'selected Doppler', 'Location', 'northeast');
else
legend('bin power', 'selected Doppler', 'Location', 'northeast');
end
title(sprintf('PRN%d ch%d scan %d: CN0=%.1f dB-Hz, selected=%.1f Hz (bin %d/%d)', ...
s.prn, s.channel, s.scan_id, s.cn0_dBHz, s.selected_doppler_hz, s.selected_bin, numel(s.doppler_hz) - 1));
grid on;
end
+77
View File
@@ -0,0 +1,77 @@
% plot_all_f_error_scans.m
%
% Bar-plots every scan in a scans struct array (as returned by
% load_f_error_dump), one figure per scan -- a batch wrapper around
% plot_f_error_scan. Optionally restrict to a subset of scans (e.g. from
% find_f_error_scans) and/or save each figure to disk instead of leaving a
% window open per scan (recommended once there are more than a handful).
%
% Usage:
% scans = load_f_error_dump();
% plot_all_f_error_scans(scans); % every scan, one window each
% plot_all_f_error_scans(scans, 0.004); % + theoretical sinc^2 overlay (Galileo E1 Td)
% plot_all_f_error_scans(scans, 0.004, find_f_error_scans(scans, 13)); % only PRN 13's scans
% plot_all_f_error_scans(scans, 0.004, [], 'plots'); % save PNGs into ./plots/ instead of opening windows
%
% See also: load_f_error_dump, find_f_error_scans, plot_f_error_scan
% -------------------------------------------------------------------------
%
% GNSS-SDR is a Global Navigation Satellite System software-defined receiver.
% This file is part of GNSS-SDR.
%
% SPDX-FileCopyrightText: Joel Brenner, 2026. joel.brenner(at)saphyrion.ch
% SPDX-License-Identifier: GPL-3.0-or-later
%
% -------------------------------------------------------------------------
function plot_all_f_error_scans(scans, Td, idxs, save_dir)
if ~exist('load_f_error_dump.m', 'file')
addpath('./libs')
end
if nargin < 2
Td = [];
end
if nargin < 3 || isempty(idxs)
idxs = 1:numel(scans);
end
if nargin < 4
save_dir = '';
end
if ~isempty(save_dir)
% note: do NOT set DefaultFigureVisible='off' here -- combined with an
% invisible figure, saveas()/print() falls back to the fltk renderer,
% which then fails without a real X display. Leaving figures nominally
% visible (but headlessly rendered via gnuplot) is what actually works.
% Use gnuplot only if it is available, and restore the caller's
% toolkit when this function returns (also on error). If gnuplot is
% not installed (or graphics_toolkit does not exist, as in MATLAB),
% keep the current toolkit.
try
prev_toolkit = graphics_toolkit();
graphics_toolkit('gnuplot');
restore_toolkit = onCleanup(@() graphics_toolkit(prev_toolkit));
catch
% keep the current graphics toolkit
end
if ~exist(save_dir, 'dir')
mkdir(save_dir);
end
end
for idx = idxs
s = scans(idx);
plot_f_error_scan(scans, idx, Td);
if ~isempty(save_dir)
fname = fullfile(save_dir, sprintf('scan%04d_prn%d_ch%d.png', s.scan_id, s.prn, s.channel));
saveas(gcf(), fname);
close(gcf());
end
end
if ~isempty(save_dir)
fprintf('Saved %d plots to %s\n', numel(idxs), save_dir);
end
end