# `AstroUtils.CircularStats`
[🔗](https://github.com/jakedjohnson/astro_utils/blob/v0.1.0/lib/astro_utils/circular_stats.ex#L1)

Circular mean and minimal covering arc for sets of angles on a circle.

Ordinary averaging breaks down on a circle: the arithmetic mean of 350° and
10° is 180°, the exact opposite of the answer you want. These functions work
on the circle, so the same pair averages to 0°.

Inputs are degrees and need not be normalized; each function normalizes to
`[0, 360)` internally. Both functions require a non-empty list and raise
`FunctionClauseError` otherwise. `circular_mean/1` returns `:undefined` for
sample sets that have no mean direction.

    iex> AstroUtils.CircularStats.circular_mean([350.0, 10.0])
    0.0

# `arc`

```elixir
@type arc() :: {:arc, degree(), degree()}
```

A covering arc: `{:arc, extent_degrees, start_degrees}`.

Sweeping `extent_degrees` from `start_degrees` in the direction of
increasing longitude covers every input sample.

# `degree`

```elixir
@type degree() :: float()
```

An angle in degrees.

# `circular_mean`

```elixir
@spec circular_mean([degree()]) :: degree() | :undefined
```

Mean direction of a list of angles, returned in `[0, 360)`.

The mean is always the direction of the resultant vector `Σ(cos θ, sin θ)`,
so samples are weighted by direction rather than by count. A lopsided set
inside a half-circle such as `[0.0, 10.0, 170.0]` therefore means to about
`19.15°`, not to the arithmetic `60.0`.

Returns `:undefined` when the resultant vector vanishes and no mean
direction exists — antipodal pairs such as `[90.0, 270.0]`, or evenly
spread samples such as `[0.0, 120.0, 240.0]`. Callers must handle this
case; it is deliberately not a number.

Raises `FunctionClauseError` on an empty list.

## Examples

    iex> AstroUtils.CircularStats.circular_mean([350.0, 10.0])
    0.0

    iex> AstroUtils.CircularStats.circular_mean([0.0, 10.0, 170.0]) |> Float.round(4)
    19.1519

    iex> AstroUtils.CircularStats.circular_mean([90.0, 270.0])
    :undefined

# `minimal_covering_arc`

```elixir
@spec minimal_covering_arc([degree()]) :: arc()
```

Shortest arc that contains every sample.

Found by locating the largest empty gap between consecutive samples around
the circle; the covering arc is the complement of that gap. Returns
`{:arc, extent_degrees, start_degrees}`, where `start_degrees` is the sample
just after the largest gap. If several gaps tie for largest, the smallest
candidate start is used.

A single sample, or repeats of one value, gives an arc of extent `0.0`.

Raises `FunctionClauseError` on an empty list.

## Examples

    iex> AstroUtils.CircularStats.minimal_covering_arc([5.0, 15.0, 25.0])
    {:arc, 20.0, 5.0}

    iex> AstroUtils.CircularStats.minimal_covering_arc([350.0, 10.0, 20.0])
    {:arc, 30.0, 350.0}

    iex> AstroUtils.CircularStats.minimal_covering_arc([180.0])
    {:arc, 0.0, 180.0}

---

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