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

Manage and propagate satellite constellations.

Build a constellation from parsed TLEs and propagate all satellites to a
given time. Useful for visibility computations over a shared fleet.

## Examples

    constellation = Sidereon.Constellation.from_tles("custom", tles)
    constellation.count

    # Propagate all satellites to now
    positions = Sidereon.Constellation.propagate_all(constellation, DateTime.utc_now())
    Enum.each(positions, fn {norad_id, pos} ->
      IO.puts("#{norad_id}: #{inspect(pos)}")
    end)

    # Find visible satellites from a ground station
    {:ok, visible} = Sidereon.Constellation.visible_from(constellation, station, datetime)

# `batch_error`

```elixir
@type batch_error() ::
  {:invalid_satellites, invalid_satellites()}
  | {:invalid_option, term()}
  | {:invalid_field, atom(), term()}
  | {:nif_error, String.t()}
```

# `fleet_pass`

```elixir
@type fleet_pass() :: %{
  satellite_index: non_neg_integer(),
  catalog_number: String.t(),
  pass: Sidereon.Pass.t()
}
```

# `invalid_satellites`

```elixir
@type invalid_satellites() :: [{String.t() | nil, {:error, term()}}]
```

# `t`

```elixir
@type t() :: %Sidereon.Constellation{
  count: non_neg_integer(),
  name: String.t(),
  satellites: [Sidereon.Elements.t()]
}
```

# `visible_error`

```elixir
@type visible_error() ::
  {:invalid_satellites, invalid_satellites()} | {:nif_error, String.t()}
```

# `visible_satellite`

```elixir
@type visible_satellite() :: %{
  catalog_number: String.t(),
  elevation: float(),
  azimuth: float(),
  range_km: float(),
  position: {float(), float(), float()}
}
```

# `from_tles`

```elixir
@spec from_tles(String.t(), [Sidereon.Elements.t()]) :: t()
```

Create a constellation from a list of TLEs.

## Examples

    constellation = Sidereon.Constellation.from_tles("custom", tles)

# `ground_tracks`

```elixir
@spec ground_tracks(t(), [DateTime.t()], keyword()) ::
  {:ok, [[Sidereon.Geodetic.t()]]} | {:error, batch_error()}
```

Compute WGS84 sub-satellite ground tracks for every satellite over a shared
epoch grid.

Returns one track per satellite in fleet order: element `i` is a list of
`%Sidereon.Geodetic{}` (latitude/longitude in degrees, ellipsoidal altitude in
km) for `satellites[i]`, one per datetime. Each point is reduced
TEME -> GCRS -> ITRS -> WGS84 geodetic by the engine's transforms. A satellite
whose element set fails SGP4 initialization yields an empty track, keeping the
result index-aligned. This is the batched companion to `Sidereon.ground_track/3`.

## Options

  * `:opsmode` - SGP4 operation mode, `:afspc` (default) or `:improved`.

## Examples

    times = for s <- 0..600//60, do: DateTime.add(DateTime.utc_now(), s, :second)
    {:ok, tracks} = Sidereon.Constellation.ground_tracks(constellation, times)
    hd(tracks) |> hd() |> Map.get(:latitude)

# `look_angle_arcs`

```elixir
@spec look_angle_arcs(t(), map(), [DateTime.t()], keyword()) ::
  {:ok, [[Sidereon.LookAngle.t()]]} | {:error, batch_error()}
```

Compute topocentric look-angle arcs from a ground station for every satellite
over a shared epoch grid.

Returns one arc per satellite in fleet order (the constellation's
`satellites` order): element `i` is a list of `%Sidereon.LookAngle{}` for
`satellites[i]`, one per datetime. A satellite whose element set fails SGP4
initialization yields an empty arc, so the result stays index-aligned with the
constellation. This is the batched companion to `Sidereon.look_angle/4`.

## Options

  * `:opsmode` - SGP4 operation mode, `:afspc` (default) or `:improved`. Each
    satellite is built with this opsmode, so the arcs are consistent with
    `visible_from/4` and `passes/5` computed under the same opsmode.

## Examples

    times = for s <- 0..600//60, do: DateTime.add(DateTime.utc_now(), s, :second)
    {:ok, arcs} = Sidereon.Constellation.look_angle_arcs(constellation, station, times)
    hd(arcs) |> hd() |> Map.get(:elevation)

# `passes`

```elixir
@spec passes(t(), map(), DateTime.t(), DateTime.t(), keyword()) ::
  {:ok, [fleet_pass()]} | {:error, batch_error()}
```

Predict passes over a ground station for every satellite within a time window.

Returns a flat list of passes across the whole constellation, each tagged with
the fleet-order `:satellite_index` and the `:catalog_number` of the satellite
it belongs to:

    {:ok, [%{
      satellite_index: non_neg_integer(),
      catalog_number: String.t(),
      pass: %Sidereon.Pass{}
    }]}

Passes are emitted satellite by satellite in fleet order, each satellite's
passes ordered by rise time. A satellite whose element set fails SGP4
initialization contributes no passes (its fleet index is still consumed, so
indices match the constellation order). This is the constellation companion to
`Sidereon.Passes.predict/5`.

## Options

  * `:min_elevation` - minimum peak elevation in degrees to keep a pass
    (default `0.0`); like `Sidereon.Passes.predict/5`, rise/set always
    reference the 0-degree horizon
  * `:step_seconds` - coarse propagation step in seconds (default `60`)
  * `:opsmode` - SGP4 operation mode, `:afspc` (default) or `:improved`

## Examples

    start_dt = DateTime.utc_now()
    end_dt = DateTime.add(start_dt, 86_400, :second)
    {:ok, passes} = Sidereon.Constellation.passes(constellation, station, start_dt, end_dt)
    hd(passes).satellite_index

# `propagate_all`

```elixir
@spec propagate_all(t(), DateTime.t()) :: [
  {String.t() | nil, {:ok, Sidereon.TemeState.t()} | {:error, term()}}
]
```

Propagate all satellites to a given time.

Returns a list of `{catalog_number, {:ok, teme_state}}` or
`{catalog_number, {:error, reason}}` tuples.

## Examples

    results = Sidereon.Constellation.propagate_all(constellation, ~U[2024-07-04 00:00:00Z])
    for {id, {:ok, teme}} <- results do
      IO.puts("#{id}: #{inspect(teme.position)}")
    end

# `visible_from`

```elixir
@spec visible_from(t(), map(), DateTime.t(), keyword()) ::
  {:ok, [visible_satellite()]} | {:error, visible_error()}
```

Find satellites visible from a ground station at a given time.

Returns satellites above `min_elevation` degrees, sorted by elevation
(highest first).

## Options

  * `:min_elevation` - minimum elevation in degrees (default: 10.0)
  * `:opsmode` - SGP4 operation mode, `:afspc` (default) or `:improved`. Each
    satellite is built with this opsmode, so visibility is consistent with the
    look angle and passes computed under the same opsmode.

## Examples

    {:ok, visible} = Sidereon.Constellation.visible_from(constellation, station, datetime)
    for sat <- visible do
      IO.puts("#{sat.catalog_number}: el=#{sat.elevation}° range=#{sat.range_km} km")
    end

---

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