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