prop/lib/microwaveprop/radio.ex
Graham McIntire 7d0978f7d5
Add /submit route for user QSO submission
Maidenhead grid module converts grid squares to lat/lon coordinates.
Submission form validates grids, bands, modes, and email, computes
positions and distance, then triggers the weather/HRRR/terrain
processing pipeline via Oban.
2026-03-29 16:46:35 -05:00

155 lines
4 KiB
Elixir

defmodule Microwaveprop.Radio do
@moduledoc false
import Ecto.Query
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Radio.Qso
alias Microwaveprop.Repo
@per_page 20
@sortable_fields ~w(station1 station2 band mode distance_km qso_timestamp)a
def list_qsos(opts \\ []) do
page = max(Keyword.get(opts, :page, 1), 1)
offset = (page - 1) * @per_page
{sort_field, sort_dir} = sort_opts(opts)
total_entries = Repo.aggregate(Qso, :count)
total_pages = max(ceil(total_entries / @per_page), 1)
entries =
Qso
|> order_by([q], [{^sort_dir, field(q, ^sort_field)}])
|> limit(^@per_page)
|> offset(^offset)
|> Repo.all()
%{
entries: entries,
page: page,
total_pages: total_pages,
total_entries: total_entries
}
end
defp sort_opts(opts) do
sort_by = Keyword.get(opts, :sort_by, :qso_timestamp)
sort_order = Keyword.get(opts, :sort_order, :desc)
field = if sort_by in @sortable_fields, do: sort_by, else: :qso_timestamp
dir = if sort_order in [:asc, :desc], do: sort_order, else: :desc
{field, dir}
end
def unprocessed_qsos(limit \\ 500) do
Qso
|> where([q], q.weather_queued == false and not is_nil(q.pos1))
|> order_by([q], asc: q.qso_timestamp)
|> limit(^limit)
|> Repo.all()
end
def mark_weather_queued!(qso_ids) do
Qso
|> where([q], q.id in ^qso_ids)
|> Repo.update_all(set: [weather_queued: true])
end
def unprocessed_hrrr_qsos(limit \\ 500) do
Qso
|> where([q], q.hrrr_queued == false and not is_nil(q.pos1))
|> order_by([q], asc: q.qso_timestamp)
|> limit(^limit)
|> Repo.all()
end
def mark_hrrr_queued!(qso_ids) do
Qso
|> where([q], q.id in ^qso_ids)
|> Repo.update_all(set: [hrrr_queued: true])
end
def unprocessed_terrain_qsos(limit \\ 500) do
Qso
|> where([q], q.terrain_queued == false and not is_nil(q.pos1) and not is_nil(q.pos2))
|> order_by([q], asc: q.qso_timestamp)
|> limit(^limit)
|> Repo.all()
end
def mark_terrain_queued!(qso_ids) do
Qso
|> where([q], q.id in ^qso_ids)
|> Repo.update_all(set: [terrain_queued: true])
end
@earth_radius_km 6371.0
def haversine_km(lat1, lon1, lat2, lon2) do
dlat = deg_to_rad(lat2 - lat1)
dlon = deg_to_rad(lon2 - lon1)
rlat1 = deg_to_rad(lat1)
rlat2 = deg_to_rad(lat2)
a =
:math.sin(dlat / 2) ** 2 +
:math.cos(rlat1) * :math.cos(rlat2) * :math.sin(dlon / 2) ** 2
2 * @earth_radius_km * :math.asin(:math.sqrt(a))
end
def backfill_distances(qsos) do
Enum.each(qsos, fn qso ->
if is_nil(qso.distance_km) && qso.pos1 && qso.pos2 do
lat1 = qso.pos1["lat"]
lon1 = qso.pos1["lon"] || qso.pos1["lng"]
lat2 = qso.pos2["lat"]
lon2 = qso.pos2["lon"] || qso.pos2["lng"]
if lat1 && lon1 && lat2 && lon2 do
dist = lat1 |> haversine_km(lon1, lat2, lon2) |> round() |> Decimal.new()
Qso
|> where([q], q.id == ^qso.id)
|> Repo.update_all(set: [distance_km: dist])
end
end
end)
end
defp deg_to_rad(deg), do: deg * :math.pi() / 180
def get_qso!(id) do
Repo.get!(Qso, id)
end
def change_qso(qso, attrs \\ %{}) do
Qso.submission_changeset(qso, attrs)
end
def create_qso(attrs) do
changeset = Qso.submission_changeset(%Qso{}, attrs)
if changeset.valid? do
grid1 = Ecto.Changeset.get_change(changeset, :grid1)
grid2 = Ecto.Changeset.get_change(changeset, :grid2)
{:ok, {lat1, lon1}} = Maidenhead.to_latlon(grid1)
{:ok, {lat2, lon2}} = Maidenhead.to_latlon(grid2)
distance = lat1 |> haversine_km(lon1, lat2, lon2) |> round() |> Decimal.new()
changeset
|> Ecto.Changeset.put_change(:pos1, %{"lat" => lat1, "lon" => lon1})
|> Ecto.Changeset.put_change(:pos2, %{"lat" => lat2, "lon" => lon2})
|> Ecto.Changeset.put_change(:distance_km, distance)
|> Ecto.Changeset.put_change(:user_submitted, true)
|> Repo.insert()
else
{:error, %{changeset | action: :insert}}
end
end
end