Three connected changes: 1) Extract PathLive.compute_path/4 + every helper it owned (resolve, profile grid lookup, sounding/ionosphere readouts, scoring, loss / power budgets) into Microwaveprop.Propagation.PathCompute. PathLive now delegates; ~410 lines of dead helpers deleted from PathLive. 2) RoverPathProfileWorker calls PathCompute.compute/4 with the mission's heights and PathLive's default station params (10 W TX, 30 dBi gains). Stores the full atom-keyed compute output as a Base64-encoded :erlang.term_to_binary/1 blob alongside the flat summary fields the rover-planning show table reads. The blob roundtrips structs and DateTime exactly (Jason.encode would lose them). 3) PathLive accepts ?rover_path_id=UUID. When set, loads the cached Path, decodes the term (binary_to_term :safe), assigns it as @result, and renders normally — no compute_path call. The rover-planning show table now links rows directly to /path?rover_path_id=UUID, so a click opens the full Path Calculator UI from cached data without re-running terrain / HRRR / sounding lookups. Bonus prod fixes folded in: - PathShow's elevation chart attribute (data-* → data-profile JSON) was crashing the JS hook with 'unexpected character at line 1'. - Station.changeset now wipes previously-resolved callsign/grid/ lat/lon when the user types into :input — typing 'AA5' early resolved to a wrong location and locked it; subsequent keystrokes never re-resolved. - phx-debounce=600 on the station input so QRZ doesn't get hit on every keystroke.
110 lines
3.3 KiB
Elixir
110 lines
3.3 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. If the user TYPED a new input (changeset has a
|
|
# `:input` change), wipe any previously-resolved callsign / grid /
|
|
# lat / lon first — otherwise an early prefix that resolved
|
|
# successfully (e.g. "AA5" matched a callsign) would lock those
|
|
# fields and the user's later keystrokes would never re-resolve.
|
|
defp resolve_input(changeset) do
|
|
cond do
|
|
not changeset.valid? ->
|
|
changeset
|
|
|
|
input_changed?(changeset) ->
|
|
changeset
|
|
|> clear_resolved()
|
|
|> resolve_from_input()
|
|
|
|
get_field(changeset, :lat) && get_field(changeset, :lon) ->
|
|
changeset
|
|
|
|
true ->
|
|
resolve_from_input(changeset)
|
|
end
|
|
end
|
|
|
|
defp input_changed?(changeset), do: not is_nil(get_change(changeset, :input))
|
|
|
|
defp clear_resolved(changeset) do
|
|
changeset
|
|
|> put_change(:callsign, nil)
|
|
|> put_change(:grid, nil)
|
|
|> put_change(:lat, nil)
|
|
|> put_change(:lon, nil)
|
|
end
|
|
|
|
defp resolve_from_input(changeset) do
|
|
case get_field(changeset, :input) do
|
|
input when is_binary(input) and input != "" ->
|
|
apply_resolution(changeset, LocationResolver.resolve(String.trim(input)))
|
|
|
|
_ ->
|
|
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
|