Skip to content

RINEX v3.04 parsing

Rnxv3Obs implements a full RINEX v3.04 observation file parser, from raw text to a validated xarray.Dataset in a single to_ds() call.


File structure

A RINEX v3 observation file has two sections separated by END OF HEADER:

+──────────────────────────────────────────────────+
│  HEADER SECTION                                   │
│  RINEX VERSION / TYPE          → 3.04, O          │
│  SYS / # / OBS TYPES           → G: S1C S2W …    │
│  TIME OF FIRST OBS             → 2024 001         │
│  INTERVAL                      → 30.000           │
+──────────────────────────────────────────────────+
│  DATA SECTION                                     │
│  > 2024 01 01 00 00 00.0000000  0  18             │  ← epoch marker
│    G01   41.050  …   22.123456  …                 │  ← observations
│    G03   40.112  …   21.987654  …                 │
│  > 2024 01 01 00 00 30.0000000  0  17             │
│    …                                              │
+──────────────────────────────────────────────────+

For RINEX 2.11 files see RINEX v2.11 Parsing.

Supported systems: GPS (G), GLONASS (R), Galileo (E), BeiDou (C), QZSS (J), IRNSS (I), SBAS (S).


Class hierarchy

classDiagram
    direction LR
    GNSSDataReader <|-- Rnxv3Obs
    Rnxv3Obs *-- Rnxv3Header
    Rnxv3Obs *-- SignalIDMapper

    class GNSSDataReader{
        <<abstract>>
        +to_ds()*
        +iter_epochs()*
        +to_ds_and_auxiliary()
        +file_hash*
    }

    class Rnxv3Obs{
        +Path fpath
        +Rnxv3Header header
        +to_ds()
        +iter_epochs()
        +file_hash
    }

    class Rnxv3Header{
        +float version
        +str rinextype
        +dict obs_codes_per_system
        +dict~str,datetime~ t0
    }

Parsing pipeline

Step 1: initialization

from pathlib import Path
from canvod.readers import Rnxv3Obs

reader = Rnxv3Obs(fpath=Path("station.24o"))

What happens on construction:

  1. Pydantic validates that fpath exists.
  2. The header section is parsed into Rnxv3Header; RINEX version (3.x), file type (O) and the observation type table (SYS / # / OBS TYPES) are validated.
  3. The epoch completeness check runs (completeness_mode, default "strict"; "warn" or "off" to relax it, see below).
  4. The whole file is read into memory and hashed (file_hash).

Epoch completeness check

The file must hold dump interval / sampling interval epochs.

You pass Dump interval Sampling interval Finds
nothing first to last epoch, plus one interval most common step between epochs gaps inside the file
expected_dump_interval, expected_sampling_interval (e.g. "15 min", "5 s") as given as given gaps, and a file cut short

Any positive sampling interval works (e.g. 3 s, 0.5 s). A mismatch raises MissingEpochError ("strict"), warns ("warn"), or is not checked ("off").

canvodpy run turns this check off and checks every file, of every format, against the period and sampling interval of its canonical name (15M_05S: 180 epochs). A file with fewer epochs is kept and logged as epoch_count_differs_from_name.

Step 2: epoch iteration

for epoch in reader.iter_epochs():
    print(epoch.timestamp, epoch.num_satellites)

The generator scans the file's lines (already in memory) from END OF HEADER, yielding one validated Rnxv3ObsEpochRecord per > epoch marker; epochs that fail validation are skipped.

Step 3: dataset construction

ds = reader.to_ds(keep_data_vars=["SNR", "Phase"])

The full pipeline:

# Build the full SID index from header obs codes (sorted)
mapper = SignalIDMapper()
sorted_sids, sid_props = self._precompute_sids_from_header()
# Pre-allocate the kept variables, fill value NaN (LLI/SSI: -1)
arrays = _allocate_obs_arrays(n_epochs, len(sorted_sids), kept_vars)
sid_to_idx = {sid: i for i, sid in enumerate(sorted_sids)}

# Default parser: only epochs that pass the Pydantic models
for t_idx, record in enumerate(self._iter_validated_epochs(rejected)):
    for sat in record.data:
        for (obs_type, sid_suffix), obs in zip(lut[sat.sv[0]], sat.observations):
            _store_observation(
                arrays,
                t_idx,
                sid_to_idx[sat.sv + sid_suffix],
                obs_type,
                obs.value,
                obs.lli,
                obs.ssi,
            )

The opt-in parser="unvalidated_fast" fills the same arrays by slicing fixed columns, without any validation (see the warning below).

# _assemble_dataset(): one coordinate per signal property,
# precomputed from the header for every sid
coords["sv"] = ("sid", np.array(sid_prop("sv"), dtype=object), ...)
coords["band"] = ("sid", np.array(sid_prop("band"), dtype=object), ...)
for key in ("freq_center", "freq_min", "freq_max"):
    coords[key] = ("sid", np.asarray(sid_prop(key), dtype=np.float32), ...)
ds = xr.Dataset(
    data_vars={"SNR": (("epoch", "sid"), snr_data, snr_meta), ...},
    coords=coords,
    attrs={**self._build_attrs()},   # Created, Software, Institution, File Hash
)

validate_dataset(ds, required_vars=keep_data_vars)
return ds

Pydantic data models

Header model

class Rnxv3Header(BaseModel):   # rinex/v3_04.py
    model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True, ...)

    version: float
    rinextype: str
    systems: str
    receiver_type: str
    approx_position: list[pint.Quantity]
    t0: dict[str, datetime]                      # TIME OF FIRST OBS
    obs_codes_per_system: dict[str, list[str]]   # system → observation codes
    signal_strength_unit: pint.Unit | str
    # ... more fields (see source for the full list)

Epoch record

Pydantic dataclasses in gnss_specs/models.py:

class Rnxv3ObsEpochRecordLineModel:  # the "> ..." line
    year: int
    month: int
    day: int
    hour: int
    minute: int
    seconds: float
    epoch_flag: int
    num_satellites: int
    receiver_clock_offset: float | None = None


class Rnxv3ObsEpochRecord:
    info: Rnxv3ObsEpochRecordLineModel
    data: list[Satellite]  # must hold num_satellites satellites

Observation and satellite

class Observation:
    obs_type: str | None
    value: float | None
    lli: int | None  # Loss of Lock Indicator
    ssi: int | None  # Signal Strength Indicator (1–9, 0 = unknown)


class Satellite:
    sv: str  # e.g. "G01"
    observations: list[Observation]

Signal ID mapping

RINEX observation codes (S1C, L2W, C5Q, …) are mapped to the canonical Signal ID format SV|BAND|CODE:

RINEX code System Band Code Signal ID
S1C on G01 GPS L1 C G01\|L1\|C
S5Q on E08 Galileo E5a Q E08\|E5a\|Q
S2P on R05 GLONASS G2 P R05\|G2\|P

Constellation band mapping

SignalIDMapper().SYSTEM_BANDS == {
    "G": {"1": "L1", "2": "L2", "5": "L5"},
    "R": {"1": "G1", "2": "G2", "3": "G3", "4": "G1a", "6": "G2a"},
    "E": {"1": "E1", "5": "E5a", "7": "E5b", "8": "E5", "6": "E6"},
    "C": {"2": "B1I", "1": "B1C", "5": "B2a", "7": "B2b", "8": "B2", "6": "B3I"},
    "J": {"1": "L1", "2": "L2", "5": "L5", "6": "L6"},
    "I": {"5": "L5", "9": "S"},
    "S": {"1": "L1", "5": "L5"},
}

Satellites

Satellite numbers follow RINEX 3.04 section 8.4. SBAS satellites are Snn with \(nn = \text{PRN} - 100\) (PRN 120 \(\rightarrow\) S20, EGNOS PRN 148 \(\rightarrow\) S48); the reader recognizes S01–S99. The satellite lists of the other systems come from the constellation models.


Loss of lock and signal strength indicators

Pseudorange, phase, Doppler, and SNR of one signal share one SID (S1C, L1C, C1C, D1C \(\rightarrow\) G01|L1|C), but each observation field carries its own LLI and SSI digit. The reader stores one LLI and one SSI per SID, following RINEX 3.04 Table A3 notes 1–3:

  • LLI is taken from the phase observation only ("should only be associated with the phase observation"). Flags on the other observables are ignored.
  • SSI is taken from the phase; if the signal has no phase (for example C1W/S1W), from the pseudorange. SSI on Doppler or SNR fields is ignored.

The result does not depend on the order of observables in the header.

LLI bit Meaning (Table A3)
0 Lost lock between previous and current observation: cycle slip possible
1 Half-cycle ambiguity/slip possible
2 Galileo BOC tracking of an MBOC-modulated signal

-1 marks "no indicator written." Table A3 defines no further bits, so the valid_range of LLI is [0, 7]; the fill value -1 lies outside it, as CF requires. The RINEX 3 meaning of LLI and SSI is shared by all readers; the RINEX 2 reader translates its bits.

Signal strength unit

The unit of the S observations is declared by the optional SIGNAL STRENGTH UNIT header record (RINEX 3.04 sect. 5.7), which defines only DBHZ. With DBHZ, SNR is labeled as C/N0 in dB-Hz. Without it, the unit is not declared and SNR is labeled dB.


Performance notes

One read

The file is read once, on construction, and kept in memory as lines; iter_epochs() and to_ds() parse from there.

Pre-allocated arrays

to_ds() pre-allocates NumPy arrays with np.full(..., np.nan) before filling, avoiding repeated memory reallocation during the fill loop.


Choosing the parser

to_ds() validates every epoch by default. The unvalidated fast parser exists for users who have checked their files otherwise:

ds = reader.to_ds()  # validated (default)
ds = reader.to_ds(parser="unvalidated_fast")  # DANGEROUS, see below

to_ds() itself always defaults to validated; a run passes the processing.params.rinex_v3_parser setting (default validated). The stripped v3.05 reader uses the same parsers.

unvalidated_fast is your responsibility

The unvalidated parser does not check epochs, satellite IDs, satellite counts or observation fields. A corrupted record can enter the dataset as partial or wrong values. canVODpy takes no responsibility for its results; checking the input files is entirely up to you. Every use emits an UnvalidatedParserWarning. For a valid file both parsers give the identical dataset.

Epoch time

Epochs are stored as written in the file. They must be GPS time, the time scale of the orbits and clocks: the time system of the TIME OF FIRST OBS header record (RINEX 3.04 Table A2; a single-system file defaults to its own system) must be GPS. to_ds() refuses a file in another time system (GLO, which is UTC, GAL, QZS, BDT, IRN) with a ValueError naming the file; nothing is converted. The epoch coordinate records time_system: GPS. Seconds keep the 100 ns resolution of the epoch record's F11.7 field.

Error handling

from pydantic import ValidationError

# Construction errors: the header is invalid, or a mandatory record such as
# TIME OF FIRST OBS is missing or malformed
try:
    reader = Rnxv3Obs(fpath=path)
except (ValidationError, ValueError) as e:
    print(f"Invalid RINEX header: {e}")

# Data section: the validated parser drops epochs that fail validation and
# logs them ("rinex_epochs_rejected", with their line numbers). An epoch with
# an impossible date or time (e.g. month 13) rejects the whole file.
try:
    ds = reader.to_ds()
except ValueError as e:
    print(f"Invalid epoch date or time: {e}")

Exception hierarchy

All reader-specific exceptions inherit from RinexError, allowing broad except RinexError handling when needed alongside specific sub-class recovery.