RINEX 3 observation products: parse a station's observation file, expose its
header (including the surveyed APPROX POSITION XYZ, optional antenna
DELTA H/E/N offset, and carrier phase-shift records), and extract the
single-frequency pseudoranges that Orbis.GNSS.Positioning.solve/4 consumes.
This is the Elixir surface over the astrodynamics-gnss RINEX observation
parser and its Hatanaka (CRINEX) decoder. A file is parsed once into a
resource handle held by the BEAM; accessors operate on that handle and never
re-read the file.
Both plain RINEX (.rnx) and Hatanaka-compressed CRINEX (.crx) text are
accepted: load/1 and parse_auto/1 sniff the first line for the
CRINEX VERS / TYPE marker and decode CRINEX before parsing. Gzip is handled
upstream by Orbis.GNSS.Data.fetch/2, so this module only ever sees plain text
or CRINEX text.
Example
{:ok, obs} = Orbis.GNSS.RINEX.Observations.load("ESBC00DNK_..._MO.crx")
Orbis.GNSS.RINEX.Observations.approx_position(obs)
# => {3_582_105.291, 532_589.7313, 5_232_754.8054}
[%{index: i, epoch: epoch} | _] = Orbis.GNSS.RINEX.Observations.epochs(obs)
{:ok, prs} = Orbis.GNSS.RINEX.Observations.pseudoranges(obs, i, codes: %{"G" => ["C1C"]})
# prs :: [{"G01", range_m}, ...] — feeds solve/4 verbatimDefault pseudorange codes
The per-system defaults are version-aware: GPS C1C, Galileo C1C then
C1X, BeiDou C1I for RINEX 3.02 / C2I for 3.01 and 3.03+ (the B1I label
changed between minor versions), GLONASS C1C. Override per system with the
:codes option, e.g. codes: %{"G" => ["C1C"], "C" => ["C2I"]}.
Summary
Types
An epoch descriptor as returned by epochs/1.
GLONASS satellite id to FDMA frequency-channel number.
A pseudorange observation {satellite_id, range_m}.
Functions
The antenna reference-point offset from the marker {height_m, east_m, north_m},
or nil if the file carries no ANTENNA: DELTA H/E/N header record.
The surveyed a-priori receiver position {x_m, y_m, z_m} (ECEF meters), or
nil if the file carries no APPROX POSITION XYZ.
Carrier frequency in hertz for a system letter and RINEX band digit.
Decode Hatanaka CRINEX text into the plain RINEX observation text it expands
to. Returns {:ok, rinex_text} or {:error, reason}.
The epoch list as [%{index:, epoch:, flag:, sat_count:}]. The :epoch is a
{{y, mo, d}, {h, mi, second_float}} tuple in the file's time scale — exactly
the form Orbis.GNSS.Positioning.solve/4 accepts.
The GLONASS satellite slot/frequency-channel map from the optional
GLONASS SLOT / FRQ # header records.
Load and parse a RINEX observation file from disk.
Like load/1 but raises on failure.
The per-constellation observation-code table as a map of system letter to the
ordered code list, e.g. %{"G" => ["C1C", ...], "E" => [...]}.
Parse plain RINEX 3 observation text into a handle.
Parse text, auto-detecting plain RINEX vs CRINEX from the first line.
Decode Hatanaka CRINEX text and parse the result into a handle.
Carrier phase-shift header records from SYS / PHASE SHIFT, in file order.
Carrier-phase observations for one epoch (the L* codes), keyed by satellite.
Extract single-frequency pseudoranges for one epoch.
Every observation value for one epoch, keyed by satellite.
Types
@type epoch_entry() :: %{ index: non_neg_integer(), epoch: {{integer(), integer(), integer()}, {integer(), integer(), float()}}, flag: 0..255, sat_count: non_neg_integer() }
An epoch descriptor as returned by epochs/1.
GLONASS satellite id to FDMA frequency-channel number.
A pseudorange observation {satellite_id, range_m}.
@type t() :: %Orbis.GNSS.RINEX.Observations{handle: reference()}
Functions
The antenna reference-point offset from the marker {height_m, east_m, north_m},
or nil if the file carries no ANTENNA: DELTA H/E/N header record.
RINEX stores this field in local height/east/north coordinates. For a station
whose APPROX POSITION XYZ is the marker, add this local offset before
comparing an observation-derived baseline to antenna-reference-point truth.
The surveyed a-priori receiver position {x_m, y_m, z_m} (ECEF meters), or
nil if the file carries no APPROX POSITION XYZ.
Carrier frequency in hertz for a system letter and RINEX band digit.
The two-argument form covers fixed-frequency systems ("G", "E", "C")
and returns nil for GLONASS because its G1/G2 carriers are FDMA
channel-dependent. Use the three-argument form with the parsed GLONASS
frequency-channel number:
Orbis.GNSS.RINEX.Observations.band_frequency_hz("R", "1", 1)
# => 1602562500.0For GLONASS, band "1" is G1 (1602 MHz + k * 562.5 kHz) and band "2" is
G2 (1246 MHz + k * 437.5 kHz), where k is the frequency-channel number.
Unknown bands return nil.
Decode Hatanaka CRINEX text into the plain RINEX observation text it expands
to. Returns {:ok, rinex_text} or {:error, reason}.
@spec epochs(t()) :: [epoch_entry()]
The epoch list as [%{index:, epoch:, flag:, sat_count:}]. The :epoch is a
{{y, mo, d}, {h, mi, second_float}} tuple in the file's time scale — exactly
the form Orbis.GNSS.Positioning.solve/4 accepts.
@spec glonass_slots(t()) :: glonass_slot_map()
The GLONASS satellite slot/frequency-channel map from the optional
GLONASS SLOT / FRQ # header records.
The keys are RINEX satellite ids such as "R01" and values are the FDMA
frequency-channel numbers used to derive GLONASS G1/G2 carrier frequencies.
Returns %{} when the observation file does not carry the header records.
Load and parse a RINEX observation file from disk.
The file may be plain RINEX (.rnx) or Hatanaka CRINEX (.crx); the first
line is sniffed for the CRINEX marker and decoded if present. Returns
{:ok, %Orbis.GNSS.RINEX.Observations{}} or {:error, reason}.
Like load/1 but raises on failure.
The per-constellation observation-code table as a map of system letter to the
ordered code list, e.g. %{"G" => ["C1C", ...], "E" => [...]}.
Parse plain RINEX 3 observation text into a handle.
Parse text, auto-detecting plain RINEX vs CRINEX from the first line.
Decode Hatanaka CRINEX text and parse the result into a handle.
@spec phase_shifts(t()) :: [ %{ system: String.t(), code: String.t(), correction_cycles: float(), satellites: [String.t()] } ]
Carrier phase-shift header records from SYS / PHASE SHIFT, in file order.
Each row is a map with :system, :code, :correction_cycles, and
:satellites. An empty satellite list means the correction applies to every
satellite for that system/code.
@spec phases(t(), non_neg_integer() | tuple(), keyword()) :: {:ok, %{required(String.t()) => [map()]}} | {:error, term()}
Carrier-phase observations for one epoch (the L* codes), keyed by satellite.
Convenience over values/3 that keeps only carrier phase and adds the
wavelength and the phase expressed in metres when the carrier frequency is
known for the satellite's system and band (GPS, Galileo, BeiDou, and GLONASS
when the file carries GLONASS SLOT / FRQ # records). GLONASS G1/G2
wavelengths are derived from the satellite's parsed FDMA frequency-channel
number; a GLONASS satellite without a channel map entry keeps
wavelength_m/value_m as nil.
SYS / PHASE SHIFT correction
When the file carries SYS / PHASE SHIFT header records with a non-zero
correction_cycles, that fractional-cycle bias is added to value_cycles
(and folded into value_m) so the carrier phase is aligned to a common
reference, which is what an integer-ambiguity resolver requires. A record's
satellite list scopes the correction: an empty list applies to every
satellite of that system/code, a non-empty list applies only to the listed
satellites. The applied offset is reported on each phase row as
:phase_shift_cycles (0.0 when no record matches), and :value_cycles
already includes it. With the common all-zero SYS / PHASE SHIFT records the
output is bit-identical to the uncorrected values.
Returns {:ok, %{satellite_id => [phase]}} where each phase is
%{code: "L1C", value_cycles: 1.23e8, lli: 0 | nil, ssi: 7 | nil,
frequency_hz: 1.57542e9 | nil, wavelength_m: 0.1903 | nil,
value_m: 2.34e7 | nil, phase_shift_cycles: 0.0}
@spec pseudoranges(t(), non_neg_integer() | tuple(), keyword()) :: {:ok, [observation()]} | {:error, term()}
Extract single-frequency pseudoranges for one epoch.
epoch is either the integer epoch index (from epochs/1) or an epoch tuple
{{y, mo, d}, {h, mi, s}}, which is resolved to its index.
Without :codes, the version-aware defaults are applied across every system.
When :codes is given it defines the whole policy: only the listed systems
are extracted, each with its given code preference, e.g. codes: %{"G" => ["C1C"]} yields GPS-only pseudoranges and codes: %{"G" => ["C1C"], "C" => ["C2I"]} yields GPS + BeiDou.
Returns {:ok, [{"G01", range_m}, ...]} (ascending satellite id) or
{:error, :epoch_out_of_range} / {:error, :unknown_epoch}.
@spec values(t(), non_neg_integer() | tuple(), keyword()) :: {:ok, %{required(String.t()) => [map()]}} | {:error, term()}
Every observation value for one epoch, keyed by satellite.
epoch is the integer index (from epochs/1) or an epoch tuple
{{y, mo, d}, {h, mi, s}}. Unlike pseudoranges/3 this returns the raw RINEX
observations across code types — pseudorange, carrier phase, Doppler, and
signal strength — so callers can build carrier-phase combinations.
Returns {:ok, %{satellite_id => [obs]}} where each obs is
%{code: "L1C", kind: :carrier_phase, value: 1.23e8, units: :cycles,
lli: 0 | nil, ssi: 7 | nil}kind/units follow the RINEX code's leading letter (C → :pseudorange/
:meters, L → :carrier_phase/:cycles, D → :doppler/:hz, S →
:signal_strength/:db_hz). A blank observation has a nil value. Returns
{:error, :epoch_out_of_range} / {:error, :unknown_epoch}.
Options
:codes— a per-system code filter, e.g.%{"G" => ["L1C", "L2W"]}. By default every code for every satellite is returned; a non-empty filter restricts the result (and the data crossing the NIF boundary) to the listed systems, and within each to the listed codes. A system mapped to[]keeps all of that system's codes — e.g.%{"G" => []}is GPS-only, all codes.