# `Sidereon.GNSS.ReducedOrbit.Piecewise`
[🔗](https://github.com/neilberkman/sidereon-ex/blob/main/lib/sidereon/gnss/reduced_orbit/piecewise.ex#L1)

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

A single `Sidereon.GNSS.ReducedOrbit` distills a whole track into one set of mean
elements. A `Piecewise` model instead splits the span `[t0, t1]` into
contiguous segments of `:segment_s` seconds and fits each segment. Every query
lands inside one fit window, so residuals are controlled by the samples in that
segment. The orbit math, frames, and time scales are unchanged.

This trades stored segment count for residual control. It is **not** orbit
determination and **not** a substitute for source ephemeris data. Measure the
residual with `drift/3` against caller-provided truth samples.

## Models

Each segment is one of the two `Sidereon.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 `Sidereon.GNSS.ReducedOrbit` for the per-model details.

## Size / residual tradeoff

Storage grows roughly linearly with segment count. Shorter segments cost more
memory but keep queries closer to a fit window center.

## 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}`.

# `epoch`

```elixir
@type epoch() :: Sidereon.GNSS.ReducedOrbit.epoch()
```

# `segment`

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

# `t`

```elixir
@type t() :: %Sidereon.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()}
}
```

# `drift`

```elixir
@spec drift(t(), [{epoch(), {number(), number(), number()}}], keyword()) ::
  {:ok, map()} | {:error, term()}
```

Evaluate the piecewise model error against truth samples.

Returns

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

matching the single-segment `Sidereon.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`

```elixir
@spec fit(
  [{epoch(), {number(), number(), number()}}],
  keyword()
) :: {:ok, t()} | {:error, term()}
```

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

Accepts a list of `{epoch, {x_m, y_m, z_m}}` ECEF samples.

## 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`)
  * `:model` - `:circular_secular` (**default**) or `:eccentric_secular`
  * `:time_scale` - the scale the sample 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, %Sidereon.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`, `{: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}}`.

# `position`

```elixir
@spec position(t(), epoch(), keyword()) ::
  {:ok, Sidereon.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
`Sidereon.GNSS.ReducedOrbit.position/3`. Pass `frame: :gcrs` for the inertial position.
An epoch outside the full span returns `{:error, :out_of_range}`.

# `position_velocity`

```elixir
@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
`Sidereon.GNSS.ReducedOrbit.position_velocity/3`. An epoch outside the full span
returns `{:error, :out_of_range}`.

# `select_segment`

```elixir
@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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
