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.
86 lines
2.7 KiB
Elixir
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
|