Orbis.GNSS.ReducedOrbit.Piecewise (Orbis v0.9.0)

Copy Markdown View Source

A long position track approximated by a sequence of contiguous, independently fitted Orbis.GNSS.ReducedOrbit segments.

A single Orbis.GNSS.ReducedOrbit distills a whole track into one set of mean elements; extrapolated over a day it drifts (GPS ~thousands of km with the circular model, ~8 km eccentric). A Piecewise model instead splits the span [t0, t1] into contiguous segments of :segment_s seconds and fits each one with the existing Orbis.GNSS.ReducedOrbit.fit/2. Every query then lands inside a fit window, so the error collapses to the in-window residual (sub-km to a few km) rather than the extrapolation error — at the cost of storing N small models. It is pure orchestration over the single-segment primitives; the orbit math, frames, and time scales are unchanged.

This is the best accuracy-per-byte option for caching and transport. It is not orbit determination and not a substitute for SP3 or SGP4 — it is a compact approximation, honest about its residual through a source-backed drift/3.

Models

Each segment is one of the two Orbis.GNSS.ReducedOrbit models, selected with the same :model option:

  • :circular_secular (the default) — a circular orbit;
  • :eccentric_secular — recovers the radial a·e signal.

See Orbis.GNSS.ReducedOrbit for the per-model details.

Size / accuracy tradeoff

Storage grows ~linearly with the number of segments: a span of T seconds split at :segment_s holds ceil(T / segment_s) models, each serialized via Orbis.GNSS.ReducedOrbit.to_map/1. Shorter segments cost more bytes but keep every query closer to the centre of a fit window, shrinking the residual. The table below is the max position error over a full day (model-vs-source, measured through drift/3 against the vendored MGEX fixtures, GPST), where both the single and piecewise models are fitted over that same full day and evaluated across it. (Fitting only part of the span and extrapolating is far worse for the single model — see Orbis.GNSS.ReducedOrbit; here the single model gets its best case, the whole-span least-squares fit, and piecewise still wins.)

circular_secular

Orbit classsingle2 h pw4 h pw6 h pw
GPS, e ~ 0.024 (G21)~1 437 km~331 km~653 km~830 km
Galileo, e ~ 1e-4 (E01)~7 km~1 km~3 km~4 km
BeiDou MEO, e ~ 9e-4 (C21)~54 km~12 km~24 km~38 km
BeiDou IGSO, e ~ 5e-3 (C08)~533 km~50 km~102 km~147 km

eccentric_secular

Orbit classsingle2 h pw4 h pw6 h pw
GPS, e ~ 0.024 (G21)~440 m~90 m~280 m~310 m
Galileo, e ~ 1e-4 (E01)~780 m~90 m~260 m~380 m
BeiDou MEO, e ~ 9e-4 (C21)~430 m~120 m~330 m~420 m
BeiDou IGSO, e ~ 5e-3 (C08)~870 m~20 m~70 m~130 m

For the eccentric model the single whole-day fit is already sub-km; piecewise still cuts the max error by roughly 3× to tens of × (most cells ~3-9×, the near-circular IGSO as much as ~40×). For the circular model the unmodelled a·e radial signal dominates and piecewise's benefit is largest in absolute terms (hundreds of km off GPS/IGSO). Note the shorter the segment the smaller the residual: the 2 h split beats the 4 h and 6 h splits in every cell, the monotonic accuracy-for-bytes tradeoff.

These are characterisations, not guarantees; the exact numbers shift with the fit window, segment length, and drift cadence. Always measure a given fit with drift/3 against the source.

Segment selection

Segments tile [t0, t1] with no gaps. A query epoch is resolved by finding the segment whose half-open interval [seg_t0, seg_t1) contains it; the final segment is treated as inclusive at the very end so the exact end-of-span epoch resolves to the last segment. An epoch exactly on an interior boundary resolves to the later segment (where it is the in-window start), which is deterministic. Selection is O(segments); the segment count is modest and the ordered list is binary-searchable if it ever grows large. An epoch before t0 or after t1 returns {:error, :out_of_range}.

Persistence

to_map/1 emits a stable, versioned container (string keys, JSON-safe) holding the per-segment maps via Orbis.GNSS.ReducedOrbit.to_map/1; from_map/1 validates the version and model and reconstructs, with the same tagged-error discipline as the single model.

Summary

Functions

Evaluate the piecewise model error against the source ephemeris over the whole span.

Fit a piecewise model over a span, one contiguous Orbis.GNSS.ReducedOrbit segment per :segment_s seconds.

Reconstruct a piecewise model from a to_map/1 map. Validates the version and model id.

Position of the piecewise model at epoch, ECEF (ITRF) meters by default.

Position and velocity of the piecewise model at epoch.

Select the segment whose coverage interval contains epoch.

Serialize a piecewise model to a stable, versioned, JSON-safe map (string keys). Each segment's model is serialized via Orbis.GNSS.ReducedOrbit.to_map/1. See from_map/1 for the inverse.

Types

epoch()

@type epoch() :: Orbis.GNSS.ReducedOrbit.epoch()

segment()

@type segment() :: %{
  t0: NaiveDateTime.t(),
  t1: NaiveDateTime.t(),
  model: Orbis.GNSS.ReducedOrbit.t()
}

t()

@type t() :: %Orbis.GNSS.ReducedOrbit.Piecewise{
  frame: String.t(),
  model: String.t(),
  segment_s: number(),
  segments: [segment()],
  time_scale: String.t(),
  version: pos_integer(),
  window: {NaiveDateTime.t(), NaiveDateTime.t()}
}

Functions

drift(pw, sp3, opts)

@spec drift(
  t(),
  Orbis.GNSS.SP3.t() | [{epoch(), {number(), number(), number()}}],
  keyword()
) ::
  {:ok, map()} | {:error, term()}

Evaluate the piecewise model error against the source ephemeris over the whole span.

Samples the source across the span and compares each truth sample to the covering segment's ECEF position (the single-segment drift NIF is per-model, so the piecewise report is composed in Elixir from position/3). For an Orbis.GNSS.SP3 source it samples over :window (defaulting to the model's full span) at :cadence_s for :satellite_id; for a sample list it uses those directly. Returns

{:ok, %{per_epoch: [%{epoch:, error_m:}], max_m:, rms_m:, threshold_horizon:,
        requested:, used:}}

matching the single-segment Orbis.GNSS.ReducedOrbit.drift/3 report. Epochs outside the model's span are skipped (counted in requested, not used). threshold_horizon is the first epoch the ECEF error exceeds :threshold_m (or nil).

fit(source, opts \\ [])

@spec fit(
  Orbis.GNSS.SP3.t() | [{epoch(), {number(), number(), number()}}],
  keyword()
) :: {:ok, t()} | {:error, term()}

Fit a piecewise model over a span, one contiguous Orbis.GNSS.ReducedOrbit segment per :segment_s seconds.

Sources

  • an Orbis.GNSS.SP3 handle — requires :satellite_id and :window; each segment is fitted with Orbis.GNSS.ReducedOrbit.fit/2 over its sub-window at :cadence_s;
  • a list of {epoch, {x_m, y_m, z_m}} ECEF samples — partitioned by segment interval, each sublist fitted directly.

Options

  • :window{t0, t1} epochs bounding the full span (t1 strictly after t0, else :invalid_window)
  • :segment_s — positive segment length in seconds, e.g. 7200 (non-positive → :invalid_segment)
  • :cadence_s — positive SP3 sampling step in seconds
  • :satellite_id — e.g. "G05" (SP3 source)
  • :model:circular_secular (default) or :eccentric_secular
  • :time_scale — for the sample-list source, the scale its epochs are in

Segments are contiguous (seg_t1 of one is seg_t0 of the next); the final segment may be shorter. A :segment_s at least the full span yields a single segment equal to the whole window (piecewise with one segment ≡ single).

Returns {:ok, %Orbis.GNSS.ReducedOrbit.Piecewise{}} or a tagged error. The error set is exactly the single model's fit errors ({:too_few_samples, got, req}, :invalid_window, :invalid_cadence, :satellite_id_required, {:unsupported_model, m}, {:unsupported_source_frame, f}, {:unsupported_time_scale, s}, :transform_unavailable, …) plus :invalid_segment. A too-few-samples failure on a non-terminal segment is surfaced (a genuinely under-covered interior span is an error, not a silent hole); only the terminal short segment may be dropped. If nothing fits at all, {:error, {:too_few_samples, 0, 4}}.

from_map(map)

@spec from_map(map()) :: {:ok, t()} | {:error, term()}

Reconstruct a piecewise model from a to_map/1 map. Validates the version and model id.

Returns {:ok, %Orbis.GNSS.ReducedOrbit.Piecewise{}} or {:error, {:unsupported_version, v}} / {:error, {:unsupported_model, m}} / {:error, :malformed_map}. A segment whose inner model fails from_map, or whose model id differs from the container, makes the whole map malformed — never a raise, never a nil-filled struct.

position(pw, epoch, opts \\ [])

@spec position(t(), epoch(), keyword()) ::
  {:ok, Orbis.GNSS.ReducedOrbit.vec3()} | {:error, term()}

Position of the piecewise model at epoch, ECEF (ITRF) meters by default.

Selects the segment covering epoch and delegates to Orbis.GNSS.ReducedOrbit.position/3. Pass frame: :gcrs for the inertial position. An epoch outside the full span returns {:error, :out_of_range}.

position_velocity(pw, epoch, opts \\ [])

@spec position_velocity(t(), epoch(), keyword()) :: {:ok, map()} | {:error, term()}

Position and velocity of the piecewise model at epoch.

Selects the covering segment and delegates to Orbis.GNSS.ReducedOrbit.position_velocity/3. An epoch outside the full span returns {:error, :out_of_range}.

select_segment(piecewise, epoch)

@spec select_segment(t(), epoch()) :: {:ok, segment()} | {:error, term()}

Select the segment whose coverage interval contains epoch.

Returns {:ok, segment} or {:error, :out_of_range}. Interior boundaries resolve to the later segment; the exact end-of-span epoch resolves to the last segment.

to_map(pw)

@spec to_map(t()) :: map()

Serialize a piecewise model to a stable, versioned, JSON-safe map (string keys). Each segment's model is serialized via Orbis.GNSS.ReducedOrbit.to_map/1. See from_map/1 for the inverse.