feat(rover): FixedStation schema with grid-derived lat/lon
This commit is contained in:
parent
b6fdfdf639
commit
21d3adfd2b
3 changed files with 236 additions and 0 deletions
137
lib/microwaveprop/rover/fixed_station.ex
Normal file
137
lib/microwaveprop/rover/fixed_station.ex
Normal file
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
75
test/microwaveprop/rover/fixed_station_test.exs
Normal file
75
test/microwaveprop/rover/fixed_station_test.exs
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Reference in a new issue