Sidereon.GNSS.Ionosphere (Sidereon v3.0.0)

Copy Markdown View Source

Single-frequency ionospheric group-delay corrections.

Four models are exposed over the sidereon-core crate: the GPS broadcast Klobuchar model (eight alpha/beta coefficients, IS-GPS-200), the Galileo NeQuick-G model in both its compact single-layer and full three-dimensional forms, and an IONEX vertical-TEC-grid slant delay (single-layer model). Each returns the group delay in positive meters.

The IONEX surface also reads and writes products: parse_ionex/1 and parse_ionex_with_warnings/1 read one, ionex_to_string/1 writes one back, from_samples/1 and from_node_samples/5 build one from samples with no text in the loop, and tec_grid_samples/1 and tec_samples/1 read one back out. ionex_header/1 reads the descriptive records and skipped_records/1 reads what a forgiving parse passed over. Sidereon.GNSS.Ionosphere.TecGrid is the standalone regular-grid variant, queried at a pierce point or along the line of sight between ECEF receiver and satellite positions, as vertical and slant TEC or as a group delay.

This is the ionosphere correction for a GNSS signal. It is not Sidereon.Atmosphere, which is NRLMSISE-00 neutral-atmosphere mass density for drag, a different quantity entirely.

Sign convention

The returned delay is a group delay and is positive: it increases the measured pseudorange (the signal arrives later than vacuum geometry would predict). The carrier-phase advance is the negation of this value. The ionosphere is dispersive, so the delay reported on a carrier other than the model's native L1 is the L1 delay scaled by (f_L1 / f)^2; pass the carrier via frequency_hz.

Units at the boundary

Public inputs are in degrees (_deg) and meters/hertz, per the Sidereon naming convention. Latitude is positive north, longitude positive east, azimuth clockwise from north.

Handles

Every function taking a product handle takes a reference from parse_ionex/1, load_ionex/1, from_samples/1 or from_node_samples/5. A reference the boundary cannot read as an IONEX product - a Sidereon.GNSS.Ionosphere.TecGrid handle, or a reference to any other resource kind - is returned as {:error, {:invalid_resource, :ionex}} by every one of them, rather than raising ArgumentError out of a call whose contract is {:ok, _} | {:error, _}. The boundary refuses such a reference as :badarg, which names no field; every other argument of these calls is checked here first, each under its own name, so the resource the call expected is what is named in its place.

Summary

Functions

Build an IONEX product from one sample per TEC grid node.

Build an IONEX product directly from whole-grid TEC samples.

Galileo NeQuick-G single-frequency ionospheric group delay, scaled to frequency_hz.

The descriptive header records a parsed or sample-built product carries.

Batch IONEX slant delays, one result per request in request order.

IONEX vertical-TEC-grid slant ionospheric group delay, scaled to frequency_hz.

Serialize a parsed IONEX product back to standard IONEX text.

GPS broadcast Klobuchar L1 ionospheric group delay, scaled to frequency_hz.

Load and parse an IONEX file into a product handle.

Galileo NeQuick-G full slant ionospheric group delay (positive meters).

Galileo NeQuick-G full three-dimensional slant total electron content (TECU).

Parse an in-memory IONEX byte buffer into a product handle.

Parse an IONEX byte buffer, keeping what the reader reported about the file.

The number of records a forgiving parse passed over.

Extract a product handle as whole-grid TEC samples.

Extract a product handle as one TEC sample per grid node.

Functions

from_node_samples(samples, shell_height_km, base_radius_km, exponent, header \\ %Header{})

@spec from_node_samples(
  [Sidereon.GNSS.Ionosphere.TecSample.t()],
  number(),
  number(),
  integer(),
  Sidereon.GNSS.Ionosphere.Header.t()
) :: {:ok, reference()} | {:error, term()}

Build an IONEX product from one sample per TEC grid node.

Every node of the grid the samples span must appear exactly once. The product has RMS maps when any sample carries an RMS value and height maps when any carries a height offset; a node without one is nil there.

shell_height_km and base_radius_km are kilometers and exponent is the IONEX EXPONENT field. header carries the descriptive records the product keeps: node samples say nothing about the mapping function or the program that produced them, so they are stated here.

Flat node samples carry no map-presence field, so this entry cannot state RMS or height maps that are declared yet hold no value at any node: samples whose rms_tecu is nil everywhere give a product with no RMS map. from_samples/1 states each stack explicitly and is the entry for that.

{:error, {:value_out_of_range, field, value}} names a radius that is an integer larger in magnitude than the largest finite double, which has no double to be read onto, or an exponent outside the signed 32-bit range the core holds it in, alongside the refusals Sidereon.GNSS.Ionosphere.TecSample, Sidereon.GNSS.Ionosphere.Header and the core name.

from_samples(samples)

@spec from_samples(Sidereon.GNSS.Ionosphere.TecGridSamples.t()) ::
  {:ok, reference()} | {:error, term()}

Build an IONEX product directly from whole-grid TEC samples.

Axes are degrees, shell and base radii are kilometers, TEC and RMS grids are TECU and height grids are kilometers. A node without a value is nil; absent RMS or height maps are nil, which is not the same as maps present with every node nil.

The returned handle is accepted anywhere a parsed IONEX handle is, including ionex_slant_delay/7 and ionex_to_string/1.

galileo_nequick_g_delay(coeffs, lat_deg, lon_deg, azimuth_deg, elevation_deg, epoch, frequency_hz)

@spec galileo_nequick_g_delay(
  map(),
  number(),
  number(),
  number(),
  number(),
  NaiveDateTime.t() | tuple(),
  number()
) :: {:ok, float()} | {:error, term()}

Galileo NeQuick-G single-frequency ionospheric group delay, scaled to frequency_hz.

coeffs carries the three Galileo broadcast effective-ionisation coefficients as %{ai0: a0, ai1: a1, ai2: a2}. The receiver geodetic latitude/longitude and the satellite azimuth/elevation are in degrees; epoch is a NaiveDateTime or {{y, m, d}, {h, min, s}} tuple in Galileo system time. The NeQuick-G arm maps the slant delay by elevation only, so azimuth_deg is accepted (to match the shared ionosphere-model boundary) but does not change the result. Returns {:ok, delay_m} (positive meters) or {:error, reason}, with the argument refusals klobuchar_delay/7 names (a coefficient under its own name, :ai0, :ai1 or :ai2) and the epoch refusals of Sidereon.GNSS.Time.epoch_to_split_jd/1.

ionex_header(handle)

@spec ionex_header(reference()) ::
  {:ok, Sidereon.GNSS.Ionosphere.Header.t()} | {:error, term()}

The descriptive header records a parsed or sample-built product carries.

Returns {:ok, %Header{}}. mapping_function is nil where the product declares no MAPPING FUNCTION record, one of :none, :cosz or :qfac for the codes IONEX 1 names, and the code's own text for any other.

ionex_slant_batch(handle, requests, policy \\ %SlantPolicy{})

Batch IONEX slant delays, one result per request in request order.

requests is a list of Sidereon.GNSS.Ionosphere.SlantRequest. policy is as in ionex_slant_evaluation/8.

Returns {:ok, results} where results has exactly one entry per request, in request order, each {:ok, %SlantEvaluation{}} or {:error, reason} with that row's own reason. A row that fails does not remove itself from the list, take any other row with it, or come back as a zero delay.

A row fails on its own for a request that cannot be put into the form the boundary takes as much as for one the product refuses: a sub-second epoch, a calendar field that is not an integer, a field that is not a number, a value past the range the boundary carries it in and a row that is not a SlantRequest are each that row's failure, with the reasons Sidereon.GNSS.Ionosphere.SlantRequest documents. A product refusal is one of those ionex_slant_evaluation/8 documents.

The call itself fails only on something that is not a property of one row: a policy the binding cannot read, a request list of the wrong shape, a handle that is not an IONEX product ({:invalid_resource, :ionex}), or a failure of the boundary itself, which is returned as it was reported rather than turned into row values. Nothing that belongs to one row is allowed to become the whole call's failure and take the other rows' results with it.

ionex_slant_delay(handle, lat_deg, lon_deg, azimuth_deg, elevation_deg, epoch, frequency_hz)

@spec ionex_slant_delay(
  reference(),
  number(),
  number(),
  number(),
  number(),
  NaiveDateTime.t() | tuple(),
  number()
) :: {:ok, float()} | {:error, term()}

IONEX vertical-TEC-grid slant ionospheric group delay, scaled to frequency_hz.

handle is a product handle from parse_ionex/1, load_ionex/1, from_samples/1 or from_node_samples/5. The receiver geodetic latitude/longitude and the satellite azimuth/elevation are in degrees; epoch is a UTC NaiveDateTime or {{y, m, d}, {h, min, s}} tuple on a whole second, UTC being the scale of IONEX map epochs (the pierce point rides on the IONEX shell, so the receiver height is not used).

This is the strict convenience: it refuses a query outside the product's coverage and one whose interpolation weights a node the product gives as non-available, and it maps vertical TEC to the line of sight with the single-layer 1/cos(z'). Use ionex_slant_evaluation/8 for other policies or for the status of the value.

Returns {:ok, delay_m} (positive meters) or {:error, reason}, where reason is one of the refusals ionex_slant_evaluation/8 documents.

ionex_slant_evaluation(handle, lat_deg, lon_deg, azimuth_deg, elevation_deg, epoch, frequency_hz, policy \\ %SlantPolicy{})

@spec ionex_slant_evaluation(
  reference(),
  number(),
  number(),
  number(),
  number(),
  NaiveDateTime.t() | tuple(),
  number(),
  Sidereon.GNSS.Ionosphere.SlantPolicy.t() | keyword() | map()
) :: {:ok, Sidereon.GNSS.Ionosphere.SlantEvaluation.t()} | {:error, term()}

IONEX slant delay under explicit coverage, missing-node and mapping policies.

Arguments are as ionex_slant_delay/7, plus policy: a Sidereon.GNSS.Ionosphere.SlantPolicy, or a keyword list or map of its :coverage, :missing_nodes and :mapping choices. An unknown key or choice is an error; nothing falls back to a default for a name it does not recognize.

Returns {:ok, %SlantEvaluation{}} carrying the delay and its independent held, degraded and assumed_mapping status fields, which can all be set on one value, or {:error, reason}.

Refusals

Where the product gives no value:

  • {:out_of_coverage, coverage_error} - the query is outside the product's epochs, latitudes or longitudes under :strict coverage. coverage_error is :epoch_before_first_map, :epoch_after_last_map, :latitude_out_of_range or :longitude_out_of_range.
  • {:nodes_not_available, %Sidereon.GNSS.Ionosphere.NodeGap{}} - the interpolation weights a node the product gives as non-available, under :strict missing nodes. The gap names every such node on each bracketing map.
  • {:height_not_available, %Sidereon.GNSS.Ionosphere.HeightNode{}} - the product's height maps give no value at the named node, so the maps state no single-layer height to ride on.
  • {:varying_heights, %Sidereon.GNSS.Ionosphere.HeightNode{}} - the height maps give two different heights, the second at the named node.
  • {:mapping_function, declaration} - :declared mapping against a product whose MAPPING FUNCTION defines no factor. declaration is :none, :cosz, :qfac, the declared code's own text, or :absent where the product carries no such record.

Where an input is refused before or by the core:

  • {:invalid_field, field, reason} - the receiver position is outside the geodetic frame's range, with the core's own field and reason text. The frame takes latitude in [-90, 90] and longitude in [-180, 180] degrees, and a value outside either is reported as "lat_rad" or "lon_rad" because the frame names its radian fields.
  • {:invalid_input, message} - the core refused elevation (which it takes in [0, 90] degrees), a non-positive or non-finite frequency, or another input, with the core's own message. The core carries no field/reason pair for these, so none is invented here.
  • {:unhandled, message} - a core refusal variant this binding predates, with the core's own text. No other tag stands in for it.
  • :non_integer_second_epoch or {:value_out_of_range, field, value} - the epoch is not on a whole second, or a calendar field, a J2000 second or one of the numeric arguments is past the range the boundary carries it in. A numeric argument is past it when it is an integer larger in magnitude than the largest finite double, which has no f64 to be read onto.
  • {:invalid_epoch_field, field, value} - a date or clock field of a tuple epoch that is not an integer. The shared calendar helper takes those five fields as 32-bit integers, and hour arrived at by division is a float, since Elixir's / always gives one.
  • {:invalid_request_field, field, value} - an argument that is not a number, named with the value that was given.
  • {:invalid_resource, :ionex} - the handle is not a reference to an IONEX product, as described under "Handles" in this module's documentation.
  • the policy refusals of Sidereon.GNSS.Ionosphere.SlantPolicy.

ionex_to_string(handle)

@spec ionex_to_string(reference()) :: {:ok, String.t()} | {:error, term()}

Serialize a parsed IONEX product back to standard IONEX text.

handle is a product handle from parse_ionex/1, load_ionex/1, from_samples/1 or from_node_samples/5. This is the inverse of parse_ionex/1: re-parsing the output reproduces the same TEC grids. The serialization is deterministic and performs no I/O.

The writer is fallible: it refuses a value it cannot write exactly rather than rounding it, and that refusal is returned as {:error, reason}.

Examples

{:ok, handle} = Sidereon.GNSS.Ionosphere.parse_ionex(ionex_bytes)
{:ok, text} = Sidereon.GNSS.Ionosphere.ionex_to_string(handle)
{:ok, _reparsed} = Sidereon.GNSS.Ionosphere.parse_ionex(text)

klobuchar_delay(params, lat_deg, lon_deg, azimuth_deg, elevation_deg, epoch, frequency_hz)

@spec klobuchar_delay(
  map(),
  number(),
  number(),
  number(),
  number(),
  NaiveDateTime.t() | tuple(),
  number()
) :: {:ok, float()} | {:error, term()}

GPS broadcast Klobuchar L1 ionospheric group delay, scaled to frequency_hz.

params carries the eight broadcast coefficients as %{alpha: {a0, a1, a2, a3}, beta: {b0, b1, b2, b3}} (or lists). The receiver geodetic latitude/longitude and the satellite azimuth/elevation are in degrees; epoch is a NaiveDateTime or {{y, m, d}, {h, min, s}} tuple in GPS time. Returns {:ok, delay_m} (positive meters) or {:error, reason}.

An argument the boundary cannot carry is named before the call: {:invalid_double, field, value} for a value that is not a number (a coefficient is named :alpha or :beta), {:value_out_of_range, field, value} for an integer no double holds, and the epoch refusals of Sidereon.GNSS.Time.second_of_day/1. A value the model refuses is {:error, :invalid_input}.

load_ionex(path)

@spec load_ionex(String.t()) :: {:ok, reference()} | {:error, term()}

Load and parse an IONEX file into a product handle.

Returns {:ok, reference()} or {:error, reason}.

nequick_g_delay(coeffs, ray, frequency_hz)

@spec nequick_g_delay(map(), map(), number()) :: {:ok, float()} | {:error, term()}

Galileo NeQuick-G full slant ionospheric group delay (positive meters).

The full 3D slant TEC from nequick_g_stec/2 mapped to a dispersive group delay on frequency_hz. coeffs and ray are as in nequick_g_stec/2, with the same refusals, and frequency_hz is named as a double field.

Returns {:ok, delay_m} (positive meters) or {:error, reason}.

nequick_g_stec(coeffs, ray)

@spec nequick_g_stec(map(), map()) :: {:ok, float()} | {:error, term()}

Galileo NeQuick-G full three-dimensional slant total electron content (TECU).

This is the reference-grade full 3D NeQuick-G integration, distinct from the compact single-layer galileo_nequick_g_delay/7. The coeffs map carries the broadcast effective-ionisation coefficients %{ai0:, ai1:, ai2:}. The ray map carries both endpoints of the receiver-to-satellite line of sight, in the reference algorithm's native units:

  • :month - month of the year, 1..12
  • :utc_hours - UTC time of day in hours, [0, 24]
  • :station_lon_deg, :station_lat_deg, :station_height_m
  • :satellite_lon_deg, :satellite_lat_deg, :satellite_height_m

Returns {:ok, stec_tecu} (slant TEC in TECU) or {:error, reason}. A coefficient or ray field the boundary cannot carry is named before the call: {:invalid_double, field, value} or {:value_out_of_range, field, value} for a double field, {:invalid_integer, :month, value} for a month that is not an integer and {:value_out_of_range, :month, value} for one outside 0..255; a map missing a key is :bad_nequick_params or :bad_nequick_ray.

parse_ionex(bytes)

@spec parse_ionex(binary()) :: {:ok, reference()} | {:error, term()}

Parse an in-memory IONEX byte buffer into a product handle.

Returns {:ok, reference()} or {:error, reason}. The buffer is parsed exactly once; the parsed grid is held as a resource handle.

Findings the reader reports without refusing the file are not returned here. Use parse_ionex_with_warnings/1 to keep them.

parse_ionex_with_warnings(bytes)

@spec parse_ionex_with_warnings(binary()) ::
  {:ok, Sidereon.GNSS.Ionosphere.ParseResult.t()} | {:error, term()}

Parse an IONEX byte buffer, keeping what the reader reported about the file.

Returns {:ok, %ParseResult{}} or {:error, reason}. The warnings are in reader order, and each carries every field its finding has alongside the core's formatted message. An empty list means the reader found nothing to report.

skipped_records is the count of records the parse passed over, which is a separate thing from a warning: a skipped record raises no finding, so a parse can report no warnings and still have skipped something. See skipped_records/1.

skipped_records(handle)

@spec skipped_records(reference()) :: {:ok, non_neg_integer()} | {:error, term()}

The number of records a forgiving parse passed over.

The reader skips a block it does not support, such as START OF AUX DATA, and a header record whose field it cannot read, counting each rather than dropping it silently. A skip is not a warning and is not reported as one, so this is the only way to ask whether anything in the file was left out. A product built from samples has skipped nothing.

tec_grid_samples(handle)

@spec tec_grid_samples(reference()) ::
  {:ok, Sidereon.GNSS.Ionosphere.TecGridSamples.t()} | {:error, term()}

Extract a product handle as whole-grid TEC samples.

This is the inverse of from_samples/1: rebuilding from the returned samples reproduces every stored float and epoch. Axes are degrees, shell and base radii are kilometers, TEC and RMS grids are TECU and height grids are kilometers.

tec_samples(handle)

@spec tec_samples(reference()) ::
  {:ok, [Sidereon.GNSS.Ionosphere.TecSample.t()]} | {:error, term()}

Extract a product handle as one TEC sample per grid node.

Samples come back in [map][latitude][longitude] order. Each carries its own epoch, latitude and longitude in degrees, vertical TEC and RMS in TECU, and a height offset in kilometers, with nil for any value the node does not have.