Multi-point path enrichment for HRRR, IEMRE, and weather data

Enqueue worker now gathers atmospheric data at pos1, midpoint, and pos2
along each QSO path instead of only pos1. Existing has_* guards prevent
duplicate fetches at each grid point.

- Add Radio.qso_path_points/1 for path point extraction
- Update hrrr_job_for_qso, iemre_job_for_qso, jobs_for_qso to iterate path points
- Refactor Weather into find_nearest_hrrr/3 and find_nearest_iemre/3
- Add hrrr_profiles_for_path/1 and iemre_for_path/1 query functions
- Add mix reset_enrichment task to trigger re-processing
This commit is contained in:
Graham McIntire 2026-03-30 16:09:01 -05:00
parent ea439e56bd
commit ded9c054ac
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
8 changed files with 432 additions and 72 deletions

View file

@ -100,6 +100,33 @@ defmodule Microwaveprop.Radio do
|> Repo.update_all(set: [iemre_queued: true])
end
@doc """
Returns a list of {lat, lon} points along the QSO path: pos1, midpoint, pos2.
Returns [pos1] if pos2 is nil, or [] if pos1 is nil.
"""
def qso_path_points(%{pos1: nil}), do: []
def qso_path_points(%{pos1: pos1, pos2: nil}) do
lat = pos1["lat"]
lon = pos1["lon"] || pos1["lng"]
if lat && lon, do: [{lat, lon}], else: []
end
def qso_path_points(%{pos1: pos1, pos2: pos2}) do
lat1 = pos1["lat"]
lon1 = pos1["lon"] || pos1["lng"]
lat2 = pos2["lat"]
lon2 = pos2["lon"] || pos2["lng"]
if lat1 && lon1 && lat2 && lon2 do
mid_lat = (lat1 + lat2) / 2
mid_lon = (lon1 + lon2) / 2
[{lat1, lon1}, {mid_lat, mid_lon}, {lat2, lon2}]
else
[{lat1, lon1}]
end
end
@earth_radius_km 6371.0
def haversine_km(lat1, lon1, lat2, lon2) do

View file

@ -192,36 +192,48 @@ defmodule Microwaveprop.Weather do
lon = qso.pos1["lon"] || qso.pos1["lng"]
if lat && lon do
# Search within ~0.05 degrees (~5km) and 1 hour
dlat = 0.05
dlon = 0.05
time_start = DateTime.add(qso.qso_timestamp, -3600, :second)
time_end = DateTime.add(qso.qso_timestamp, 3600, :second)
HrrrProfile
|> where(
[h],
h.lat >= ^(lat - dlat) and h.lat <= ^(lat + dlat) and
h.lon >= ^(lon - dlon) and h.lon <= ^(lon + dlon) and
h.valid_time >= ^time_start and h.valid_time <= ^time_end
)
|> order_by([h],
asc:
fragment(
"ABS(? - ?) + ABS(? - ?) + ABS(EXTRACT(EPOCH FROM ? - ?))",
h.lat,
^lat,
h.lon,
^lon,
h.valid_time,
^qso.qso_timestamp
)
)
|> limit(1)
|> Repo.one()
find_nearest_hrrr(lat, lon, qso.qso_timestamp)
end
end
def find_nearest_hrrr(lat, lon, timestamp) do
dlat = 0.05
dlon = 0.05
time_start = DateTime.add(timestamp, -3600, :second)
time_end = DateTime.add(timestamp, 3600, :second)
HrrrProfile
|> where(
[h],
h.lat >= ^(lat - dlat) and h.lat <= ^(lat + dlat) and
h.lon >= ^(lon - dlon) and h.lon <= ^(lon + dlon) and
h.valid_time >= ^time_start and h.valid_time <= ^time_end
)
|> order_by([h],
asc:
fragment(
"ABS(? - ?) + ABS(? - ?) + ABS(EXTRACT(EPOCH FROM ? - ?))",
h.lat,
^lat,
h.lon,
^lon,
h.valid_time,
^timestamp
)
)
|> limit(1)
|> Repo.one()
end
def hrrr_profiles_for_path(%{pos1: nil}), do: []
def hrrr_profiles_for_path(qso) do
qso
|> Microwaveprop.Radio.qso_path_points()
|> Enum.map(fn {lat, lon} -> find_nearest_hrrr(lat, lon, qso.qso_timestamp) end)
|> Enum.reject(&is_nil/1)
end
def round_to_hrrr_grid(lat, lon) do
{Float.round(lat / 1.0, 2), Float.round(lon / 1.0, 2)}
end
@ -253,12 +265,25 @@ defmodule Microwaveprop.Weather do
lon = qso.pos1["lon"] || qso.pos1["lng"]
if lat && lon do
{rlat, rlon} = round_to_iemre_grid(lat, lon)
date = DateTime.to_date(qso.qso_timestamp)
IemreObservation
|> where([i], i.lat == ^rlat and i.lon == ^rlon and i.date == ^date)
|> Repo.one()
find_nearest_iemre(lat, lon, qso.qso_timestamp)
end
end
def find_nearest_iemre(lat, lon, timestamp) do
{rlat, rlon} = round_to_iemre_grid(lat, lon)
date = DateTime.to_date(timestamp)
IemreObservation
|> where([i], i.lat == ^rlat and i.lon == ^rlon and i.date == ^date)
|> Repo.one()
end
def iemre_for_path(%{pos1: nil}), do: []
def iemre_for_path(qso) do
qso
|> Microwaveprop.Radio.qso_path_points()
|> Enum.map(fn {lat, lon} -> find_nearest_iemre(lat, lon, qso.qso_timestamp) end)
|> Enum.reject(&is_nil/1)
end
end

View file

@ -112,13 +112,12 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
end
defp jobs_for_qso(qso) do
lat = qso.pos1["lat"]
lon = qso.pos1["lon"] || qso.pos1["lng"]
asos_jobs = build_asos_jobs(lat, lon, qso.qso_timestamp)
raob_jobs = build_raob_jobs(lat, lon, qso.qso_timestamp)
asos_jobs ++ raob_jobs
qso
|> Radio.qso_path_points()
|> Enum.flat_map(fn {lat, lon} ->
build_asos_jobs(lat, lon, qso.qso_timestamp) ++
build_raob_jobs(lat, lon, qso.qso_timestamp)
end)
end
defp enqueue_iemre_jobs do
@ -143,12 +142,12 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
defp hrrr_job_for_qso(%{pos1: nil}), do: []
defp hrrr_job_for_qso(qso) do
lat = qso.pos1["lat"]
lon = qso.pos1["lon"] || qso.pos1["lng"]
rounded_time = HrrrClient.nearest_hrrr_hour(qso.qso_timestamp)
if lat && lon do
qso
|> Radio.qso_path_points()
|> Enum.flat_map(fn {lat, lon} ->
{rlat, rlon} = Weather.round_to_hrrr_grid(lat, lon)
rounded_time = HrrrClient.nearest_hrrr_hour(qso.qso_timestamp)
if Weather.has_hrrr_profile?(rlat, rlon, rounded_time) do
[]
@ -161,9 +160,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
})
]
end
else
[]
end
end)
end
defp build_asos_jobs(lat, lon, timestamp) do
@ -189,12 +186,12 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
defp iemre_job_for_qso(%{pos1: nil}), do: []
defp iemre_job_for_qso(qso) do
lat = qso.pos1["lat"]
lon = qso.pos1["lon"] || qso.pos1["lng"]
date = DateTime.to_date(qso.qso_timestamp)
if lat && lon do
qso
|> Radio.qso_path_points()
|> Enum.flat_map(fn {lat, lon} ->
{rlat, rlon} = Weather.round_to_iemre_grid(lat, lon)
date = DateTime.to_date(qso.qso_timestamp)
if Weather.has_iemre_observation?(rlat, rlon, date) do
[]
@ -207,9 +204,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
})
]
end
else
[]
end
end)
end
defp build_raob_jobs(lat, lon, timestamp) do

View file

@ -0,0 +1,33 @@
defmodule Mix.Tasks.ResetEnrichment do
@shortdoc "Reset weather/HRRR/IEMRE enrichment flags for re-processing"
@moduledoc """
Resets weather, HRRR, and IEMRE enrichment flags on all QSOs so the
next enqueue worker run re-processes them with multi-point path data.
Does NOT reset terrain_queued since terrain profiles are path-based (pos1pos2)
and don't change with multi-point enrichment.
mix reset_enrichment
"""
use Mix.Task
import Ecto.Query
alias Microwaveprop.Radio.Qso
alias Microwaveprop.Repo
@impl Mix.Task
def run(_args) do
Mix.Task.run("app.start")
{count, _} =
Qso
|> where([q], q.weather_queued == true or q.hrrr_queued == true or q.iemre_queued == true)
|> Repo.update_all(set: [weather_queued: false, hrrr_queued: false, iemre_queued: false])
Mix.shell().info("Reset enrichment flags on #{count} QSOs")
:ok
end
end

View file

@ -346,6 +346,52 @@ defmodule Microwaveprop.RadioTest do
end
end
describe "qso_path_points/1" do
test "returns pos1, midpoint, and pos2 when both positions exist" do
qso =
create_qso(%{
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7}
})
points = Radio.qso_path_points(qso)
assert length(points) == 3
[{lat1, lon1}, {mid_lat, mid_lon}, {lat2, lon2}] = points
assert lat1 == 32.9
assert lon1 == -97.0
assert_in_delta mid_lat, 31.6, 0.01
assert_in_delta mid_lon, -97.35, 0.01
assert lat2 == 30.3
assert lon2 == -97.7
end
test "returns only pos1 when pos2 is nil" do
qso = create_qso(%{pos2: nil})
points = Radio.qso_path_points(qso)
assert points == [{32.9, -97.0}]
end
test "returns empty list when pos1 is nil" do
qso = create_qso(%{pos1: nil})
assert Radio.qso_path_points(qso) == []
end
test "handles lng key in position maps" do
qso =
create_qso(%{
pos1: %{"lat" => 32.9, "lng" => -97.0},
pos2: %{"lat" => 30.3, "lng" => -97.7}
})
points = Radio.qso_path_points(qso)
assert length(points) == 3
end
end
describe "change_qso/2" do
test "returns a submission changeset" do
changeset = Radio.change_qso(%Qso{})

View file

@ -578,6 +578,112 @@ defmodule Microwaveprop.WeatherTest do
end
end
describe "hrrr_profiles_for_path/1" do
test "returns HRRR profiles for all path points" do
# Profile at pos1
Weather.upsert_hrrr_profile(@hrrr_profile_attrs)
# Profile near pos2
Weather.upsert_hrrr_profile(%{
@hrrr_profile_attrs
| lat: 30.30,
lon: -97.70
})
qso = %{
pos1: %{"lat" => 32.91, "lon" => -97.05},
pos2: %{"lat" => 30.31, "lon" => -97.71},
qso_timestamp: ~U[2026-03-28 18:30:00Z]
}
profiles = Weather.hrrr_profiles_for_path(qso)
assert length(profiles) >= 2
end
test "returns empty list when no HRRR profiles exist nearby" do
qso = %{
pos1: %{"lat" => 40.0, "lon" => -80.0},
pos2: %{"lat" => 41.0, "lon" => -81.0},
qso_timestamp: ~U[2026-03-28 18:00:00Z]
}
assert Weather.hrrr_profiles_for_path(qso) == []
end
test "returns profiles for pos1 only when pos2 is nil" do
Weather.upsert_hrrr_profile(@hrrr_profile_attrs)
qso = %{
pos1: %{"lat" => 32.91, "lon" => -97.05},
pos2: nil,
qso_timestamp: ~U[2026-03-28 18:30:00Z]
}
profiles = Weather.hrrr_profiles_for_path(qso)
assert length(profiles) == 1
end
end
describe "iemre_for_path/1" do
test "returns IEMRE observations for all path points" do
Weather.upsert_iemre_observation(@iemre_attrs)
# Observation near pos2
Weather.upsert_iemre_observation(%{
lat: 30.25,
lon: -97.75,
date: ~D[2026-03-28],
hourly: [%{"hour" => 0, "p01m_mm" => 1.0}]
})
qso = %{
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7},
qso_timestamp: ~U[2026-03-28 18:00:00Z]
}
observations = Weather.iemre_for_path(qso)
assert length(observations) >= 2
end
test "returns empty list when no observations exist" do
qso = %{
pos1: %{"lat" => 40.0, "lon" => -80.0},
pos2: %{"lat" => 41.0, "lon" => -81.0},
qso_timestamp: ~U[2026-03-28 18:00:00Z]
}
assert Weather.iemre_for_path(qso) == []
end
test "returns observation for pos1 only when pos2 is nil" do
Weather.upsert_iemre_observation(@iemre_attrs)
qso = %{
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: nil,
qso_timestamp: ~U[2026-03-28 18:00:00Z]
}
observations = Weather.iemre_for_path(qso)
assert length(observations) == 1
end
end
describe "find_nearest_hrrr/3" do
test "returns nearest HRRR profile for given lat, lon, and timestamp" do
Weather.upsert_hrrr_profile(@hrrr_profile_attrs)
profile = Weather.find_nearest_hrrr(32.91, -97.05, ~U[2026-03-28 18:30:00Z])
assert profile
assert profile.lat == 32.90
end
test "returns nil when no HRRR profiles exist nearby" do
assert Weather.find_nearest_hrrr(40.0, -80.0, ~U[2026-03-28 18:00:00Z]) == nil
end
end
describe "iemre_for_qso/1" do
test "returns IEMRE observation matching QSO pos1 and date" do
Weather.upsert_iemre_observation(@iemre_attrs)

View file

@ -113,8 +113,19 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
assert length(asos_jobs) == 1
end
test "builds ASOS jobs for stations near pos2 and midpoint" do
# Station near pos2 (30.3, -97.7) but NOT near pos1 (32.9, -97.0)
station_near_pos2 = create_asos_station("KAUS", 30.20, -97.68)
qso = create_qso()
jobs = QsoWeatherEnqueueWorker.build_weather_jobs([qso])
station_ids = Enum.map(jobs, & &1.changes.args["station_id"])
assert station_near_pos2.id in station_ids
end
test "returns empty list when no stations nearby" do
qso = create_qso(%{pos1: %{"lat" => 60.0, "lon" => -150.0}})
qso = create_qso(%{pos1: %{"lat" => 60.0, "lon" => -150.0}, pos2: nil})
assert QsoWeatherEnqueueWorker.build_weather_jobs([qso]) == []
end
@ -244,11 +255,22 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
end
describe "build_hrrr_jobs/1" do
test "builds one HRRR job per QSO with rounded lat/lon" do
test "builds HRRR jobs for all path points (pos1, midpoint, pos2)" do
qso = create_qso(%{pos1: %{"lat" => 32.907, "lon" => -97.038}})
jobs = QsoWeatherEnqueueWorker.build_hrrr_jobs([qso])
# pos1, midpoint, pos2 = 3 distinct grid points
assert length(jobs) == 3
lats = jobs |> Enum.map(& &1.changes.args["lat"]) |> Enum.sort()
assert 32.91 in lats
end
test "builds only one HRRR job when pos2 is nil" do
qso = create_qso(%{pos1: %{"lat" => 32.907, "lon" => -97.038}, pos2: nil})
jobs = QsoWeatherEnqueueWorker.build_hrrr_jobs([qso])
assert length(jobs) == 1
job = hd(jobs)
assert job.changes.args["lat"] == 32.91
@ -256,7 +278,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
end
test "rounds valid_time to nearest hour" do
qso = create_qso(%{qso_timestamp: ~U[2026-03-28 18:45:00Z]})
qso = create_qso(%{qso_timestamp: ~U[2026-03-28 18:45:00Z], pos2: nil})
jobs = QsoWeatherEnqueueWorker.build_hrrr_jobs([qso])
@ -264,19 +286,21 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
assert job.changes.args["valid_time"] == "2026-03-28T19:00:00Z"
end
test "deduplicates jobs with identical args" do
test "deduplicates jobs across QSOs with identical path points" do
q1 =
create_qso(%{
station1: "A1",
qso_timestamp: ~U[2026-03-28 18:10:00Z],
pos1: %{"lat" => 32.90, "lon" => -97.04}
pos1: %{"lat" => 32.90, "lon" => -97.04},
pos2: nil
})
q2 =
create_qso(%{
station1: "A2",
qso_timestamp: ~U[2026-03-28 18:20:00Z],
pos1: %{"lat" => 32.90, "lon" => -97.04}
pos1: %{"lat" => 32.90, "lon" => -97.04},
pos2: nil
})
jobs = QsoWeatherEnqueueWorker.build_hrrr_jobs([q1, q2])
@ -285,16 +309,30 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
assert length(jobs) == 1
end
test "deduplicates path points that round to the same HRRR grid cell" do
# pos1 and pos2 only 0.001° apart — all 3 points round to same HRRR cell
qso =
create_qso(%{
pos1: %{"lat" => 32.901, "lon" => -97.041},
pos2: %{"lat" => 32.902, "lon" => -97.042}
})
jobs = QsoWeatherEnqueueWorker.build_hrrr_jobs([qso])
# All 3 path points round to {32.90, -97.04} → 1 job
assert length(jobs) == 1
end
test "skips QSOs without pos1" do
qso = create_qso(%{pos1: nil})
assert QsoWeatherEnqueueWorker.build_hrrr_jobs([qso]) == []
end
test "skips HRRR job when profile already exists for that location and time" do
test "skips only path points where HRRR profile already exists" do
qso = create_qso(%{pos1: %{"lat" => 32.907, "lon" => -97.038}})
# Insert a profile at the rounded grid point and nearest hour
# Insert a profile at the pos1 rounded grid point only
Weather.upsert_hrrr_profile(%{
valid_time: ~U[2026-03-28 18:00:00Z],
lat: 32.91,
@ -308,7 +346,12 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
surface_pressure_mb: 1013.0
})
assert QsoWeatherEnqueueWorker.build_hrrr_jobs([qso]) == []
jobs = QsoWeatherEnqueueWorker.build_hrrr_jobs([qso])
# pos1 skipped (exists), midpoint + pos2 still need fetching
assert length(jobs) == 2
lats = Enum.map(jobs, & &1.changes.args["lat"])
refute 32.91 in lats
end
end
@ -380,11 +423,22 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
end
describe "build_iemre_jobs/1" do
test "builds one IEMRE job per QSO with rounded lat/lon" do
test "builds IEMRE jobs for all path points (pos1, midpoint, pos2)" do
qso = create_qso(%{pos1: %{"lat" => 32.907, "lon" => -97.038}})
jobs = QsoWeatherEnqueueWorker.build_iemre_jobs([qso])
# pos1, midpoint, pos2 may have distinct IEMRE grid cells
assert length(jobs) >= 2
lats = Enum.map(jobs, & &1.changes.args["lat"])
assert 32.875 in lats
end
test "builds only one IEMRE job when pos2 is nil" do
qso = create_qso(%{pos1: %{"lat" => 32.907, "lon" => -97.038}, pos2: nil})
jobs = QsoWeatherEnqueueWorker.build_iemre_jobs([qso])
assert length(jobs) == 1
job = hd(jobs)
assert job.changes.args["lat"] == 32.875
@ -392,19 +446,21 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
assert job.changes.args["date"] == "2026-03-28"
end
test "deduplicates jobs with identical args" do
test "deduplicates jobs across QSOs with identical path points" do
q1 =
create_qso(%{
station1: "A1",
qso_timestamp: ~U[2026-03-28 18:10:00Z],
pos1: %{"lat" => 32.90, "lon" => -97.04}
pos1: %{"lat" => 32.90, "lon" => -97.04},
pos2: nil
})
q2 =
create_qso(%{
station1: "A2",
qso_timestamp: ~U[2026-03-28 20:20:00Z],
pos1: %{"lat" => 32.90, "lon" => -97.04}
pos1: %{"lat" => 32.90, "lon" => -97.04},
pos2: nil
})
jobs = QsoWeatherEnqueueWorker.build_iemre_jobs([q1, q2])
@ -418,14 +474,16 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
create_qso(%{
station1: "A1",
qso_timestamp: ~U[2026-03-28 23:00:00Z],
pos1: %{"lat" => 32.90, "lon" => -97.04}
pos1: %{"lat" => 32.90, "lon" => -97.04},
pos2: nil
})
q2 =
create_qso(%{
station1: "A2",
qso_timestamp: ~U[2026-03-29 01:00:00Z],
pos1: %{"lat" => 32.90, "lon" => -97.04}
pos1: %{"lat" => 32.90, "lon" => -97.04},
pos2: nil
})
jobs = QsoWeatherEnqueueWorker.build_iemre_jobs([q1, q2])
@ -437,10 +495,10 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
assert QsoWeatherEnqueueWorker.build_iemre_jobs([qso]) == []
end
test "skips IEMRE job when observation already exists for that location and date" do
test "skips only path points where IEMRE observation already exists" do
qso = create_qso(%{pos1: %{"lat" => 32.907, "lon" => -97.038}})
# Insert an IEMRE observation at the rounded grid point and date
# Insert an IEMRE observation at the pos1 rounded grid point only
Weather.upsert_iemre_observation(%{
lat: 32.875,
lon: -97.0,
@ -448,7 +506,12 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
hourly: [%{"hour" => 0, "p01m_mm" => 0.0}]
})
assert QsoWeatherEnqueueWorker.build_iemre_jobs([qso]) == []
jobs = QsoWeatherEnqueueWorker.build_iemre_jobs([qso])
# pos1 skipped, midpoint + pos2 still need fetching
assert length(jobs) >= 1
lats = Enum.map(jobs, & &1.changes.args["lat"])
refute 32.875 in lats
end
end

View file

@ -0,0 +1,65 @@
defmodule Mix.Tasks.ResetEnrichmentTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Radio.Qso
alias Mix.Tasks.ResetEnrichment
defp create_queued_qso do
import Ecto.Query
default = %{
station1: "W5XD",
station2: "K5TR",
qso_timestamp: ~U[2026-03-28 18:00:00Z],
mode: "CW",
band: Decimal.new("1296"),
grid1: "EM12",
grid2: "EM00",
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7},
distance_km: Decimal.new("295")
}
{:ok, qso} =
%Qso{}
|> Qso.changeset(default)
|> Repo.insert()
# Set all queued flags to true (not in changeset cast)
Qso
|> where([q], q.id == ^qso.id)
|> Repo.update_all(set: [weather_queued: true, hrrr_queued: true, iemre_queued: true, terrain_queued: true])
Repo.get!(Qso, qso.id)
end
describe "run/1" do
test "resets weather_queued, hrrr_queued, and iemre_queued to false" do
qso = create_queued_qso()
assert qso.weather_queued == true
assert qso.hrrr_queued == true
assert qso.iemre_queued == true
ResetEnrichment.run([])
updated = Repo.get!(Qso, qso.id)
assert updated.weather_queued == false
assert updated.hrrr_queued == false
assert updated.iemre_queued == false
end
test "does not reset terrain_queued" do
qso = create_queued_qso()
ResetEnrichment.run([])
updated = Repo.get!(Qso, qso.id)
assert updated.terrain_queued == true
end
test "handles empty database" do
assert :ok = ResetEnrichment.run([])
end
end
end