prop/lib/microwaveprop/rover_planning/station.ex
Graham McIntire a4f0e171e8
feat(rover-planning): worker pre-computes full /path output, PathLive renders cached
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.
2026-05-03 14:58:16 -05:00

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