Ensure positions computed from grids before any enrichment

- Radio.ensure_positions!/1 computes pos1/pos2/distance from grids
- Called in contact show page mount and enqueue_for_contact
- Migration backfills positions for existing contacts missing them
- Migration corrects enrichment statuses based on actual data presence
- Backfill dashboard includes contacts with grids but no positions
This commit is contained in:
Graham McIntire 2026-04-02 12:28:48 -05:00
parent df27209025
commit 79af4e4959
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
6 changed files with 188 additions and 4 deletions

View file

@ -212,6 +212,54 @@ defmodule Microwaveprop.Radio do
Contact.submission_changeset(contact, attrs)
end
@doc """
Computes pos1/pos2/distance_km from grid squares if missing.
Returns the updated contact, or the original if already computed or grids are missing.
"""
def ensure_positions!(contact) do
needs_pos1 = is_nil(contact.pos1) && contact.grid1
needs_pos2 = is_nil(contact.pos2) && contact.grid2
if needs_pos1 || needs_pos2 do
pos1 = contact.pos1 || latlon_from_grid(contact.grid1)
pos2 = contact.pos2 || latlon_from_grid(contact.grid2)
distance =
if pos1 && pos2 do
haversine_km(pos1["lat"], pos1["lon"], pos2["lat"], pos2["lon"])
|> round()
|> Decimal.new()
else
contact.distance_km
end
changes =
%{}
|> then(fn c -> if pos1 && is_nil(contact.pos1), do: Map.put(c, :pos1, pos1), else: c end)
|> then(fn c -> if pos2 && is_nil(contact.pos2), do: Map.put(c, :pos2, pos2), else: c end)
|> then(fn c -> if distance && is_nil(contact.distance_km), do: Map.put(c, :distance_km, distance), else: c end)
if changes != %{} do
contact
|> Ecto.Changeset.change(changes)
|> Repo.update!()
else
contact
end
else
contact
end
end
defp latlon_from_grid(nil), do: nil
defp latlon_from_grid(grid) do
case Maidenhead.to_latlon(grid) do
{:ok, {lat, lon}} -> %{"lat" => lat, "lon" => lon}
_ -> nil
end
end
def create_contact(attrs) do
changeset = Contact.submission_changeset(%Contact{}, attrs)

View file

@ -20,6 +20,7 @@ defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorker do
Called directly from submission flow no Oban indirection.
"""
def enqueue_for_contact(contact) do
contact = Radio.ensure_positions!(contact)
weather_jobs = build_weather_jobs([contact])
hrrr_jobs = build_hrrr_jobs([contact])
terrain_jobs = build_terrain_jobs([contact])

View file

@ -74,7 +74,7 @@ defmodule MicrowavepropWeb.BackfillLive do
contacts =
from(c in Contact,
where:
not is_nil(c.pos1) and
(not is_nil(c.pos1) or not is_nil(c.grid1)) and
(c.hrrr_status in ^@enrichable or c.weather_status in ^@enrichable or
c.terrain_status in ^@enrichable or c.iemre_status in ^@enrichable),
order_by: [desc: c.qso_timestamp],
@ -82,6 +82,7 @@ defmodule MicrowavepropWeb.BackfillLive do
)
|> Repo.all()
# ensure_positions! is called inside enqueue_for_contact
Enum.each(contacts, &ContactWeatherEnqueueWorker.enqueue_for_contact/1)
length(contacts)
end

View file

@ -20,7 +20,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
@impl true
def mount(%{"id" => id}, _session, socket) do
contact = Radio.get_contact!(id)
contact = id |> Radio.get_contact!() |> Radio.ensure_positions!()
if connected?(socket) do
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "contact_enrichment:#{contact.id}")

View file

@ -0,0 +1,134 @@
defmodule Microwaveprop.Repo.Migrations.FixEnrichmentStatusFromActualData do
use Ecto.Migration
def up do
# First, backfill positions from grid squares where missing.
# Done in Elixir since Maidenhead decoding isn't available in SQL.
flush()
backfill_positions_from_grids()
# Reset all "complete" statuses to "pending", then re-mark as "complete"
# only where actual data exists. This corrects the initial migration which
# assumed queued=true meant data exists.
# Mark contacts without pos1 as unavailable for everything
execute """
UPDATE qsos SET
hrrr_status = 'unavailable',
weather_status = 'unavailable',
terrain_status = 'unavailable',
iemre_status = 'unavailable'
WHERE pos1 IS NULL
"""
# Mark contacts without pos2 as unavailable for terrain
execute """
UPDATE qsos SET terrain_status = 'unavailable'
WHERE pos2 IS NULL AND pos1 IS NOT NULL
"""
# Terrain: check actual terrain_profiles table
execute """
UPDATE qsos SET terrain_status = 'pending'
WHERE terrain_status = 'complete'
AND id NOT IN (SELECT qso_id FROM terrain_profiles)
AND pos1 IS NOT NULL AND pos2 IS NOT NULL
"""
# HRRR: reset to pending where no nearby profile exists
# (checking exact match is too slow for migration, reset all and let
# the backfill dashboard / page views re-enqueue as needed)
execute """
UPDATE qsos SET hrrr_status = 'pending'
WHERE hrrr_status = 'complete'
AND pos1 IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM hrrr_profiles h
WHERE h.lat BETWEEN (qsos.pos1->>'lat')::float - 0.05
AND (qsos.pos1->>'lat')::float + 0.05
AND h.lon BETWEEN COALESCE(qsos.pos1->>'lon', qsos.pos1->>'lng')::float - 0.05
AND COALESCE(qsos.pos1->>'lon', qsos.pos1->>'lng')::float + 0.05
AND h.valid_time BETWEEN qsos.qso_timestamp - interval '1 hour'
AND qsos.qso_timestamp + interval '1 hour'
)
"""
# Weather: reset to pending where no surface observations exist nearby
execute """
UPDATE qsos SET weather_status = 'pending'
WHERE weather_status = 'complete'
AND pos1 IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM surface_observations o
JOIN weather_stations ws ON ws.id = o.station_id
WHERE ws.lat BETWEEN (qsos.pos1->>'lat')::float - 3
AND (qsos.pos1->>'lat')::float + 3
AND ws.lon BETWEEN COALESCE(qsos.pos1->>'lon', qsos.pos1->>'lng')::float - 3
AND COALESCE(qsos.pos1->>'lon', qsos.pos1->>'lng')::float + 3
AND o.observed_at BETWEEN qsos.qso_timestamp - interval '6 hours'
AND qsos.qso_timestamp + interval '6 hours'
)
"""
end
def down do
# No rollback — statuses are now more accurate
:ok
end
defp backfill_positions_from_grids do
import Ecto.Query
alias Microwaveprop.Radio.Maidenhead
contacts =
from(q in "qsos",
where: is_nil(q.pos1) and not is_nil(q.grid1),
select: %{id: q.id, grid1: q.grid1, grid2: q.grid2}
)
|> repo().all()
for c <- contacts do
pos1 = grid_to_pos(c.grid1)
pos2 = grid_to_pos(c.grid2)
if pos1 do
distance =
if pos1 && pos2 do
haversine(pos1["lat"], pos1["lon"], pos2["lat"], pos2["lon"])
|> round()
end
updates =
[pos1: pos1]
|> then(fn u -> if pos2, do: [{:pos2, pos2} | u], else: u end)
|> then(fn u -> if distance, do: [{:distance_km, distance} | u], else: u end)
|> Keyword.put(:updated_at, DateTime.truncate(DateTime.utc_now(), :second))
from(q in "qsos", where: q.id == ^c.id)
|> repo().update_all(set: updates)
end
end
end
defp grid_to_pos(nil), do: nil
defp grid_to_pos(grid) do
case Microwaveprop.Radio.Maidenhead.to_latlon(grid) do
{:ok, {lat, lon}} -> %{"lat" => lat, "lon" => lon}
_ -> nil
end
end
defp haversine(lat1, lon1, lat2, lon2) do
dlat = (lat2 - lat1) * :math.pi() / 180
dlon = (lon2 - lon1) * :math.pi() / 180
rlat1 = lat1 * :math.pi() / 180
rlat2 = lat2 * :math.pi() / 180
a =
:math.sin(dlat / 2) ** 2 +
:math.cos(rlat1) * :math.cos(rlat2) * :math.sin(dlon / 2) ** 2
2 * 6371.0 * :math.asin(:math.sqrt(a))
end
end

View file

@ -607,9 +607,9 @@ defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorkerTest do
assert updated.iemre_status == :queued
end
test "works for QSO without pos2 (marks terrain_queued false)" do
test "works for QSO without pos2 or grid2 (terrain stays pending)" do
_station = create_asos_station("KDFW", 32.90, -97.04)
contact = create_contact(%{pos2: nil})
contact = create_contact(%{pos2: nil, grid2: nil})
assert :ok = ContactWeatherEnqueueWorker.enqueue_for_contact(contact)