Files
gnss-sdr/utils/python/lib/gnss_sdr_conf.py
T
joebre 6d3bdbc3cc python: add missing BeiDou B1C/SBAS L1 entries to plot SIGNAL_ORDER
SIGNAL_ORDER in utils/python/lib/gnss_sdr_conf.py is meant to mirror
signal_mapping in src/core/receiver/gnss_block_factory.cc, which
GetChannels() walks in a fixed order to assign absolute channel IDs
(consuming Channels_<type>.count channels per entry; the per-channel
Channel<N>.signal lines in the conf file are not used for this).

SIGNAL_ORDER was missing "1D" (BeiDou B1C) and "S1" (SBAS L1), both
present in signal_mapping. Any configuration enabling BeiDou B1C or
SBAS alongside other signal types would get every later signal's
inferred channel range shifted, mislabeling tracking dump plots for
those channels. Added both entries, in the same relative position as
signal_mapping, along with their SignalType metadata.

Signed-off-by: joebre <joel.brenner@saphyrion.ch>
2026-08-27 15:48:02 +02:00

438 lines
13 KiB
Python

"""
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 signal_mapping in
# src/core/receiver/gnss_block_factory.cc. GNSS-SDR assigns absolute channel
# IDs by walking the configured signal counts in this order (it does not use
# the per-channel Channel<N>.signal lines in the conf file for this).
SIGNAL_ORDER = (
"1C",
"2S",
"L5",
"1B",
"5X",
"E6",
"1G",
"2G",
"B1",
"1D",
"B3",
"7X",
"J1",
"J5",
"S1",
)
@dataclass(frozen=True)
class SignalType:
system: str
n_chips: int
pretty_name: str
def __iter__(self):
# Keep compatibility with callers that unpack SIGNAL_TYPES entries as
# (system, n_chips).
yield self.system
yield self.n_chips
# GNSS-SDR signal nomenclature: each signal code maps to the system character
# used in acquisition dump filenames, its primary code length in chips, and a
# human-friendly signal name for plot labels.
SIGNAL_TYPES = {
"1C": SignalType("G", 1023, "GPS L1 C/A"),
"2S": SignalType("G", 10230, "GPS L2C"),
"L5": SignalType("G", 10230, "GPS L5"),
"1B": SignalType("E", 4092, "Galileo E1B"),
"5X": SignalType("E", 10230, "Galileo E5a"),
"7X": SignalType("E", 10230, "Galileo E5b"),
"E6": SignalType("E", 5115, "Galileo E6"),
"1G": SignalType("R", 511, "GLONASS L1 C/A"),
"2G": SignalType("R", 511, "GLONASS L2 C/A"),
"B1": SignalType("C", 2046, "BeiDou B1I"),
"1D": SignalType("C", 10230, "BeiDou B1C"),
"B3": SignalType("C", 10230, "BeiDou B3I"),
"J1": SignalType("J", 1023, "QZSS L1 C/A"),
"J5": SignalType("J", 10230, "QZSS L5"),
"S1": SignalType("S", 1023, "SBAS L1"),
}
N_CHIPS = {
(signal_type.system, code): signal_type.n_chips
for code, signal_type in SIGNAL_TYPES.items()
}
def signal_pretty_name(signal_type):
if signal_type is None:
return ""
metadata = SIGNAL_TYPES.get(signal_type.upper())
if metadata is None:
return signal_type
return metadata.pretty_name
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]