diff --git a/utils/python/README.md b/utils/python/README.md index 06636f1ca..8e8b0f8ef 100644 --- a/utils/python/README.md +++ b/utils/python/README.md @@ -26,6 +26,11 @@ repeat them with the default value that applies to each tool. - `-h`, `--help`: print the option list and exit. - `-i`, `--input-path`: directory containing the dump files (default: current directory). +- `--conf`: optional GNSS-SDR configuration file. When provided, scripts infer + missing options such as `--file-prefix`, `--channels`, `--first-channel`, + `--sampling-frequency`, `--signal-type`, and `--nav-sol-period` from the + configuration. Explicit command-line options always take precedence over + values inferred from `--conf`. - `--file-prefix`: the value of the block's `dump_filename` configuration parameter in GNSS-SDR. It may include a directory and an extension; each script reconstructs the actual dump filenames from it exactly the way GNSS-SDR @@ -40,6 +45,13 @@ repeat them with the default value that applies to each tool. Any directory in `--file-prefix` is resolved relative to `--input-path` (an absolute `--file-prefix` path overrides it). + When `--conf` is provided to the generic tracking and acquisition plotters, + a multi-signal configuration expands to every enabled signal. Pass + `--signal-type` to restrict plots to one signal. Single-file modes and + signal-specific scripts still require or prefer one signal. Channel ranges + are computed using GNSS-SDR's receiver order: + `1C, 2S, L5, 1B, 5X, E6, 1G, 2G, B1, B3, 7X, J1, J5`. + - `-o`, `--fig-path`: directory where plots are saved. Defaults to a per-script subdirectory under `./plots/`. - `--show`: display plots interactively after saving them. By default, scripts @@ -83,7 +95,8 @@ Output PNGs (`_2D` and `_3D`): | Option | Default | Description | | ---------------------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------- | | `-i`, `--input-path` | `.` | Directory containing the acquisition `.mat` dumps. | -| `--file-prefix` | `acquisition` | `Acquisition.dump_filename` value (may include a directory/extension). | +| `--conf` | unset | GNSS-SDR configuration used to infer acquisition dump prefixes and signal selectors. | +| `--file-prefix` | `--conf` or `acquisition` | `Acquisition.dump_filename` value (may include a directory/extension). | | `-o`, `--fig-path` | `plots/acquisition` | Output directory for PNGs. | | `--output-basename`, `--output-prefix` | `--file-prefix` basename | Base name for the saved PNGs (directory and extension stripped). | | `--all-files` / `--single-file` | `--all-files` | Plot every matching dump, or a single dump chosen by the selectors below. | @@ -92,7 +105,7 @@ Output PNGs (`_2D` and `_3D`): | `--sat` | `1` in single-file mode | Satellite PRN. Filters in all-files mode; selects the dump in single-file mode. | | `--channel` | `0` in single-file mode | Acquisition channel number. Filter / selector. | | `--execution` | `1` in single-file mode | Acquisition dump execution index. Filter / selector. | -| `--signal-type CODE` | `1C` (GPS L1 C/A) | GNSS-SDR signal code (see table below); case-insensitive. Filters in all-files mode; selects in single-file mode. | +| `--signal-type CODE` | `--conf` or `1C` | GNSS-SDR signal code (see table below); case-insensitive. Filters in all-files mode; selects in single-file mode. | | `--samples-per-chip` | `3` | Samples per chip used by the light-grid interpolation. | | `--samples-per-code` | `25000` | Samples per code used for the 2D normalization. | | `--input-power` | `100.0` | Input power used for the 2D normalization. | @@ -119,8 +132,10 @@ GNSS-SDR configuration and dump filenames (e.g. `--signal-type=E6`): | `J5` | QZSS | L5 | 10230 | In all-files mode `--sat`, `--channel`, `--execution`, and `--signal-type` -filter the matching dumps; if a `--file-prefix` matches no files, the available -dump prefixes found in the directory are listed. +filter the matching dumps. If `--conf` enables several signals and +`--file-prefix` is not set, all configured acquisition dump prefixes are +scanned. If a `--file-prefix` matches no files, the available dump prefixes +found in the directory are listed. Examples: @@ -130,6 +145,7 @@ python3 plot_acq_grid.py --input-path ./out --fig-path ./plots/acquisition python3 plot_acq_grid.py --file-prefix hackrf_l1_acq_dump --channel 0 python3 plot_acq_grid.py --file-prefix hackrf_l1_acq_dump --sat 26 python3 plot_acq_grid.py --signal-type E6 +python3 plot_acq_grid.py --conf conf/File_input/Beidou/gnss-sdr_BDS_B3I_byte.conf --signal-type B3 --input-path ./out python3 plot_acq_grid.py --single-file --signal-type J1 --sat 193 --channel 0 --execution 1 python3 plot_acq_grid.py --single-file --sat 3 --channel 0 --execution 1 --full-view python3 plot_acq_grid.py --include-negative --output-basename my_run @@ -145,11 +161,13 @@ Input pattern: `/.dat`. | Option | Default | Description | | ---------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------- | | `-i`, `--input-path` | `.` | Directory containing the tracking `.dat` dumps. | -| `--file-prefix` | `track_ch` | `Tracking.dump_filename` value (may include a directory/extension). | +| `--conf` | unset | GNSS-SDR configuration used to infer tracking dump prefix, sampling frequency, and channel range. | +| `--file-prefix` | `--conf` or `track_ch` | `Tracking.dump_filename` value (may include a directory/extension). | | `-o`, `--fig-path` | `plots/dll-pll-veml-tracking` | Output directory for PNGs. | -| `--sampling-frequency` | `3000000.0` | Signal sampling frequency in Hz (sets the RX-time axis). | -| `--channels` | `5` | Number of channels to read. | -| `--first-channel` | `0` | First channel number in the dump filenames. | +| `--sampling-frequency` | `--conf` or `3000000.0` | Signal sampling frequency in Hz (sets the RX-time axis). | +| `--channels` | `--conf` or `5` | Number of channels to read. | +| `--first-channel` | `--conf` or `0` | First channel number in the dump filenames. | +| `--signal-type CODE` | inferred if needed | GNSS-SDR signal code used to restrict channel ranges and dump filenames from `--conf`; omitted means all configured signals. | | `--plot-last-outputs` | `0` | Only plot the last N outputs; `0` plots all of them. | | `--no-doppler` | Doppler plots on | Skip the extra Doppler-only plots. | | `--show` | off | Display figures after saving. | @@ -160,6 +178,7 @@ Examples: ```bash python3 dll_pll_veml_plot_sample.py --input-path ./out python3 dll_pll_veml_plot_sample.py --file-prefix tracking_ch_ --channels 8 +python3 dll_pll_veml_plot_sample.py --conf conf/File_input/Galileo/gnss-sdr_Galileo_E1_nsr.conf --signal-type 1B --input-path ./out python3 dll_pll_veml_plot_sample.py --plot-last-outputs 5000 --no-doppler ``` @@ -173,11 +192,13 @@ Input pattern: `/.dat`. | Option | Default | Description | | ---------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------- | | `-i`, `--input-path` | `.` | Directory containing the tracking `.dat` dumps. | -| `--file-prefix` | `track_ch` | `Tracking.dump_filename` value (may include a directory/extension). | +| `--conf` | unset | GNSS-SDR configuration used to infer tracking dump prefix, sampling frequency, and channel range. | +| `--file-prefix` | `--conf` or `track_ch` | `Tracking.dump_filename` value (may include a directory/extension). | | `-o`, `--fig-path` | `plots/kf-tracking` | Output directory for PNGs. | -| `--sampling-frequency` | `4000000.0` | Signal sampling frequency in Hz. | -| `--channels` | `5` | Number of channels to read. | -| `--first-channel` | `0` | First channel number in the dump filenames. | +| `--sampling-frequency` | `--conf` or `4000000.0` | Signal sampling frequency in Hz. | +| `--channels` | `--conf` or `5` | Number of channels to read. | +| `--first-channel` | `--conf` or `0` | First channel number in the dump filenames. | +| `--signal-type CODE` | inferred if needed | GNSS-SDR signal code used to select channel ranges and dump filenames from `--conf`. | | `--code-period` | `0.001` | Code period in seconds. | | `--show` | off | Display figures after saving. | | `--format` | `png` | Saved figure format: `png` (default), `pdf`, `eps`, `svg`, `jpg`. Vector formats embed fonts (publication-ready). | @@ -190,6 +211,7 @@ Examples: ```bash python3 gps_l1_ca_kf_plot_sample.py --input-path ./out python3 gps_l1_ca_kf_plot_sample.py --file-prefix track_ch --channels 8 +python3 gps_l1_ca_kf_plot_sample.py --conf conf/File_input/MultiCons/gnss-sdr_labsat_kf.conf --signal-type 1C --input-path ./out python3 gps_l1_ca_kf_plot_sample.py --sampling-frequency 4000000 --code-period 0.001 ``` @@ -203,22 +225,26 @@ Input pattern: `/.dat`. | Option | Default | Description | | -------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | | `-i`, `--input-path` | `.` | Directory containing the tracking `.dat` dumps. | -| `--file-prefix` | `track_ch` | `Tracking.dump_filename` value (may include a directory/extension). | +| `--conf` | unset | GNSS-SDR configuration used to infer tracking dump prefix, sampling frequency, signal type, and channel range. | +| `--file-prefix` | `--conf` or `track_ch` | `Tracking.dump_filename` value (may include a directory/extension). | | `-o`, `--fig-path` | `plots/tracking-quality` | Output directory for PNGs. | -| `--channels` | `5` | Number of channels to read. | -| `--first-channel` | `0` | First channel number in the dump filenames. | -| `--signal-type` | `1C` | GNSS-SDR signal code used as the C/N0 legend label (case-insensitive). The tracking dump stores only the PRN, so the signal is supplied here. | +| `--sampling-frequency` | `--conf` or `4000000.0` | Signal sampling frequency in Hz. | +| `--channels` | `--conf` or `5` | Number of channels to read. | +| `--first-channel` | `--conf` or `0` | First channel number in the dump filenames. | +| `--signal-type` | `--conf` or `1C` | GNSS-SDR signal code used to restrict channel ranges and label traces; omitted with multi-signal `--conf` means all configured signals. | | `--show` | off | Display figures after saving. | | `--format` | `png` | Saved figure format: `png` (default), `pdf`, `eps`, `svg`, `jpg`. Vector formats embed fonts (publication-ready). | The C/N0 legend labels each trace as ` PRN ` (e.g. -`1C PRN 9`); the carrier-lock legend labels traces as `SV `. +`1C PRN 9`); the carrier-lock legend labels traces as `SV PRN +`. Examples: ```bash python3 plot_tracking_quality_indicators.py --input-path ./out python3 plot_tracking_quality_indicators.py --file-prefix hackrf_l1_tracking_ch_ --channels 8 +python3 plot_tracking_quality_indicators.py --conf conf/RealTime_input/gnss-sdr_GPS_L1_USRP_realtime.conf --input-path ./out python3 plot_tracking_quality_indicators.py --file-prefix hackrf_l1_tracking_ch_ --channels 8 --signal-type 1C ``` @@ -232,12 +258,14 @@ Input pattern: `/.dat`. | Option | Default | Description | | -------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------- | | `-i`, `--input-path` | `.` | Directory containing the telemetry `.dat` dumps. | -| `--file-prefix` | `telemetry` | `TelemetryDecoder.dump_filename` value (may include a directory/extension). | +| `--conf` | unset | GNSS-SDR configuration used to infer telemetry dump prefix and channel range. | +| `--file-prefix` | `--conf` or `telemetry` | `TelemetryDecoder.dump_filename` value (may include a directory/extension). | | `-o`, `--fig-path` | `plots/telemetry` | Output directory for PNGs. | -| `--channels` | `18` | Number of channels to try reading; missing channels are skipped. | -| `--first-channel` | `0` | First channel number in the dump filenames. | -| `--channel-a` | `0` | First channel number to plot. | -| `--channel-b` | `5` | Second channel number to plot. | +| `--channels` | `--conf` or `18` | Number of channels to try reading; missing channels are skipped. | +| `--first-channel` | `--conf` or `0` | First channel number in the dump filenames. | +| `--signal-type CODE` | inferred if needed | GNSS-SDR signal code used to select channel ranges and dump filenames from `--conf`. | +| `--channel-a` | `--conf` or `0` | First channel number to plot. | +| `--channel-b` | `--conf` or `5` | Second channel number to plot. | | `--show` | off | Display figures after saving. | | `--format` | `png` | Saved figure format: `png` (default), `pdf`, `eps`, `svg`, `jpg`. Vector formats embed fonts (publication-ready). | @@ -245,6 +273,7 @@ Examples: ```bash python3 gps_l1_ca_telemetry_plot_sample.py --input-path ./out +python3 gps_l1_ca_telemetry_plot_sample.py --conf conf/gnss-sdr.conf --input-path ./out python3 gps_l1_ca_telemetry_plot_sample.py --file-prefix hackrf_l1_tlm_ch_ --channel-a 0 --channel-b 5 ``` @@ -258,9 +287,10 @@ Input pattern: `/.dat` (single file). | Option | Default | Description | | -------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------- | | `-i`, `--input-path` | `.` | Directory containing the observables dump. | -| `--file-prefix` | `observables.dat` | `Observables.dump_filename` value (may include a directory/extension). | +| `--conf` | unset | GNSS-SDR configuration used to infer observables dump filename and total channel count. | +| `--file-prefix` | `--conf` or `observables.dat` | `Observables.dump_filename` value (may include a directory/extension). | | `-o`, `--fig-path` | `plots/hybrid-observables` | Output directory for PNGs. | -| `--channels` | `5` | Number of observable channels in the dump. | +| `--channels` | `--conf` or `5` | Number of observable channels in the dump. | | `--show` | off | Display figures after saving. | | `--format` | `png` | Saved figure format: `png` (default), `pdf`, `eps`, `svg`, `jpg`. Vector formats embed fonts (publication-ready). | @@ -269,6 +299,7 @@ Examples: ```bash python3 hybrid_observables_plot_sample.py python3 hybrid_observables_plot_sample.py --input-path ./out --channels 8 +python3 hybrid_observables_plot_sample.py --conf conf/gnss-sdr.conf --input-path ./out python3 hybrid_observables_plot_sample.py --file-prefix hackrf_l1_observables.dat ``` @@ -283,9 +314,10 @@ Input pattern: `/.dat` (single file). | Option | Default | Description | | ----------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------- | | `-i`, `--input-path` | `.` | Directory containing the PVT dump. | -| `--file-prefix` | `PVT.dat` | `PVT.dump_filename` value (may include a directory/extension). | +| `--conf` | unset | GNSS-SDR configuration used to infer PVT dump filename and navigation solution period. | +| `--file-prefix` | `--conf` or `PVT.dat` | `PVT.dump_filename` value (may include a directory/extension). | | `-o`, `--fig-path` | `plots/pvt` | Output directory for PNGs and maps. | -| `--nav-sol-period` | `10.0` | Navigation solution period in milliseconds. | +| `--nav-sol-period` | `--conf` or `10.0` | Navigation solution period in milliseconds. | | `--true-position E_UTM N_UTM U_UTM` | unset (NaN) | Reference receiver position in UTM, used for error plots. | | `--plot-skyplot` | off | Try to generate the sky-plot panel. | | `--no-position` | position plot on | Skip the position/map diagnostic (avoids the `folium` dependency). | @@ -299,6 +331,7 @@ Examples: ```bash python3 gps_l1_ca_pvt_raw_plot_sample.py python3 gps_l1_ca_pvt_raw_plot_sample.py --input-path ./out --file-prefix PVT.dat +python3 gps_l1_ca_pvt_raw_plot_sample.py --conf conf/gnss-sdr.conf --input-path ./out python3 gps_l1_ca_pvt_raw_plot_sample.py --true-position 431234.0 4587654.0 120.0 python3 gps_l1_ca_pvt_raw_plot_sample.py --one-vs-time X_vel --one-vs-time Y_vel python3 gps_l1_ca_pvt_raw_plot_sample.py --no-position diff --git a/utils/python/dll_pll_veml_plot_sample.py b/utils/python/dll_pll_veml_plot_sample.py index 78a660f0d..3099e6732 100755 --- a/utils/python/dll_pll_veml_plot_sample.py +++ b/utils/python/dll_pll_veml_plot_sample.py @@ -27,14 +27,28 @@ import numpy as np from lib.dll_pll_veml_read_tracking_dump import dll_pll_veml_read_tracking_dump from lib.dump_filename import resolve_dump_prefix +from lib.gnss_sdr_conf import ( + ChannelDump, + ConfigError, + SIGNAL_TYPES, + add_conf_argument, + load_gnss_sdr_conf, +) from lib.plot_format import add_output_format_argument, apply_publication_style from lib.plotVEMLTracking import plotVEMLTracking +DEFAULT_FILE_PREFIX = "track_ch" +DEFAULT_SAMPLING_FREQUENCY = 3000000.0 +DEFAULT_CHANNELS = 5 +DEFAULT_FIRST_CHANNEL = 0 +DEFAULT_SIGNAL_TYPE = "1C" + def parse_args(): parser = argparse.ArgumentParser( description="Plot GNSS-SDR DLL/PLL VEML tracking dumps." ) + add_conf_argument(parser) parser.add_argument( "-i", "--input-path", @@ -51,28 +65,42 @@ def parse_args(): ) parser.add_argument( "--file-prefix", - default="track_ch", - help="GNSS-SDR Tracking.dump_filename value (default: track_ch). May " - "include a directory and extension; the matching .dat " - "files are read, resolved against --input-path.", + default=None, + help="GNSS-SDR Tracking.dump_filename value. May include a directory " + "and extension; the matching .dat files are read, " + "resolved against --input-path. Defaults to the configured Tracking " + "dump filename or filenames from --conf, or track_ch.", ) parser.add_argument( "--sampling-frequency", type=float, - default=3000000.0, - help="Signal sampling frequency in Hz.", + default=None, + help="Signal sampling frequency in Hz. Defaults to " + "GNSS-SDR.internal_fs_sps from --conf, or 3000000.0.", ) parser.add_argument( "--channels", type=int, - default=5, - help="Number of channels to read.", + default=None, + help="Number of channels to read. Defaults to all selected channels " + "from --conf, or 5.", ) parser.add_argument( "--first-channel", type=int, - default=0, - help="First channel number in the dump filenames.", + default=None, + help="First channel number in the dump filenames. Defaults to the " + "first selected absolute channel from --conf, or 0.", + ) + parser.add_argument( + "--signal-type", + type=str.upper, + choices=sorted(SIGNAL_TYPES), + default=None, + metavar="CODE", + help="GNSS-SDR signal code used to restrict channel ranges and dump " + "filenames from --conf (e.g. 1C, 5X, 7X, E6, J1). Omitted with a " + "multi-signal --conf means all configured signals.", ) parser.add_argument( "--plot-last-outputs", @@ -93,15 +121,98 @@ def parse_args(): ) add_output_format_argument(parser) parser.set_defaults(plot_doppler=True) - return parser.parse_args() + args = parser.parse_args() + try: + apply_conf_defaults(args) + except ConfigError as exc: + parser.error(str(exc)) + return args + + +def apply_conf_defaults(args): + conf = load_gnss_sdr_conf(args.conf) if args.conf else None + signals = [] + explicit_range = args.channels is not None or args.first_channel is not None + + if conf is not None: + signals = conf.select_signals(args.signal_type) + if args.signal_type is not None: + args.signal_type = signals[0].signal + + if args.file_prefix is None: + if conf is None or (len(signals) == 1 and explicit_range): + signal = signals[0] if signals else None + args.file_prefix = ( + signal.tracking_dump_filename + if signal is not None and signal.tracking_dump_filename + else DEFAULT_FILE_PREFIX + ) + if args.sampling_frequency is None: + if conf is not None: + if conf.internal_fs_sps is None: + raise ConfigError( + "GNSS-SDR.internal_fs_sps is required to infer " + "--sampling-frequency." + ) + args.sampling_frequency = conf.internal_fs_sps + else: + args.sampling_frequency = DEFAULT_SAMPLING_FREQUENCY + + if conf is not None and not explicit_range: + args.channel_plan = conf.channel_dump_plan( + signals=signals, + dump_filename_attr="tracking_dump_filename", + default_file_prefix=DEFAULT_FILE_PREFIX, + override_file_prefix=args.file_prefix, + ) + args.channels = len(args.channel_plan) + args.first_channel = args.channel_plan[0].channel + args.channel_ids = [entry.channel for entry in args.channel_plan] + if len(signals) == 1: + args.file_prefix = args.channel_plan[0].file_prefix + return + + if args.channels is None: + args.channels = signals[0].count if signals else DEFAULT_CHANNELS + + if args.first_channel is None: + args.first_channel = ( + signals[0].first_channel if signals else DEFAULT_FIRST_CHANNEL + ) + + if args.file_prefix is None: + args.file_prefix = DEFAULT_FILE_PREFIX + + manual_signal = args.signal_type or ( + signals[0].signal if signals else DEFAULT_SIGNAL_TYPE + ) + args.channel_plan = [ + ChannelDump( + channel=channel, + signal=manual_signal, + file_prefix=args.file_prefix, + ) + for channel in range(args.first_channel, args.first_channel + args.channels) + ] + args.channel_ids = [entry.channel for entry in args.channel_plan] def read_tracking_dumps(args): - directory, base = resolve_dump_prefix(args.file_prefix, args.input_path) dumps = [] - for channel in range(args.first_channel, args.first_channel + args.channels): + for entry in args.channel_plan: + directory, base = resolve_dump_prefix(entry.file_prefix, args.input_path) + channel = entry.channel tracking_log_path = directory / f"{base}{channel}.dat" - dumps.append(dll_pll_veml_read_tracking_dump(tracking_log_path)) + if not tracking_log_path.exists(): + print(f"Skipping channel {channel}: missing {tracking_log_path}") + continue + tracking = dll_pll_veml_read_tracking_dump(tracking_log_path) + if not tracking["PRN"]: + print(f"Skipping channel {channel}: no samples in {tracking_log_path}") + continue + tracking["_channel"] = channel + tracking["_signal_type"] = entry.signal + dumps.append(tracking) return dumps @@ -112,9 +223,15 @@ def main(): apply_publication_style() gnss_tracking = read_tracking_dumps(args) + if not gnss_tracking: + raise SystemExit("Error: no channel dumps found to plot.") + + plotted_channel_ids = [tracking["_channel"] for tracking in gnss_tracking] track_results = [] settings = { - "numberOfChannels": args.channels, + "numberOfChannels": len(gnss_tracking), + "firstChannel": plotted_channel_ids[0], + "channelIds": plotted_channel_ids, "fig_path": args.fig_path, "show": args.show, "output_format": args.output_format, @@ -158,7 +275,7 @@ def main(): plotVEMLTracking(index, track_results, settings) if args.plot_doppler: - channel = args.first_channel + index - 1 + channel = tracking["_channel"] plt.figure() plt.plot( track_result["prn_start_time_s"], diff --git a/utils/python/gps_l1_ca_kf_plot_sample.py b/utils/python/gps_l1_ca_kf_plot_sample.py index e755e4e55..a5619430e 100755 --- a/utils/python/gps_l1_ca_kf_plot_sample.py +++ b/utils/python/gps_l1_ca_kf_plot_sample.py @@ -21,20 +21,34 @@ import argparse from pathlib import Path +import sys import numpy as np from lib.dump_filename import resolve_dump_prefix +from lib.gnss_sdr_conf import ( + ConfigError, + SIGNAL_TYPES, + add_conf_argument, + load_gnss_sdr_conf, +) from lib.gps_l1_ca_kf_read_tracking_dump import gps_l1_ca_kf_read_tracking_dump from lib.plot_format import add_output_format_argument, apply_publication_style from lib.plotKalman import plotKalman from lib.plotTracking import plotTracking +DEFAULT_FILE_PREFIX = "track_ch" +DEFAULT_SAMPLING_FREQUENCY = 4000000.0 +DEFAULT_CHANNELS = 5 +DEFAULT_FIRST_CHANNEL = 0 +DEFAULT_CODE_PERIOD = 0.001 + def parse_args(): parser = argparse.ArgumentParser( description="Plot GPS L1 C/A Kalman-filter tracking dumps." ) + add_conf_argument(parser) parser.add_argument( "-i", "--input-path", @@ -51,33 +65,46 @@ def parse_args(): ) parser.add_argument( "--file-prefix", - default="track_ch", - help="GNSS-SDR Tracking.dump_filename value (default: track_ch). May " - "include a directory and extension; the matching .dat " - "files are read, resolved against --input-path.", + default=None, + help="GNSS-SDR Tracking.dump_filename value. May include a directory " + "and extension; the matching .dat files are read, " + "resolved against --input-path. Defaults to the selected Tracking " + "dump filename from --conf, or track_ch.", ) parser.add_argument( "--sampling-frequency", type=float, - default=4000000.0, - help="Signal sampling frequency in Hz.", + default=None, + help="Signal sampling frequency in Hz. Defaults to " + "GNSS-SDR.internal_fs_sps from --conf, or 4000000.0.", ) parser.add_argument( "--channels", type=int, - default=5, - help="Number of channels to read.", + default=None, + help="Number of channels to read. Defaults to the selected signal's " + "channel count from --conf, or 5.", ) parser.add_argument( "--first-channel", type=int, - default=0, - help="First channel number in the dump filenames.", + default=None, + help="First channel number in the dump filenames. Defaults to the " + "selected signal's first absolute channel from --conf, or 0.", + ) + parser.add_argument( + "--signal-type", + type=str.upper, + choices=sorted(SIGNAL_TYPES), + default=None, + metavar="CODE", + help="GNSS-SDR signal code used to select channel ranges and dump " + "filenames from --conf (default: infer from --conf, otherwise 1C).", ) parser.add_argument( "--code-period", type=float, - default=0.001, + default=None, help="Code period in seconds.", ) parser.add_argument( @@ -86,7 +113,69 @@ def parse_args(): help="Display figures interactively after saving them.", ) add_output_format_argument(parser) - return parser.parse_args() + args = parser.parse_args() + try: + apply_conf_defaults(args) + except ConfigError as exc: + parser.error(str(exc)) + return args + + +def apply_conf_defaults(args): + conf = load_gnss_sdr_conf(args.conf) if args.conf else None + signal = None + if conf is not None and ( + args.signal_type is not None + or args.file_prefix is None + or args.channels is None + or args.first_channel is None + ): + signal = conf.select_signal( + args.signal_type, + default_signal="1C", + prefer_default_on_ambiguous=True, + ) + args.signal_type = signal.signal + + if ( + signal.tracking_implementation + and "KF" not in signal.tracking_implementation.upper() + ): + print( + "Warning: selected tracking implementation " + f"{signal.tracking_implementation!r} does not look like a " + "Kalman-filter tracker; the dump layout may be incompatible.", + file=sys.stderr, + ) + + if args.file_prefix is None: + args.file_prefix = ( + signal.tracking_dump_filename + if signal is not None and signal.tracking_dump_filename + else DEFAULT_FILE_PREFIX + ) + + if args.sampling_frequency is None: + if conf is not None: + if conf.internal_fs_sps is None: + raise ConfigError( + "GNSS-SDR.internal_fs_sps is required to infer " + "--sampling-frequency." + ) + args.sampling_frequency = conf.internal_fs_sps + else: + args.sampling_frequency = DEFAULT_SAMPLING_FREQUENCY + + if args.channels is None: + args.channels = signal.count if signal is not None else DEFAULT_CHANNELS + + if args.first_channel is None: + args.first_channel = ( + signal.first_channel if signal is not None else DEFAULT_FIRST_CHANNEL + ) + + if args.code_period is None: + args.code_period = DEFAULT_CODE_PERIOD def read_tracking_dumps(args): @@ -145,6 +234,7 @@ def main(): settings = { "numberOfChannels": args.channels, + "firstChannel": args.first_channel, "msToProcess": len(tracking["E"]), "codePeriod": args.code_period, "timeStartInSeconds": 0, diff --git a/utils/python/gps_l1_ca_pvt_raw_plot_sample.py b/utils/python/gps_l1_ca_pvt_raw_plot_sample.py index 0158f3ba7..2efe9f11f 100755 --- a/utils/python/gps_l1_ca_pvt_raw_plot_sample.py +++ b/utils/python/gps_l1_ca_pvt_raw_plot_sample.py @@ -21,13 +21,22 @@ from pathlib import Path import numpy as np from lib.dump_filename import resolve_dump_prefix +from lib.gnss_sdr_conf import ( + ConfigError, + add_conf_argument, + load_gnss_sdr_conf, +) from lib.plot_format import add_output_format_argument, apply_publication_style +DEFAULT_FILE_PREFIX = "PVT.dat" +DEFAULT_NAV_SOL_PERIOD = 10.0 + def parse_args(): parser = argparse.ArgumentParser( description="Plot GNSS-SDR GPS L1 C/A PVT raw dump data." ) + add_conf_argument(parser) parser.add_argument( "-i", "--input-path", @@ -37,10 +46,10 @@ def parse_args(): ) parser.add_argument( "--file-prefix", - default="PVT.dat", - help="GNSS-SDR PVT.dump_filename value (default: PVT.dat). May include " - "a directory and extension; the matching .dat file is read, " - "resolved against --input-path.", + default=None, + help="GNSS-SDR PVT.dump_filename value. May include a directory and " + "extension; the matching .dat file is read, resolved against " + "--input-path. Defaults to PVT.dump_filename from --conf, or PVT.dat.", ) parser.add_argument( "-o", @@ -52,8 +61,9 @@ def parse_args(): parser.add_argument( "--nav-sol-period", type=float, - default=10.0, - help="Navigation solution period in milliseconds.", + default=None, + help="Navigation solution period in milliseconds. Defaults to " + "PVT.output_rate_ms from --conf, or 10.0.", ) parser.add_argument( "--true-position", @@ -94,7 +104,30 @@ def parse_args(): ) add_output_format_argument(parser) parser.set_defaults(plot_position=True) - return parser.parse_args() + args = parser.parse_args() + try: + apply_conf_defaults(args) + except ConfigError as exc: + parser.error(str(exc)) + return args + + +def apply_conf_defaults(args): + conf = load_gnss_sdr_conf(args.conf) if args.conf else None + + if args.file_prefix is None: + args.file_prefix = ( + conf.pvt_dump_filename + if conf is not None and conf.pvt_dump_filename + else DEFAULT_FILE_PREFIX + ) + + if args.nav_sol_period is None: + args.nav_sol_period = ( + conf.pvt_output_rate_ms + if conf is not None and conf.pvt_output_rate_ms is not None + else DEFAULT_NAV_SOL_PERIOD + ) def add_utm_coordinates(nav_solutions): diff --git a/utils/python/gps_l1_ca_telemetry_plot_sample.py b/utils/python/gps_l1_ca_telemetry_plot_sample.py index 26aaefe7f..7f252c611 100755 --- a/utils/python/gps_l1_ca_telemetry_plot_sample.py +++ b/utils/python/gps_l1_ca_telemetry_plot_sample.py @@ -25,14 +25,27 @@ from pathlib import Path import matplotlib.pyplot as plt from lib.dump_filename import resolve_dump_prefix +from lib.gnss_sdr_conf import ( + ConfigError, + SIGNAL_TYPES, + add_conf_argument, + load_gnss_sdr_conf, +) from lib.gps_l1_ca_read_telemetry_dump import gps_l1_ca_read_telemetry_dump from lib.plot_format import add_output_format_argument, apply_publication_style +DEFAULT_FILE_PREFIX = "telemetry" +DEFAULT_CHANNELS = 18 +DEFAULT_FIRST_CHANNEL = 0 +DEFAULT_CHANNEL_A = 0 +DEFAULT_CHANNEL_B = 5 + def parse_args(): parser = argparse.ArgumentParser( description="Plot GNSS-SDR GPS L1 C/A telemetry dumps." ) + add_conf_argument(parser) parser.add_argument( "-i", "--input-path", @@ -49,34 +62,49 @@ def parse_args(): ) parser.add_argument( "--file-prefix", - default="telemetry", - help="GNSS-SDR TelemetryDecoder.dump_filename value (default: " - "telemetry). May include a directory and extension; the matching " - ".dat files are read, resolved against --input-path.", + default=None, + help="GNSS-SDR TelemetryDecoder.dump_filename value. May include a " + "directory and extension; the matching .dat files " + "are read, resolved against --input-path. Defaults to the selected " + "TelemetryDecoder dump filename from --conf, or telemetry.", ) parser.add_argument( "--channels", type=int, - default=18, - help="Number of channels to try reading.", + default=None, + help="Number of channels to try reading. Defaults to the selected " + "signal's channel count from --conf, or 18.", ) parser.add_argument( "--first-channel", type=int, - default=0, - help="First channel number in the dump filenames.", + default=None, + help="First channel number in the dump filenames. Defaults to the " + "selected signal's first absolute channel from --conf, or 0.", + ) + parser.add_argument( + "--signal-type", + type=str.upper, + choices=sorted(SIGNAL_TYPES), + default=None, + metavar="CODE", + help="GNSS-SDR signal code used to select channel ranges and dump " + "filenames from --conf (default: infer from --conf, preferring 1C " + "when available).", ) parser.add_argument( "--channel-a", type=int, - default=0, - help="First channel number to plot.", + default=None, + help="First channel number to plot. Defaults to the first selected " + "channel from --conf, or 0.", ) parser.add_argument( "--channel-b", type=int, - default=5, - help="Second channel number to plot.", + default=None, + help="Second channel number to plot. Defaults to the second selected " + "channel from --conf, or 5.", ) parser.add_argument( "--show", @@ -84,7 +112,59 @@ def parse_args(): help="Display figures interactively after saving them.", ) add_output_format_argument(parser) - return parser.parse_args() + args = parser.parse_args() + try: + apply_conf_defaults(args) + except ConfigError as exc: + parser.error(str(exc)) + return args + + +def apply_conf_defaults(args): + conf = load_gnss_sdr_conf(args.conf) if args.conf else None + signal = None + if conf is not None and ( + args.signal_type is not None + or args.file_prefix is None + or args.channels is None + or args.first_channel is None + or args.channel_a is None + or args.channel_b is None + ): + signal = conf.select_signal( + args.signal_type, + default_signal="1C", + prefer_default_on_ambiguous=True, + ) + args.signal_type = signal.signal + + if args.file_prefix is None: + args.file_prefix = ( + signal.telemetry_dump_filename + if signal is not None and signal.telemetry_dump_filename + else DEFAULT_FILE_PREFIX + ) + + if args.channels is None: + args.channels = signal.count if signal is not None else DEFAULT_CHANNELS + + if args.first_channel is None: + args.first_channel = ( + signal.first_channel if signal is not None else DEFAULT_FIRST_CHANNEL + ) + + if args.channel_a is None: + args.channel_a = ( + signal.first_channel if signal is not None else DEFAULT_CHANNEL_A + ) + + if args.channel_b is None: + if signal is not None and signal.count > 1: + args.channel_b = signal.first_channel + 1 + elif signal is not None: + args.channel_b = signal.first_channel + else: + args.channel_b = DEFAULT_CHANNEL_B def read_telemetry_dumps(args): diff --git a/utils/python/hybrid_observables_plot_sample.py b/utils/python/hybrid_observables_plot_sample.py index cb2a16f59..a2c617233 100755 --- a/utils/python/hybrid_observables_plot_sample.py +++ b/utils/python/hybrid_observables_plot_sample.py @@ -23,14 +23,23 @@ import matplotlib.pyplot as plt import numpy as np from lib.dump_filename import resolve_dump_prefix +from lib.gnss_sdr_conf import ( + ConfigError, + add_conf_argument, + load_gnss_sdr_conf, +) from lib.plot_format import add_output_format_argument, apply_publication_style from lib.read_hybrid_observables_dump import read_hybrid_observables_dump +DEFAULT_FILE_PREFIX = "observables.dat" +DEFAULT_CHANNELS = 5 + def parse_args(): parser = argparse.ArgumentParser( description="Plot GNSS-SDR hybrid observables dump data." ) + add_conf_argument(parser) parser.add_argument( "-i", "--input-path", @@ -40,10 +49,11 @@ def parse_args(): ) parser.add_argument( "--file-prefix", - default="observables.dat", - help="GNSS-SDR Observables.dump_filename value (default: " - "observables.dat). May include a directory and extension; the " - "matching .dat file is read, resolved against --input-path.", + default=None, + help="GNSS-SDR Observables.dump_filename value. May include a " + "directory and extension; the matching .dat file is read, " + "resolved against --input-path. Defaults to Observables.dump_filename " + "from --conf, or observables.dat.", ) parser.add_argument( "-o", @@ -55,8 +65,9 @@ def parse_args(): parser.add_argument( "--channels", type=int, - default=5, - help="Number of observable channels in the dump.", + default=None, + help="Number of observable channels in the dump. Defaults to the " + "total configured channel count from --conf, or 5.", ) parser.add_argument( "--show", @@ -64,7 +75,34 @@ def parse_args(): help="Display figures interactively after saving them.", ) add_output_format_argument(parser) - return parser.parse_args() + args = parser.parse_args() + try: + apply_conf_defaults(args) + except ConfigError as exc: + parser.error(str(exc)) + return args + + +def apply_conf_defaults(args): + conf = load_gnss_sdr_conf(args.conf) if args.conf else None + + if args.file_prefix is None: + args.file_prefix = ( + conf.observables_dump_filename + if conf is not None and conf.observables_dump_filename + else DEFAULT_FILE_PREFIX + ) + + if args.channels is None: + if conf is not None: + if conf.total_channels <= 0: + raise ConfigError( + "At least one Channels_.count value is required " + "to infer --channels." + ) + args.channels = conf.total_channels + else: + args.channels = DEFAULT_CHANNELS def first_valid_observable(gnss_observables, channels): diff --git a/utils/python/lib/gnss_sdr_conf.py b/utils/python/lib/gnss_sdr_conf.py new file mode 100644 index 000000000..1b1c1bc28 --- /dev/null +++ b/utils/python/lib/gnss_sdr_conf.py @@ -0,0 +1,406 @@ +""" + gnss_sdr_conf.py + + Helpers to read GNSS-SDR configuration files from the Python plotting + utilities. + + ----------------------------------------------------------------------------- + + GNSS-SDR is a Global Navigation Satellite System software-defined receiver. + This file is part of GNSS-SDR. + + Copyright (C) 2026 (see AUTHORS file for a list of contributors) + SPDX-License-Identifier: GPL-3.0-or-later + + ----------------------------------------------------------------------------- +""" + +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + + +# Keep this order in sync with src/core/receiver/gnss_block_factory.cc. GNSS-SDR +# assigns absolute channel IDs by walking the configured signal counts in this +# order. +SIGNAL_ORDER = ( + "1C", + "2S", + "L5", + "1B", + "5X", + "E6", + "1G", + "2G", + "B1", + "B3", + "7X", + "J1", + "J5", +) + +# GNSS-SDR signal nomenclature: each signal code maps to the system character +# used in acquisition dump filenames and to its primary code length in chips. +SIGNAL_TYPES = { + "1C": ("G", 1023), # GPS L1 C/A + "2S": ("G", 10230), # GPS L2C + "L5": ("G", 10230), # GPS L5 + "1B": ("E", 4092), # Galileo E1B + "5X": ("E", 10230), # Galileo E5a + "7X": ("E", 10230), # Galileo E5b + "E6": ("E", 5115), # Galileo E6 + "1G": ("R", 511), # GLONASS L1 + "2G": ("R", 511), # GLONASS L2 + "B1": ("C", 2046), # BeiDou B1I + "B3": ("C", 10230), # BeiDou B3I + "J1": ("J", 1023), # QZSS L1 C/A + "J5": ("J", 10230), # QZSS L5 +} + +N_CHIPS = {(system, code): nc for code, (system, nc) in SIGNAL_TYPES.items()} + + +class ConfigError(ValueError): + """Raised when a configuration file cannot provide required metadata.""" + + +@dataclass(frozen=True) +class SignalConfig: + signal: str + system: str + n_chips: int + count: int + first_channel: int + acquisition_dump_filename: Optional[str] + tracking_dump_filename: Optional[str] + telemetry_dump_filename: Optional[str] + acquisition_implementation: Optional[str] + tracking_implementation: Optional[str] + telemetry_implementation: Optional[str] + + @property + def last_channel(self): + if self.count <= 0: + return None + return self.first_channel + self.count - 1 + + @property + def channels(self): + return range(self.first_channel, self.first_channel + self.count) + + def range_label(self): + if self.count == 0: + return "none" + if self.count == 1: + return str(self.first_channel) + return f"{self.first_channel}..{self.last_channel}" + + +@dataclass(frozen=True) +class ChannelDump: + channel: int + signal: str + file_prefix: str + + +def add_conf_argument(parser): + parser.add_argument( + "--conf", + type=Path, + default=None, + help="GNSS-SDR configuration file. When provided, missing plotting " + "options such as dump filename, channel range, signal type, sampling " + "frequency, and PVT period are inferred from it.", + ) + + +def load_gnss_sdr_conf(path): + return GnssSdrConfig(Path(path)) + + +class GnssSdrConfig: + def __init__(self, path): + self.path = Path(path) + self.values = _parse_config_file(self.path) + self._signals = None + + def get_str(self, key, default=None): + return self.values.get(_normalize_key(key), default) + + def get_bool(self, key, default=None): + value = self.get_str(key) + if value is None: + return default + normalized = value.strip().lower() + if normalized in ("true", "yes", "on", "1"): + return True + if normalized in ("false", "no", "off", "0"): + return False + return default + + def get_int(self, key, default=None): + value = self.get_str(key) + if value is None: + return default + try: + return int(value, 0) + except ValueError: + return default + + def get_float(self, key, default=None): + value = self.get_str(key) + if value is None: + return default + try: + return float(value) + except ValueError: + return default + + @property + def internal_fs_sps(self): + return self.get_float( + "GNSS-SDR.internal_fs_sps", + self.get_float("GNSS-SDR.internal_fs_hz"), + ) + + @property + def pvt_output_rate_ms(self): + return self.get_float("PVT.output_rate_ms") + + @property + def observables_dump_filename(self): + return self.get_str("Observables.dump_filename") + + @property + def pvt_dump_filename(self): + return self.get_str("PVT.dump_filename") + + @property + def signals(self): + if self._signals is None: + self._signals = self._build_signals() + return self._signals + + @property + def enabled_signals(self): + return [signal for signal in self.signals if signal.count > 0] + + @property + def total_channels(self): + return sum(signal.count for signal in self.signals) + + def signal(self, signal_type): + requested = signal_type.upper() + for signal in self.signals: + if signal.signal == requested: + return signal + return None + + def require_enabled_signal(self, signal_type): + signal = self.signal(signal_type) + if signal is None: + choices = ", ".join(SIGNAL_ORDER) + raise ConfigError( + f"Unsupported signal type {signal_type!r}. Choices: {choices}." + ) + if signal.count <= 0: + raise ConfigError( + f"Configuration {self.path} enables no {signal.signal} " + "channels." + ) + return signal + + def select_signal( + self, + requested_signal=None, + *, + default_signal=None, + prefer_default_on_ambiguous=False, + ): + if requested_signal is not None: + return self.require_enabled_signal(requested_signal) + + enabled = self.enabled_signals + if len(enabled) == 1: + return enabled[0] + + if ( + default_signal is not None + and prefer_default_on_ambiguous + and self.signal(default_signal) is not None + and self.signal(default_signal).count > 0 + ): + return self.signal(default_signal) + + if not enabled: + raise ConfigError( + f"Configuration {self.path} enables no GNSS-SDR channels." + ) + + available = ", ".join( + f"{signal.signal} (channels {signal.range_label()})" + for signal in enabled + ) + raise ConfigError( + f"Configuration {self.path} enables multiple signals: {available}. " + "Pass --signal-type to select one." + ) + + def select_signals(self, requested_signal=None): + if requested_signal is not None: + return [self.require_enabled_signal(requested_signal)] + + enabled = self.enabled_signals + if not enabled: + raise ConfigError( + f"Configuration {self.path} enables no GNSS-SDR channels." + ) + return enabled + + def channel_dump_plan( + self, + *, + signals, + dump_filename_attr, + default_file_prefix, + override_file_prefix=None, + ): + plan = [] + for signal in signals: + file_prefix = ( + override_file_prefix + or getattr(signal, dump_filename_attr) + or default_file_prefix + ) + for channel in signal.channels: + plan.append(ChannelDump(channel, signal.signal, file_prefix)) + return plan + + def summary_lines(self): + lines = [f"Configuration: {self.path}"] + if self.internal_fs_sps is not None: + lines.append(f"internal_fs_sps: {self.internal_fs_sps:g}") + lines.append("signals:") + for signal in self.enabled_signals: + parts = [ + f" {signal.signal}: channels {signal.range_label()}", + ] + if signal.tracking_dump_filename: + parts.append(f"tracking dump {signal.tracking_dump_filename}") + if signal.acquisition_dump_filename: + parts.append( + f"acquisition dump {signal.acquisition_dump_filename}" + ) + lines.append(", ".join(parts)) + if self.observables_dump_filename: + lines.append(f"observables dump: {self.observables_dump_filename}") + if self.pvt_dump_filename: + lines.append(f"PVT dump: {self.pvt_dump_filename}") + return lines + + def _build_signals(self): + signals = [] + first_channel = 0 + for signal_type in SIGNAL_ORDER: + system, n_chips = SIGNAL_TYPES[signal_type] + count = self.get_int(f"Channels_{signal_type}.count", 0) + if count is None: + count = 0 + if count < 0: + raise ConfigError( + f"Configuration {self.path} has a negative channel count " + f"for Channels_{signal_type}.count." + ) + signals.append( + SignalConfig( + signal=signal_type, + system=system, + n_chips=n_chips, + count=count, + first_channel=first_channel, + acquisition_dump_filename=self._block_value( + "Acquisition", signal_type, "dump_filename" + ), + tracking_dump_filename=self._block_value( + "Tracking", signal_type, "dump_filename" + ), + telemetry_dump_filename=self._block_value( + "TelemetryDecoder", signal_type, "dump_filename" + ), + acquisition_implementation=self._block_value( + "Acquisition", signal_type, "implementation" + ), + tracking_implementation=self._block_value( + "Tracking", signal_type, "implementation" + ), + telemetry_implementation=self._block_value( + "TelemetryDecoder", signal_type, "implementation" + ), + ) + ) + first_channel += count + return signals + + def _block_value(self, role, signal_type, option): + return self.get_str( + f"{role}_{signal_type}.{option}", + self.get_str(f"{role}.{option}"), + ) + + +def _parse_config_file(path): + if not path.exists(): + raise ConfigError(f"Configuration file not found: {path}") + + flat_values = {} + gnss_values = {} + has_gnss_section = False + active_section = None + + try: + lines = path.read_text(encoding="utf-8").splitlines() + except UnicodeDecodeError: + lines = path.read_text(encoding="latin-1").splitlines() + + for line_no, raw_line in enumerate(lines, start=1): + if raw_line.lstrip().startswith("#"): + continue + line = _strip_inline_comment(raw_line).strip() + if not line: + continue + if line.startswith("#"): + continue + + if line.startswith("["): + if not line.endswith("]"): + raise ConfigError( + f"{path}:{line_no}: malformed section header." + ) + active_section = line[1:-1].strip().lower() + if active_section == "gnss-sdr": + has_gnss_section = True + continue + + if "=" not in line: + raise ConfigError(f"{path}:{line_no}: expected key=value.") + + key, value = line.split("=", 1) + key = _normalize_key(key) + value = value.strip() + if active_section is None: + flat_values[key] = value + elif active_section == "gnss-sdr": + gnss_values[key] = value + + values = gnss_values if has_gnss_section else flat_values + if not values: + raise ConfigError(f"No GNSS-SDR configuration values found in {path}.") + return values + + +def _normalize_key(key): + return key.strip().lower() + + +def _strip_inline_comment(line): + return line.split(";", 1)[0] diff --git a/utils/python/lib/plotKalman.py b/utils/python/lib/plotKalman.py index a2a482d85..bc042ec6f 100644 --- a/utils/python/lib/plotKalman.py +++ b/utils/python/lib/plotKalman.py @@ -43,6 +43,11 @@ def plotKalman(channelNr, trackResults, settings): np.arange(1, settings['numberOfChannels'] + 1)) for channelNr in channelNr: + channel_ids = settings.get('channelIds') + if channel_ids is not None: + display_channel = channel_ids[channelNr - 1] + else: + display_channel = settings.get('firstChannel', 1) + channelNr - 1 time_start = settings['timeStartInSeconds'] time_axis_in_seconds = np.arange(1, settings['msToProcess']+1)/1000 @@ -50,7 +55,7 @@ def plotKalman(channelNr, trackResults, settings): plt.figure(figsize=(1920 / 100, 1080 / 100)) plt.clf() plt.gcf().canvas.manager.set_window_title( - f'Channel {channelNr} (PRN ' + f'Channel {display_channel} (PRN ' f'{str(trackResults[channelNr-1]["PRN"][-2])}) results') plt.subplots_adjust(left=0.1, right=0.9, top=0.9, bottom=0.1, hspace=0.4, wspace=0.4) @@ -137,7 +142,7 @@ def plotKalman(channelNr, trackResults, settings): plt.tight_layout() plt.savefig(os.path.join(fig_path, - f'kalman_ch{channelNr}_PRN_' + f'kalman_ch{display_channel}_PRN_' f'{trackResults[channelNr - 1]["PRN"][-1]}' f'.{output_format}')) # Close unless it will be shown; the caller triggers a single diff --git a/utils/python/lib/plotTracking.py b/utils/python/lib/plotTracking.py index 29966c4a8..015cf5313 100644 --- a/utils/python/lib/plotTracking.py +++ b/utils/python/lib/plotTracking.py @@ -40,11 +40,16 @@ def plotTracking(channelNr, trackResults, settings): # Protection - if the list contains incorrect channel numbers if channelNr in list(range(1,settings["numberOfChannels"]+1)): + channel_ids = settings.get('channelIds') + if channel_ids is not None: + display_channel = channel_ids[channelNr - 1] + else: + display_channel = settings.get('firstChannel', 1) + channelNr - 1 plt.figure(figsize=(1920 / 120, 1080 / 120)) plt.clf() plt.gcf().canvas.manager.set_window_title( - f'Channel {channelNr} (PRN ' + f'Channel {display_channel} (PRN ' f'{trackResults[channelNr-1]["PRN"][0]}) results') plt.subplots_adjust(left=0.1, right=0.9, top=0.9, bottom=0.1, hspace=0.4, wspace=0.4) @@ -184,7 +189,7 @@ def plotTracking(channelNr, trackResults, settings): plt.tight_layout() plt.savefig(os.path.join(fig_path, - f'trk_dump_ch{channelNr}_PRN_' + f'trk_dump_ch{display_channel}_PRN_' f'{trackResults[channelNr - 1]["PRN"][-1]}' f'.{output_format}')) # Close unless it will be shown; the caller triggers a single diff --git a/utils/python/lib/plotVEMLTracking.py b/utils/python/lib/plotVEMLTracking.py index 01a4aba28..a4f4ffb47 100644 --- a/utils/python/lib/plotVEMLTracking.py +++ b/utils/python/lib/plotVEMLTracking.py @@ -38,13 +38,18 @@ def plotVEMLTracking(channelNr, trackResults, settings): if not os.path.exists(fig_path): os.makedirs(fig_path) + channel_ids = settings.get('channelIds') + if channel_ids is not None: + display_channel = channel_ids[channelNr - 1] + else: + display_channel = settings.get('firstChannel', 1) + channelNr - 1 + # Protection - if the list contains incorrect channel numbers if channelNr in list(range(1,settings["numberOfChannels"]+1)): - plt.figure(figsize=(1920 / 120, 1080 / 120)) plt.clf() plt.gcf().canvas.manager.set_window_title( - f'Channel {channelNr} (PRN ' + f'Channel {display_channel} (PRN ' f'{trackResults[channelNr-1]["PRN"][0]}) results') plt.subplots_adjust(left=0.1, right=0.9, top=0.9, bottom=0.1, hspace=0.4, wspace=0.4) @@ -165,7 +170,7 @@ def plotVEMLTracking(channelNr, trackResults, settings): plt.title('Filtered DLL discriminator',fontweight='bold') plt.savefig(os.path.join(fig_path, - f'Ch{channelNr}_PRN' + f'Ch{display_channel}_PRN' f'{trackResults[channelNr-1]["PRN"][0]}' f'_results.{output_format}')) # Close the figure unless it will be shown; the caller triggers a single diff --git a/utils/python/plot_acq_grid.py b/utils/python/plot_acq_grid.py index 165cf9513..ee1bff8b6 100755 --- a/utils/python/plot_acq_grid.py +++ b/utils/python/plot_acq_grid.py @@ -22,9 +22,18 @@ """ import argparse +from dataclasses import dataclass from pathlib import Path +from typing import Optional from lib.dump_filename import resolve_dump_prefix +from lib.gnss_sdr_conf import ( + ConfigError, + N_CHIPS, + SIGNAL_TYPES, + add_conf_argument, + load_gnss_sdr_conf, +) from lib.plot_format import add_output_format_argument, apply_publication_style # Heavy numerical and plotting libraries (h5py, numpy, matplotlib, scipy) are @@ -32,36 +41,25 @@ from lib.plot_format import add_output_format_argument, apply_publication_style # argument-error invocations fast and free of matplotlib font-cache warnings. -# GNSS-SDR signal nomenclature: each two-character signal code maps to its -# system character (as written in the dump filename) and primary code length -# in chips. The signal code matches the value used in GNSS-SDR configuration -# (e.g. Galileo.implementation signals) and in the dump filename. -SIGNAL_TYPES = { - "1C": ("G", 1023), # GPS L1 C/A - "2S": ("G", 10230), # GPS L2C - "L5": ("G", 10230), # GPS L5 - "1B": ("E", 4092), # Galileo E1B - "5X": ("E", 10230), # Galileo E5a - "7X": ("E", 10230), # Galileo E5b - "E6": ("E", 5115), # Galileo E6 - "1G": ("R", 511), # GLONASS L1 - "2G": ("R", 511), # GLONASS L2 - "B1": ("C", 2046), # BeiDou B1I - "B3": ("C", 10230), # BeiDou B3I - "J1": ("J", 1023), # QZSS L1 C/A - "J5": ("J", 10230), # QZSS L5 -} -N_CHIPS = {(system, code): nc for code, (system, nc) in SIGNAL_TYPES.items()} +DEFAULT_FILE_PREFIX = "acquisition" DEFAULT_SAT = 1 DEFAULT_CHANNEL = 0 DEFAULT_EXECUTION = 1 DEFAULT_SIGNAL_TYPE = "1C" +@dataclass(frozen=True) +class AcquisitionDumpPlan: + signal: Optional[str] + system: Optional[str] + file_prefix: str + + def parse_args(): parser = argparse.ArgumentParser( description="Plot GNSS-SDR acquisition dump grids." ) + add_conf_argument(parser) parser.add_argument( "-i", "--input-path", @@ -78,11 +76,12 @@ def parse_args(): ) parser.add_argument( "--file-prefix", - default="acquisition", - help="GNSS-SDR Acquisition.dump_filename value (default: " - "acquisition). May include a directory and extension; matching " + default=None, + help="GNSS-SDR Acquisition.dump_filename value. May include a " + "directory and extension; matching " "___ch___sat_.mat files are read, " - "resolved against --input-path.", + "resolved against --input-path. Defaults to the configured " + "Acquisition dump filename or filenames from --conf, or acquisition.", ) parser.add_argument( "--output-basename", @@ -167,8 +166,9 @@ def parse_args(): default=None, metavar="CODE", help="GNSS-SDR signal code (e.g. 1C, 5X, 7X, E6, J1). Filters " - "all-files mode; defaults to 1C (GPS L1 C/A) in single-file mode. " - "Choices: " + ", ".join(sorted(SIGNAL_TYPES)) + ".", + "all-files mode; omitted with a multi-signal --conf scans all " + "configured signals. Defaults to 1C (GPS L1 C/A) in single-file " + "mode. Choices: " + ", ".join(sorted(SIGNAL_TYPES)) + ".", ) parser.add_argument( "--samples-per-chip", @@ -196,13 +196,84 @@ def parse_args(): add_output_format_argument(parser) args = parser.parse_args() + try: + apply_conf_defaults(args) + except ConfigError as exc: + parser.error(str(exc)) if args.output_basename is None: # Default the saved-plot basename to the dump basename, dropping any - # directory and extension carried by --file-prefix. - _, args.output_basename = resolve_dump_prefix(args.file_prefix) + # directory and extension carried by --file-prefix. A multi-signal + # configuration may use several prefixes, so leave the basename empty + # and let the system/signal/channel fields identify each output. + if getattr(args, "acquisition_plans", None) and args.file_prefix is None: + args.output_basename = None + else: + _, args.output_basename = resolve_dump_prefix(args.file_prefix) return args +def apply_conf_defaults(args): + conf = load_gnss_sdr_conf(args.conf) if args.conf else None + signal = None + signals = [] + args.acquisition_plans = [] + + if conf is not None: + if args.signal_type is not None: + signal = conf.require_enabled_signal(args.signal_type) + signals = [signal] + elif args.plot_all_files and args.file_prefix is None: + signals = conf.select_signals() + if len(signals) == 1: + signal = signals[0] + args.signal_type = signal.signal + elif not args.plot_all_files: + signal = conf.select_signal() + signals = [signal] + args.signal_type = signal.signal + + if conf is not None and args.plot_all_files and args.file_prefix is None: + args.acquisition_plans = [ + AcquisitionDumpPlan( + signal=selected.signal, + system=selected.system, + file_prefix=( + selected.acquisition_dump_filename + or DEFAULT_FILE_PREFIX + ), + ) + for selected in signals + ] + if len(args.acquisition_plans) == 1: + args.file_prefix = args.acquisition_plans[0].file_prefix + + if args.file_prefix is None and not args.acquisition_plans: + args.file_prefix = ( + signal.acquisition_dump_filename + if signal is not None and signal.acquisition_dump_filename + else DEFAULT_FILE_PREFIX + ) + + if args.signal_type is None and not args.plot_all_files: + args.signal_type = DEFAULT_SIGNAL_TYPE + + +def acquisition_plans_for_args(args): + if getattr(args, "acquisition_plans", None): + return args.acquisition_plans + + system = None + if args.signal_type is not None: + system, _ = SIGNAL_TYPES[args.signal_type] + return [ + AcquisitionDumpPlan( + signal=args.signal_type, + system=system, + file_prefix=args.file_prefix, + ) + ] + + def positive_acq_value(data): for key in ("positive_acq", "d_positive_acq"): value = data.get(key) @@ -389,6 +460,12 @@ def metadata_matches_filters(metadata, args): return True +def metadata_matches_plan(metadata, plan): + if plan.signal is None: + return True + return metadata["system"] == plan.system and metadata["signal"] == plan.signal + + def show_figures(args): # Display every saved figure with a single plt.show() call. Showing once # (instead of once per figure) keeps the GUI event loop to a single entry @@ -417,32 +494,56 @@ def main(): return plotted = 0 - directory, base = resolve_dump_prefix(args.file_prefix, args.input_path) - file_paths = sorted(directory.glob(f"{base}*.mat")) - for file_path in file_paths: - metadata = parse_dump_name(file_path) - if metadata is None: - print(f"Skipping {file_path.name}: unexpected filename format") - continue + found_paths = set() + handled_paths = set() + searched_prefixes = [] + for plan in acquisition_plans_for_args(args): + directory, base = resolve_dump_prefix(plan.file_prefix, args.input_path) + searched_prefixes.append((directory, base)) + file_paths = sorted(directory.glob(f"{base}*.mat")) + for file_path in file_paths: + found_paths.add(file_path) + if file_path in handled_paths: + continue - if not metadata_matches_filters(metadata, args): - continue + metadata = parse_dump_name(file_path) + if metadata is None: + print(f"Skipping {file_path.name}: unexpected filename format") + continue - n_chips = N_CHIPS.get((metadata["system"], metadata["signal"])) - if n_chips is None: - print( - f"Unknown system/signal {metadata['system']}/" - f"{metadata['signal']}, skipping: {file_path.name}" + if not metadata_matches_plan(metadata, plan): + continue + + if not metadata_matches_filters(metadata, args): + continue + + handled_paths.add(file_path) + n_chips = N_CHIPS.get((metadata["system"], metadata["signal"])) + if n_chips is None: + print( + f"Unknown system/signal {metadata['system']}/" + f"{metadata['signal']}, skipping: {file_path.name}" + ) + continue + + image_name_root = image_name_from_metadata( + args.output_basename, + metadata, ) - continue + if plot_dump(file_path, args.fig_path, image_name_root, n_chips, args): + plotted += 1 - image_name_root = image_name_from_metadata(args.output_basename, metadata) - if plot_dump(file_path, args.fig_path, image_name_root, n_chips, args): - plotted += 1 - - if not file_paths: - print(f"No files matching {base}*.mat in {directory}") - prefixes = matching_dump_prefixes(directory) + if not found_paths: + for directory, base in searched_prefixes: + print(f"No files matching {base}*.mat in {directory}") + directories = sorted( + {directory for directory, _ in searched_prefixes}, + key=str, + ) + prefixes = [] + for directory in directories: + prefixes.extend(matching_dump_prefixes(directory)) + prefixes = sorted(set(prefixes)) if prefixes: print("Available acquisition dump prefixes:") for prefix in prefixes: diff --git a/utils/python/plot_tracking_quality_indicators.py b/utils/python/plot_tracking_quality_indicators.py index 8bdfb3593..762ce60f5 100755 --- a/utils/python/plot_tracking_quality_indicators.py +++ b/utils/python/plot_tracking_quality_indicators.py @@ -34,13 +34,27 @@ import numpy as np from lib.dll_pll_veml_read_tracking_dump import dll_pll_veml_read_tracking_dump from lib.dump_filename import resolve_dump_prefix +from lib.gnss_sdr_conf import ( + ChannelDump, + ConfigError, + SIGNAL_TYPES, + add_conf_argument, + load_gnss_sdr_conf, +) from lib.plot_format import add_output_format_argument, apply_publication_style +DEFAULT_FILE_PREFIX = "track_ch" +DEFAULT_SAMPLING_FREQUENCY = 4000000.0 +DEFAULT_CHANNELS = 5 +DEFAULT_FIRST_CHANNEL = 0 +DEFAULT_SIGNAL_TYPE = "1C" + def parse_args(): parser = argparse.ArgumentParser( description="Plot tracking quality indicators from GNSS-SDR dumps." ) + add_conf_argument(parser) parser.add_argument( "-i", "--input-path", @@ -57,37 +71,42 @@ def parse_args(): ) parser.add_argument( "--file-prefix", - default="track_ch", - help="GNSS-SDR Tracking.dump_filename value (default: track_ch). May " - "include a directory and extension; the matching .dat " - "files are read, resolved against --input-path.", + default=None, + help="GNSS-SDR Tracking.dump_filename value. May include a directory " + "and extension; the matching .dat files are read, " + "resolved against --input-path. Defaults to the configured Tracking " + "dump filename or filenames from --conf, or track_ch.", ) parser.add_argument( "--sampling-frequency", type=float, - default=4000000.0, - help="Signal sampling frequency in Hz.", + default=None, + help="Signal sampling frequency in Hz. Defaults to " + "GNSS-SDR.internal_fs_sps from --conf, or 4000000.0.", ) parser.add_argument( "--channels", type=int, - default=5, - help="Number of channels to read.", + default=None, + help="Number of channels to read. Defaults to all selected channels " + "from --conf, or 5.", ) parser.add_argument( "--first-channel", type=int, - default=0, - help="First channel number in the dump filenames.", + default=None, + help="First channel number in the dump filenames. Defaults to the " + "first selected absolute channel from --conf, or 0.", ) parser.add_argument( "--signal-type", type=str.upper, - default="1C", + choices=sorted(SIGNAL_TYPES), + default=None, metavar="CODE", - help="GNSS-SDR signal code (e.g. 1C, 5X, 7X, E6, J1) used as the " - "C/N0 legend label. The tracking dump stores only the PRN, so the " - "signal is supplied here (default: 1C).", + help="GNSS-SDR signal code (e.g. 1C, 5X, 7X, E6, J1) used to restrict " + "channel ranges and label traces. Omitted with a multi-signal --conf " + "means all configured signals.", ) parser.add_argument( "--style", @@ -109,24 +128,107 @@ def parse_args(): help="Display figures interactively after saving them.", ) add_output_format_argument(parser) - return parser.parse_args() + args = parser.parse_args() + try: + apply_conf_defaults(args) + except ConfigError as exc: + parser.error(str(exc)) + return args + + +def apply_conf_defaults(args): + conf = load_gnss_sdr_conf(args.conf) if args.conf else None + signals = [] + explicit_range = args.channels is not None or args.first_channel is not None + if conf is not None: + signals = conf.select_signals(args.signal_type) + if args.signal_type is not None: + args.signal_type = signals[0].signal + elif args.signal_type is None: + args.signal_type = DEFAULT_SIGNAL_TYPE + + if args.file_prefix is None: + if conf is None or (len(signals) == 1 and explicit_range): + signal = signals[0] if signals else None + args.file_prefix = ( + signal.tracking_dump_filename + if signal is not None and signal.tracking_dump_filename + else DEFAULT_FILE_PREFIX + ) + + if args.sampling_frequency is None: + if conf is not None: + if conf.internal_fs_sps is None: + raise ConfigError( + "GNSS-SDR.internal_fs_sps is required to infer " + "--sampling-frequency." + ) + args.sampling_frequency = conf.internal_fs_sps + else: + args.sampling_frequency = DEFAULT_SAMPLING_FREQUENCY + + if conf is not None and not explicit_range: + args.channel_plan = conf.channel_dump_plan( + signals=signals, + dump_filename_attr="tracking_dump_filename", + default_file_prefix=DEFAULT_FILE_PREFIX, + override_file_prefix=args.file_prefix, + ) + args.channels = len(args.channel_plan) + args.first_channel = args.channel_plan[0].channel + args.channel_ids = [entry.channel for entry in args.channel_plan] + if len(signals) == 1: + args.file_prefix = args.channel_plan[0].file_prefix + return + + if args.channels is None: + args.channels = signals[0].count if signals else DEFAULT_CHANNELS + + if args.first_channel is None: + args.first_channel = ( + signals[0].first_channel if signals else DEFAULT_FIRST_CHANNEL + ) + + if args.file_prefix is None: + args.file_prefix = DEFAULT_FILE_PREFIX + + manual_signal = args.signal_type or ( + signals[0].signal if signals else DEFAULT_SIGNAL_TYPE + ) + args.channel_plan = [ + ChannelDump( + channel=channel, + signal=manual_signal, + file_prefix=args.file_prefix, + ) + for channel in range(args.first_channel, args.first_channel + args.channels) + ] + args.channel_ids = [entry.channel for entry in args.channel_plan] def read_tracking_dumps(args): - directory, base = resolve_dump_prefix(args.file_prefix, args.input_path) dumps = [] - for channel in range(args.first_channel, args.first_channel + args.channels): + for entry in args.channel_plan: + directory, base = resolve_dump_prefix(entry.file_prefix, args.input_path) + channel = entry.channel tracking_log_path = directory / f"{base}{channel}.dat" - dumps.append(dll_pll_veml_read_tracking_dump(tracking_log_path)) + tracking = dll_pll_veml_read_tracking_dump(tracking_log_path) + tracking["_channel"] = channel + tracking["_signal_type"] = entry.signal + dumps.append(tracking) return dumps def prn_label(tracking): # Distinct PRNs the channel tracked, in order of appearance. A channel that # is reassigned mid-run (lost lock and re-acquired) tracks more than one - # satellite; + # satellite. prns = dict.fromkeys(int(p) for p in tracking["PRN"] if int(p)) - return "/".join(str(p) for p in prns) + signal = tracking.get("_signal_type") + prn_list = "/".join(str(p) for p in prns) or "unknown" + if signal: + return f"{signal} PRN {prn_list}" + return prn_list def time_seconds(tracking, sampling_frequency): @@ -134,28 +236,38 @@ def time_seconds(tracking, sampling_frequency): def series_by_prn(tracking_list, key, sampling_frequency): - # Merge each PRN's samples across all channels, sorted by time, so a - # satellite handed between channels becomes one continuous series. - # Assumes no two channels track the same PRN simultaneously + # Merge each signal/PRN series across all channels, sorted by time, so a + # satellite handed between channels becomes one continuous series. Assumes + # no two channels track the same signal/PRN simultaneously. parts = {} for tracking in tracking_list: time_s = time_seconds(tracking, sampling_frequency) prn = np.asarray(tracking["PRN"]) values = np.asarray(tracking[key], dtype=float) + signal = tracking.get("_signal_type", "") for p in np.unique(prn): if not p: continue sel = prn == p - parts.setdefault(int(p), []).append((time_s[sel], values[sel])) + parts.setdefault((signal, int(p)), []).append( + (time_s[sel], values[sel]) + ) series = {} - for p, chunks in parts.items(): + for key, chunks in parts.items(): time_s = np.concatenate([c[0] for c in chunks]) values = np.concatenate([c[1] for c in chunks]) order = np.argsort(time_s, kind="stable") - series[p] = (time_s[order], values[order]) + series[key] = (time_s[order], values[order]) return series +def satellite_title(series_key): + signal, prn = series_key + if signal: + return f"{signal} PRN {prn}" + return f"PRN {prn}" + + def plot_per_satellite(tracking_list, spec, args): series = series_by_prn(tracking_list, spec["key"], args.sampling_frequency) prns = sorted(series) @@ -175,7 +287,7 @@ def plot_per_satellite(tracking_list, spec, args): ax.set_visible(True) time_s, values = series[p] ax.plot(time_s, values, linewidth=0.8) - ax.set_title(f"PRN {p}", fontsize=9) + ax.set_title(satellite_title(p), fontsize=9) ax.grid(alpha=0.3) fig.suptitle(f"{spec['ylabel']} per satellite") fig.supxlabel("Time(s)") @@ -195,7 +307,7 @@ def plot_per_channel(tracking_list, spec, args): plt.plot( time_s, tracking[spec["key"]], - label=f"{spec['legend_prefix']} {prn_label(tracking)}", + label=spec["label"](tracking), ) plt.xlabel("Time(s)") plt.ylabel(spec["ylabel"]) @@ -217,13 +329,13 @@ def main(): "key": "carrier_lock_test", "ylabel": "Carrier lock test", "stem": "carrier_lock_test", - "legend_prefix": "SV", + "label": lambda tracking: f"SV {prn_label(tracking)}", }, { "key": "CN0_SNV_dB_Hz", "ylabel": "C/N0 (dB-Hz)", "stem": "CN0_SNV_dB_Hz", - "legend_prefix": f"{args.signal_type} PRN", + "label": lambda tracking: prn_label(tracking), }, ] for spec in specs: