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:
parent
df27209025
commit
79af4e4959
6 changed files with 188 additions and 4 deletions
|
|
@ -212,6 +212,54 @@ defmodule Microwaveprop.Radio do
|
||||||
Contact.submission_changeset(contact, attrs)
|
Contact.submission_changeset(contact, attrs)
|
||||||
end
|
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
|
def create_contact(attrs) do
|
||||||
changeset = Contact.submission_changeset(%Contact{}, attrs)
|
changeset = Contact.submission_changeset(%Contact{}, attrs)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorker do
|
||||||
Called directly from submission flow — no Oban indirection.
|
Called directly from submission flow — no Oban indirection.
|
||||||
"""
|
"""
|
||||||
def enqueue_for_contact(contact) do
|
def enqueue_for_contact(contact) do
|
||||||
|
contact = Radio.ensure_positions!(contact)
|
||||||
weather_jobs = build_weather_jobs([contact])
|
weather_jobs = build_weather_jobs([contact])
|
||||||
hrrr_jobs = build_hrrr_jobs([contact])
|
hrrr_jobs = build_hrrr_jobs([contact])
|
||||||
terrain_jobs = build_terrain_jobs([contact])
|
terrain_jobs = build_terrain_jobs([contact])
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ defmodule MicrowavepropWeb.BackfillLive do
|
||||||
contacts =
|
contacts =
|
||||||
from(c in Contact,
|
from(c in Contact,
|
||||||
where:
|
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.hrrr_status in ^@enrichable or c.weather_status in ^@enrichable or
|
||||||
c.terrain_status in ^@enrichable or c.iemre_status in ^@enrichable),
|
c.terrain_status in ^@enrichable or c.iemre_status in ^@enrichable),
|
||||||
order_by: [desc: c.qso_timestamp],
|
order_by: [desc: c.qso_timestamp],
|
||||||
|
|
@ -82,6 +82,7 @@ defmodule MicrowavepropWeb.BackfillLive do
|
||||||
)
|
)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|
|
||||||
|
# ensure_positions! is called inside enqueue_for_contact
|
||||||
Enum.each(contacts, &ContactWeatherEnqueueWorker.enqueue_for_contact/1)
|
Enum.each(contacts, &ContactWeatherEnqueueWorker.enqueue_for_contact/1)
|
||||||
length(contacts)
|
length(contacts)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(%{"id" => id}, _session, socket) do
|
def mount(%{"id" => id}, _session, socket) do
|
||||||
contact = Radio.get_contact!(id)
|
contact = id |> Radio.get_contact!() |> Radio.ensure_positions!()
|
||||||
|
|
||||||
if connected?(socket) do
|
if connected?(socket) do
|
||||||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "contact_enrichment:#{contact.id}")
|
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "contact_enrichment:#{contact.id}")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -607,9 +607,9 @@ defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorkerTest do
|
||||||
assert updated.iemre_status == :queued
|
assert updated.iemre_status == :queued
|
||||||
end
|
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)
|
_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)
|
assert :ok = ContactWeatherEnqueueWorker.enqueue_for_contact(contact)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue