prop/lib/microwaveprop/rover_planning/station.ex
Graham McIntire d1a5442afb
feat(rover-planning): /rover-planning page with path-profile worker
Adds a new globally-scoped, owner-mutable rover-mission tracker:
- /rover-planning paginated table (LiveTable) with View/Edit/Delete
- /rover-planning/new + /:id/edit form: name, band, antenna heights,
  notes, "only check against known good locations" toggle (default on),
  and a dynamic list of stationary stations entered as callsigns,
  Maidenhead grids, or lat,lon pairs (Station changeset geocodes via
  LocationResolver, lat/lon stays editable after add)
- /rover-planning/:id show page renders the station list, scope, and a
  matrix of computed path profiles (distance, min clearance, diffraction,
  verdict) populated as the worker completes each pairing

After save, RoverPlanning enqueues one RoverPathProfileWorker job per
(rover-location × station) pairing. The worker mirrors PathLive's
synchronous compute (ElevationClient + TerrainAnalysis at the mission's
band + heights) and stores the result on the matching path row.
PubSub broadcast on completion lets the show page live-refresh.

Admins can edit/delete any mission; owners can edit their own.
2026-05-03 11:04:11 -05:00

86 lines
2.7 KiB
Elixir

defmodule Microwaveprop.RoverPlanning.Station do
@moduledoc """
A stationary endpoint for a rover mission. The user provides one of:
callsign, Maidenhead grid, or `lat, lon`; the changeset normalizes
the input into all three when possible (callsigns geocode via
`MicrowavepropWeb.LocationResolver`).
"""
use Ecto.Schema
import Ecto.Changeset
alias Microwaveprop.RoverPlanning.Mission
alias MicrowavepropWeb.LocationResolver
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "rover_mission_stations" do
field :callsign, :string
field :grid, :string
field :input, :string
field :lat, :float
field :lon, :float
field :position, :integer, default: 0
belongs_to :mission, Mission
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, :input, :lat, :lon, :position])
|> validate_required([:input])
|> resolve_input()
|> 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)
end
# When `input` looks like a callsign or grid, geocode it and populate
# the matching field. Skips resolution when the user already provided
# explicit lat/lon — they may want a free-form coordinate that doesn't
# round-trip through callsign lookup.
defp resolve_input(changeset) do
cond do
not changeset.valid? ->
changeset
get_field(changeset, :lat) && get_field(changeset, :lon) ->
changeset
input = get_field(changeset, :input) ->
apply_resolution(changeset, LocationResolver.resolve(String.trim(input)))
true ->
changeset
end
end
defp apply_resolution(changeset, {:ok, %{kind: :callsign} = res}) do
changeset
|> put_change(:callsign, res.label)
|> put_change(:grid, Map.get(res, :grid))
|> put_change(:lat, res.lat)
|> put_change(:lon, res.lon)
end
defp apply_resolution(changeset, {:ok, %{kind: :grid} = res}) do
changeset
|> put_change(:grid, res.label)
|> put_change(:lat, res.lat)
|> put_change(:lon, res.lon)
end
defp apply_resolution(changeset, {:ok, %{kind: :coordinates} = res}) do
changeset
|> put_change(:lat, res.lat)
|> put_change(:lon, res.lon)
end
defp apply_resolution(changeset, {:error, msg}), do: add_error(changeset, :input, msg)
defp apply_resolution(changeset, :empty), do: add_error(changeset, :input, "is required")
end