Add possibility to infer Python plot defaults from GNSS-SDR configuration files

This commit is contained in:
Carles Fernandez
2026-06-25 20:49:30 +02:00
parent 16cf5e7753
commit 95ad7d19ec
12 changed files with 1193 additions and 168 deletions
+59 -26
View File
@@ -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: `<input-path>/<file-prefix><channel>.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: `<input-path>/<file-prefix><channel>.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: `<input-path>/<file-prefix><channel>.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 `<signal-type> PRN <PRN>` (e.g.
`1C PRN 9`); the carrier-lock legend labels traces as `SV <PRN>`.
`1C PRN 9`); the carrier-lock legend labels traces as `SV <signal-type> PRN
<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: `<input-path>/<file-prefix><channel>.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: `<input-path>/<file-prefix>.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: `<input-path>/<file-prefix>.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
+133 -16
View File
@@ -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 <prefix><channel>.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 <prefix><channel>.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"],
+102 -12
View File
@@ -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 <prefix><channel>.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 <prefix><channel>.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,
+40 -7
View File
@@ -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 <prefix>.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 <prefix>.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):
+93 -13
View File
@@ -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 "
"<prefix><channel>.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 <prefix><channel>.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):
+45 -7
View File
@@ -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 <prefix>.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 <prefix>.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_<signal>.count value is required "
"to infer --channels."
)
args.channels = conf.total_channels
else:
args.channels = DEFAULT_CHANNELS
def first_valid_observable(gnss_observables, channels):
+406
View File
@@ -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]
+7 -2
View File
@@ -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
+7 -2
View File
@@ -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
+8 -3
View File
@@ -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
+151 -50
View File
@@ -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 "
"<prefix>_<sys>_<sig>_ch_<ch>_<exec>_sat_<PRN>.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:
+142 -30
View File
@@ -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 <prefix><channel>.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 <prefix><channel>.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: