mirror of
https://github.com/gnss-sdr/gnss-sdr
synced 2026-07-01 17:08:51 +00:00
433 lines
13 KiB
Python
433 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 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",
|
|
)
|
|
|
|
@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"),
|
|
"B3": SignalType("C", 10230, "BeiDou B3I"),
|
|
"J1": SignalType("J", 1023, "QZSS L1 C/A"),
|
|
"J5": SignalType("J", 10230, "QZSS L5"),
|
|
}
|
|
|
|
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]
|