From 21d3adfd2b7d78de703d9607e20a63d71537888e Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 25 Apr 2026 16:15:12 -0500 Subject: [PATCH] feat(rover): FixedStation schema with grid-derived lat/lon --- lib/microwaveprop/rover/fixed_station.ex | 137 ++++++++++++++++++ .../20260425210540_create_fixed_stations.exs | 24 +++ .../rover/fixed_station_test.exs | 75 ++++++++++ 3 files changed, 236 insertions(+) create mode 100644 lib/microwaveprop/rover/fixed_station.ex create mode 100644 priv/repo/migrations/20260425210540_create_fixed_stations.exs create mode 100644 test/microwaveprop/rover/fixed_station_test.exs diff --git a/lib/microwaveprop/rover/fixed_station.ex b/lib/microwaveprop/rover/fixed_station.ex new file mode 100644 index 00000000..f01beffd --- /dev/null +++ b/lib/microwaveprop/rover/fixed_station.ex @@ -0,0 +1,137 @@ +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 diff --git a/priv/repo/migrations/20260425210540_create_fixed_stations.exs b/priv/repo/migrations/20260425210540_create_fixed_stations.exs new file mode 100644 index 00000000..60f87951 --- /dev/null +++ b/priv/repo/migrations/20260425210540_create_fixed_stations.exs @@ -0,0 +1,24 @@ +defmodule Microwaveprop.Repo.Migrations.CreateFixedStations do + use Ecto.Migration + + def change do + create table(:fixed_stations, primary_key: false) do + add :id, :binary_id, primary_key: true, null: false + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + add :callsign, :string, null: false + add :grid, :string + add :lat, :float, null: false + add :lon, :float, null: false + add :elevation_m, :integer + add :selected, :boolean, null: false, default: true + add :position, :integer, null: false, default: 0 + timestamps(type: :utc_datetime) + end + + create index(:fixed_stations, [:user_id]) + + create unique_index(:fixed_stations, [:user_id, :callsign], + name: :fixed_stations_user_id_callsign_index + ) + end +end diff --git a/test/microwaveprop/rover/fixed_station_test.exs b/test/microwaveprop/rover/fixed_station_test.exs new file mode 100644 index 00000000..a2de1480 --- /dev/null +++ b/test/microwaveprop/rover/fixed_station_test.exs @@ -0,0 +1,75 @@ +defmodule Microwaveprop.Rover.FixedStationTest do + use Microwaveprop.DataCase, async: true + + import Microwaveprop.AccountsFixtures + + alias Microwaveprop.Rover.FixedStation + + describe "changeset/2" do + test "with grid only derives lat/lon from Maidenhead" do + cs = FixedStation.changeset(%FixedStation{}, %{callsign: "W5LUA", grid: "EM13qc"}) + assert cs.valid? + lat = Ecto.Changeset.get_field(cs, :lat) + lon = Ecto.Changeset.get_field(cs, :lon) + assert_in_delta lat, 33.10, 0.05 + assert_in_delta lon, -96.625, 0.05 + end + + test "with explicit lat/lon only is valid without grid derivation" do + cs = FixedStation.changeset(%FixedStation{}, %{callsign: "W5HN", lat: 33.27, lon: -96.875}) + assert cs.valid? + assert Ecto.Changeset.get_field(cs, :lat) == 33.27 + assert Ecto.Changeset.get_field(cs, :lon) == -96.875 + assert Ecto.Changeset.get_field(cs, :grid) == nil + end + + test "with neither grid nor lat/lon is invalid" do + cs = FixedStation.changeset(%FixedStation{}, %{callsign: "N5XU"}) + refute cs.valid? + assert %{lat: _} = errors_on(cs) + end + + test "callsign normalised to uppercase + trimmed" do + cs = FixedStation.changeset(%FixedStation{}, %{callsign: " w5lua ", lat: 33.1, lon: -96.6}) + assert cs.valid? + assert Ecto.Changeset.get_field(cs, :callsign) == "W5LUA" + end + + test "grid em13QC normalised to EM13qc" do + cs = FixedStation.changeset(%FixedStation{}, %{callsign: "W5LUA", grid: "em13QC"}) + assert cs.valid? + assert Ecto.Changeset.get_field(cs, :grid) == "EM13qc" + end + + test "invalid grid ZZ99zz is rejected" do + cs = FixedStation.changeset(%FixedStation{}, %{callsign: "W5LUA", grid: "ZZ99zz"}) + refute cs.valid? + assert %{grid: _} = errors_on(cs) + end + + test "lat out of range is invalid" do + cs = FixedStation.changeset(%FixedStation{}, %{callsign: "W5LUA", lat: 100.0, lon: -96.6}) + refute cs.valid? + assert %{lat: _} = errors_on(cs) + end + + test "unique constraint on (user_id, callsign)" do + user = user_fixture() + + attrs = %{callsign: "W5LUA", lat: 33.1, lon: -96.6} + + {:ok, _} = + %FixedStation{user_id: user.id} + |> FixedStation.changeset(attrs) + |> Repo.insert() + + {:error, cs} = + %FixedStation{user_id: user.id} + |> FixedStation.changeset(attrs) + |> Repo.insert() + + refute cs.valid? + assert %{callsign: _} = errors_on(cs) + end + end +end