prop/lib/microwaveprop/rover/fixed_station.ex

137 lines
3.9 KiB
Elixir

defmodule Microwaveprop.Rover.FixedStation do
@moduledoc """
A fixed station owned by a user. Used as a known endpoint for the rover
planner — the rover predicts SNR margins from each candidate cell back
to the selected fixed stations.
Either an explicit `lat`/`lon` pair or a Maidenhead `grid` is required;
when only `grid` is provided the changeset derives the centre lat/lon
via `Microwaveprop.Radio.Maidenhead.to_latlon/1`. Elevation is *not*
filled in by the changeset — `Microwaveprop.Workers.StationElevationWorker`
enriches it after insert.
"""
use Ecto.Schema
import Ecto.Changeset
alias Microwaveprop.Radio.Maidenhead
@callsign_regex ~r/^[A-Z0-9\/]{3,12}$/
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "fixed_stations" do
field :callsign, :string
field :grid, :string
field :lat, :float
field :lon, :float
field :elevation_m, :integer
field :selected, :boolean, default: true
field :position, :integer, default: 0
belongs_to :user, Microwaveprop.Accounts.User
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(station, attrs) do
station
|> cast(attrs, [:callsign, :grid, :lat, :lon, :elevation_m, :selected, :position])
|> normalize_callsign()
|> normalize_grid()
|> validate_required([:callsign])
|> validate_format(:callsign, @callsign_regex)
|> validate_grid_format()
|> derive_latlon_from_grid()
|> validate_required([:lat, :lon])
|> validate_number(:lat, greater_than_or_equal_to: -90.0, less_than_or_equal_to: 90.0)
|> validate_number(:lon, greater_than_or_equal_to: -180.0, less_than_or_equal_to: 180.0)
|> unique_constraint(:callsign, name: :fixed_stations_user_id_callsign_index)
end
defp normalize_callsign(changeset) do
case get_change(changeset, :callsign) do
nil ->
changeset
call when is_binary(call) ->
put_change(changeset, :callsign, call |> String.trim() |> String.upcase())
end
end
defp normalize_grid(changeset) do
case get_change(changeset, :grid) do
nil -> changeset
"" -> put_change(changeset, :grid, nil)
grid when is_binary(grid) -> put_change(changeset, :grid, normalize_grid_string(grid))
end
end
# Field + Square uppercase, subsquare lowercase, extended square uppercase.
# Maidenhead convention: A-R / 0-9 / a-x / 0-9 / A-X / ...
defp normalize_grid_string(grid) do
grid
|> String.trim()
|> String.graphemes()
|> Enum.with_index()
|> Enum.map_join(fn {ch, idx} ->
pair = div(idx, 2)
letter_pair_index = div(pair, 2)
cond do
# Pair 0 (Field, letters) + Pair 1 (Square, digits) +
# Pair 2 (Subsquare, letters) + Pair 3 (Extended, digits) ...
# Letter pairs alternate uppercase / lowercase / uppercase ...
rem(pair, 2) == 1 -> ch
rem(letter_pair_index, 2) == 0 -> String.upcase(ch)
true -> String.downcase(ch)
end
end)
end
defp validate_grid_format(changeset) do
case get_field(changeset, :grid) do
nil ->
changeset
grid ->
if Maidenhead.valid?(grid) do
changeset
else
add_error(changeset, :grid, "is not a valid Maidenhead grid")
end
end
end
defp derive_latlon_from_grid(changeset) do
lat = get_field(changeset, :lat)
lon = get_field(changeset, :lon)
grid = get_field(changeset, :grid)
cond do
not changeset.valid? ->
changeset
is_number(lat) and is_number(lon) ->
changeset
is_binary(grid) ->
case Maidenhead.to_latlon(grid) do
{:ok, {derived_lat, derived_lon}} ->
changeset
|> put_change(:lat, derived_lat)
|> put_change(:lon, derived_lon)
:error ->
changeset
end
true ->
changeset
end
end
end