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

GNSS constellation identity catalogs and validation helpers.

This module is a data/catalog layer: it builds normalized satellite identity
records from public sources and compares those records with GNSS products. It
does not alter positioning solves or infer application-specific health rules.

The identity, parsing, validation, and diff logic lives in the `sidereon_core`
`constellation` module (the shared Rust core), reached through the NIF. The
core covers all five GNSS constellations: **GPS, Galileo, GLONASS, BeiDou, and
QZSS**, so this module is multi-system: `from_celestrak_omm/2` dispatches on
the constellation atom (`:gps`, `:galileo`, `:glonass`, `:beidou`, `:qzss`) to
the per-system identity adapter in the core.

OMM/JSON source records are parsed from `OBJECT_NAME` and rendered as the
SP3/RINEX id (`"G13"`, `"E07"`, `"R13"`, `"C19"`, `"J02"`). GPS constellation
status HTML can be parsed and merged as an optional SVN/usability overlay.

## Examples

    iex> omms = [
    ...>   %{"OBJECT_NAME" => "GPS BIIF-8  (PRN 03)", "NORAD_CAT_ID" => 40294}
    ...> ]
    iex> {:ok, [record]} = Sidereon.GNSS.Constellation.from_celestrak_omm(:gps, omms)
    iex> {record.system, record.prn, record.norad_id, record.sp3_id}
    {:gps, 3, 40294, "G03"}

    iex> record = %Sidereon.GNSS.Constellation.Record{
    ...>   system: :gps,
    ...>   prn: 3,
    ...>   svn: nil,
    ...>   norad_id: 40294,
    ...>   sp3_id: "G03",
    ...>   active?: true,
    ...>   usable?: true,
    ...>   source: %{}
    ...> }
    iex> Sidereon.GNSS.Constellation.to_csv([record])
    "prn,norad_cat_id,active,sp3_id\n3,40294,true,G03\n"

Use `from_celestrak_omm/2`, `from_celestrak_omm_lenient/2`,
`parse_navcen_html/1`, and `merge_navcen/2` with caller-provided text or
decoded maps.

# `error`

```elixir
@type error() ::
  {:error, {:unsupported_system, term()}}
  | {:error, {:bad_celestrak_record, term(), map()}}
  | {:error, {:invalid_input, term()}}
  | {:error, {:bad_navcen_html, term()}}
  | {:error, term()}
```

# `system`

```elixir
@type system() :: :gps | :glonass | :galileo | :beidou | :qzss
```

# `changed?`

```elixir
@spec changed?(Sidereon.GNSS.Constellation.Diff.t()) :: boolean()
```

Returns `true` when a constellation diff has any findings.

# `diff`

```elixir
@spec diff([Sidereon.GNSS.Constellation.Record.t()], [
  Sidereon.GNSS.Constellation.Record.t()
]) ::
  Sidereon.GNSS.Constellation.Diff.t()
```

Compare two constellation catalog snapshots by `{system, prn}` identity.

The result separates added/removed PRNs from identity/status changes on a PRN
that exists in both snapshots. Assumes each input has at most one record per
`{system, prn}`; run `validate/1` first on hand-edited catalogs.

    iex> previous = [
    ...>   %Sidereon.GNSS.Constellation.Record{
    ...>     system: :gps, prn: 3, svn: 69, norad_id: 40294, sp3_id: "G03",
    ...>     active?: true, usable?: true, source: %{}
    ...>   }
    ...> ]
    iex> current = [%{hd(previous) | norad_id: 99999, active?: false}]
    iex> diff = Sidereon.GNSS.Constellation.diff(previous, current)
    iex> [norad] = diff.norad_reassigned
    iex> {norad.system, norad.prn, norad.from, norad.to}
    {:gps, 3, 40294, 99999}
    iex> [activity] = diff.activity_changed
    iex> {activity.system, activity.prn, activity.from, activity.to}
    {:gps, 3, true, false}
    iex> Sidereon.GNSS.Constellation.changed?(diff)
    true

# `from_celestrak_json`

```elixir
@spec from_celestrak_json(String.t(), system()) ::
  {:ok, [Sidereon.GNSS.Constellation.Record.t()]} | error()
```

Build records for `system` from a JSON OMM feed.

This is the string-input sibling of `from_celestrak_omm/2`; decoded entries
are still resolved by the shared Rust core.

# `from_celestrak_json_lenient`

```elixir
@spec from_celestrak_json_lenient(String.t(), system()) ::
  {:ok, Sidereon.GNSS.Constellation.Catalog.t()} | error()
```

Build a lenient catalog from a JSON OMM feed.

This is the string-input sibling of `from_celestrak_omm_lenient/2`.

# `from_celestrak_omm`

```elixir
@spec from_celestrak_omm(system(), [map()]) ::
  {:ok, [Sidereon.GNSS.Constellation.Record.t()]} | error()
```

Build records for `system` from CelesTrak OMM/JSON maps, failing on the first
entry whose `OBJECT_NAME` does not resolve to a PRN for `system`.

CelesTrak does not publish SVN, so records built from this source alone have
`svn: nil`. Records are returned sorted by `{system, prn}`.

# `from_celestrak_omm_lenient`

```elixir
@spec from_celestrak_omm_lenient(String.t()) ::
  {:ok, Sidereon.GNSS.Constellation.Catalog.t()} | error()
```

Build records for `system` from CelesTrak OMM/JSON maps, skipping (rather than
aborting on) entries whose `OBJECT_NAME` does not resolve to a PRN for
`system`.

The lenient sibling of `from_celestrak_omm/2`: every OMM that resolves becomes
a `Record`; every entry that does not (an other-constellation satellite in a
combined `gnss` feed, or a freshly launched vehicle whose name does not yet
resolve) is collected into `Catalog.skipped` with its identity. Resolvable
records are returned sorted by `{system, prn}`.

Leniency covers identity resolution only: an entry missing a valid
`NORAD_CAT_ID` still aborts with `{:error, reason}`.

## Examples

    iex> omms = [
    ...>   %{"OBJECT_NAME" => "GPS BIIF-8  (PRN 03)", "NORAD_CAT_ID" => 40294},
    ...>   %{"OBJECT_NAME" => "GSAT0210 (GALILEO 13)", "NORAD_CAT_ID" => 41859}
    ...> ]
    iex> {:ok, catalog} = Sidereon.GNSS.Constellation.from_celestrak_omm_lenient(:gps, omms)
    iex> Enum.map(catalog.records, & &1.sp3_id)
    ["G03"]
    iex> Enum.map(catalog.skipped, &{&1.object_name, &1.norad_id})
    [{"GSAT0210 (GALILEO 13)", 41859}]

# `from_celestrak_omm_lenient`

```elixir
@spec from_celestrak_omm_lenient(String.t(), system()) ::
  {:ok, Sidereon.GNSS.Constellation.Catalog.t()} | error()
@spec from_celestrak_omm_lenient(system(), [map()]) ::
  {:ok, Sidereon.GNSS.Constellation.Catalog.t()} | error()
```

# `galileo_prn_for_gsat`

```elixir
@spec galileo_prn_for_gsat(integer()) :: integer() | nil
```

Resolve a Galileo `GSATdddd` build id to its SVID/PRN (E-number), or `nil` when
no SVID is assigned yet.

    iex> Sidereon.GNSS.Constellation.galileo_prn_for_gsat(210)
    1

# `glonass_fdma_channel`

```elixir
@spec glonass_fdma_channel(integer()) :: integer() | nil
```

Resolve a GLONASS orbital slot (`1..24`) to its FDMA L1/L2 frequency-channel
number `k`, or `nil` for a slot outside the operational range.

    iex> Sidereon.GNSS.Constellation.glonass_fdma_channel(1)
    1
    iex> Sidereon.GNSS.Constellation.glonass_fdma_channel(2)
    -4
    iex> Sidereon.GNSS.Constellation.glonass_fdma_channel(0)
    nil

# `glonass_slot_for_number`

```elixir
@spec glonass_slot_for_number(integer()) :: integer() | nil
```

Resolve a GLONASS (Uragan) number to its orbital slot (`1..24`), or `nil` when
the number is not in the published constellation table.

    iex> Sidereon.GNSS.Constellation.glonass_slot_for_number(730)
    1

# `merge_navcen`

```elixir
@spec merge_navcen([Sidereon.GNSS.Constellation.Record.t()], [
  Sidereon.GNSS.Constellation.NavcenStatus.t()
]) :: [Sidereon.GNSS.Constellation.Record.t()]
```

Merge NAVCEN status rows into normalized records by PRN.

NAVCEN does not publish NORAD ids, so CelesTrak stays the identity base. When a
PRN is present in both sources and the block types are compatible, this fills
`svn`, updates `usable?`, and records the NAVCEN provenance under
`source.navcen`. A NAVCEN row that matches the PRN but carries an incompatible
block type (a PRN transition) is recorded under `source.navcen_conflict`.

# `merge_navcen_at`

```elixir
@spec merge_navcen_at([Sidereon.GNSS.Constellation.Record.t()], [
  Sidereon.GNSS.Constellation.NavcenAssessment.t()
]) :: [Sidereon.GNSS.Constellation.Record.t()]
```

Merge explicitly timed NAVCEN assessments into normalized records.

The merged usability is the result computed for each assessment's
`evaluated_at_utc`; the full assessments remain available to the caller for
audit and interval display.

# `parse_navcen_html`

```elixir
@spec parse_navcen_html(String.t()) ::
  {:ok, [Sidereon.GNSS.Constellation.NavcenStatus.t()]} | error()
```

Parse NAVCEN's GPS constellation status HTML.

Returns status rows keyed by PRN/SVN; merge them into CelesTrak records with
`merge_navcen/2`.

# `parse_navcen_html_at`

```elixir
@spec parse_navcen_html_at(String.t(), DateTime.t()) ::
  {:ok, [Sidereon.GNSS.Constellation.NavcenAssessment.t()]} | error()
```

Parse NAVCEN's GPS constellation status HTML at an explicit UTC instant.

This deterministic companion to `parse_navcen_html/1` evaluates active
bounded forecast notices only during their parsed half-open interval. It
preserves the raw NANU type, subject, outage-start cell, and either the
complete interval or an explicit `:unparseable` result. It never reads the
host clock.

Active `UNUSABLE` and `DECOM` notices retain their legacy immediate-unusable
behavior. This time-aware path additionally recognizes `UNUSUFN` as
immediately unusable; the legacy parser's pre-existing omission remains
unchanged. An ambiguous forecast is retained and conservatively leaves the
satellite usable rather than inventing a time window.

# `sp3_id`

```elixir
@spec sp3_id(system(), pos_integer()) :: String.t()
```

Render the canonical SP3/RINEX satellite token for a supported GNSS PRN.

    iex> Sidereon.GNSS.Constellation.sp3_id(:gps, 7)
    "G07"
    iex> Sidereon.GNSS.Constellation.sp3_id(:galileo, 7)
    "E07"

# `to_csv`

```elixir
@spec to_csv(
  [Sidereon.GNSS.Constellation.Record.t()],
  keyword()
) :: String.t()
```

Export records as the compact mapping CSV:

    prn,norad_cat_id,active,sp3_id

The `active` column is `true` only when `record.active?` and `record.usable?`
are both true. Records are sorted by `{system, prn}`.

## Options

  * `:booleans` - how the `active` column is rendered: `:lower` (default:
    `true`/`false`) or `:title` (`True`/`False`, for a pandas consumer).

# `valid?`

```elixir
@spec valid?(Sidereon.GNSS.Constellation.Validation.t()) :: boolean()
```

Returns `true` when a validation report has no findings.

# `validate`

```elixir
@spec validate([Sidereon.GNSS.Constellation.Record.t()]) ::
  Sidereon.GNSS.Constellation.Validation.t()
```

Validate catalog identity without an SP3 product.

Reports duplicate PRNs, duplicate NORAD ids, and PRNs that are inactive or
unusable according to the normalized records.

# `validate_sp3`

```elixir
@spec validate_sp3(
  [Sidereon.GNSS.Constellation.Record.t()],
  Sidereon.GNSS.SP3.t() | [String.t()]
) ::
  Sidereon.GNSS.Constellation.Validation.t()
```

Validate catalog identity against SP3 satellite ids.

The second argument may be a loaded `%Sidereon.GNSS.SP3{}` or a list of SP3/RINEX
satellite tokens. `missing_sp3_ids` reports active+usable catalog ids absent
from the product; `extra_sp3_ids` reports product ids absent from the
active+usable catalog, restricted to the systems the catalog covers.

# `validate_sp3!`

```elixir
@spec validate_sp3!(
  [Sidereon.GNSS.Constellation.Record.t()],
  Sidereon.GNSS.SP3.t() | [String.t()]
) ::
  :ok
```

Validate against SP3 satellite ids and raise unless the catalog is clean.

A build-time gate: returns `:ok` when the catalog has no findings, otherwise
raises `ArgumentError` describing them. Intended for catalog-build / automation
steps, not the positioning runtime.

---

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