prop/priv/repo/import_contacts.exs
Graham McIntire 6f16395f44
Add QSO import, solar indices, Oban workers, LiveView UI, and parallel weather import
- Radio context with QSO schema and CSV import script (58K contacts)
- Solar index schema, GFZ client, and daily Oban cron worker
- LiveView dashboard with QSO table and weather correlation views
- Parallelize weather import script using Task.async_stream (10 concurrent workers)
- Replace sequential rate_limit sleeps with concurrency-based backpressure
- Atomic progress counter for interleave-safe reporting
2026-03-29 13:04:55 -05:00

310 lines
7.9 KiB
Elixir

alias Microwaveprop.Radio.Qso
alias Microwaveprop.Repo
# -- Config --
data_path = Path.expand("~/dev/ntms/propdata/data/microwave_contacts.json")
gridmap_base = "https://gridmap.org/grid/"
batch_size = 1000
IO.puts("Reading #{data_path}...")
data = data_path |> File.read!() |> Jason.decode!()
# -- Band normalization --
# Contest logs use "10G", "24G", etc. ARRL uses "10 GHz". UKuG uses band_ghz float.
band_map = %{
"10G" => Decimal.new("10000"),
"24G" => Decimal.new("24000"),
"47G" => Decimal.new("47000"),
"75G" => Decimal.new("75000"),
"LIGHT" => Decimal.new("300000000")
}
defmodule BandParser do
def parse(band) when is_binary(band) do
case Map.get(band_map(), String.upcase(band)) do
nil -> parse_ghz_string(band)
val -> val
end
end
def parse(ghz) when is_number(ghz) do
ghz |> Decimal.from_float() |> Decimal.mult(Decimal.new("1000"))
end
def parse(_), do: nil
defp parse_ghz_string(s) do
case Float.parse(String.replace(s, ~r/\s*GHz\s*/i, "")) do
{ghz, _} -> ghz |> Decimal.from_float() |> Decimal.mult(Decimal.new("1000"))
:error -> nil
end
end
defp band_map, do: unquote(Macro.escape(band_map))
end
# -- Collect all unique grids --
IO.puts("Collecting unique grids...")
collect_grids = fn records, grid_keys ->
Enum.reduce(records, MapSet.new(), fn record, acc ->
Enum.reduce(grid_keys, acc, fn key, inner_acc ->
case Map.get(record, key) do
nil -> inner_acc
"" -> inner_acc
grid -> MapSet.put(inner_acc, String.upcase(grid))
end
end)
end)
end
contest_qsos =
Enum.flat_map(data["contest_logs"], fn log -> Map.get(log, "qsos", []) end)
all_grids =
MapSet.new()
|> MapSet.union(collect_grids.(contest_qsos, ["grid1", "grid2"]))
|> MapSet.union(collect_grids.(data["arrl_distance_records"], ["grid1", "grid2"]))
|> MapSet.union(collect_grids.(data["ukug_distance_records"], ["grid1", "grid2"]))
IO.puts("Found #{MapSet.size(all_grids)} unique grids to look up")
# -- Fetch grid coordinates from gridmap.org --
IO.puts("Fetching coordinates from gridmap.org...")
grid_coords =
all_grids
|> Task.async_stream(
fn grid ->
case Req.get("#{gridmap_base}#{grid}") do
{:ok, %{status: 200, body: %{"latitude" => lat, "longitude" => lng}}} ->
{grid, %{lat: lat, lng: lng}}
{:ok, resp} ->
IO.puts(" Warning: grid #{grid} returned status #{resp.status}")
{grid, nil}
{:error, err} ->
IO.puts(" Error fetching grid #{grid}: #{inspect(err)}")
{grid, nil}
end
end,
max_concurrency: 20,
timeout: 15_000,
ordered: false
)
|> Enum.reduce(%{}, fn
{:ok, {grid, coords}}, acc -> Map.put(acc, grid, coords)
{:exit, _reason}, acc -> acc
end)
resolved = Enum.count(grid_coords, fn {_k, v} -> v != nil end)
IO.puts("Resolved #{resolved}/#{MapSet.size(all_grids)} grids")
# -- Haversine distance --
defmodule Haversine do
@earth_radius_km 6371.0
def distance_km(nil, _), do: nil
def distance_km(_, nil), do: nil
def distance_km(%{lat: lat1, lng: lng1}, %{lat: lat2, lng: lng2}) do
dlat = deg_to_rad(lat2 - lat1)
dlng = deg_to_rad(lng2 - lng1)
lat1_r = deg_to_rad(lat1)
lat2_r = deg_to_rad(lat2)
a =
:math.sin(dlat / 2) * :math.sin(dlat / 2) +
:math.cos(lat1_r) * :math.cos(lat2_r) * :math.sin(dlng / 2) * :math.sin(dlng / 2)
c = 2 * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a))
Decimal.from_float(Float.round(@earth_radius_km * c, 1))
end
defp deg_to_rad(deg), do: deg * :math.pi() / 180.0
end
# -- Build QSO rows --
IO.puts("Building QSO records...")
now = DateTime.utc_now() |> DateTime.truncate(:second)
lookup = fn grid ->
case grid do
nil -> nil
"" -> nil
g -> Map.get(grid_coords, String.upcase(g))
end
end
parse_timestamp = fn date_str, time_str ->
case {date_str, time_str} do
{nil, _} ->
nil
{_, nil} ->
nil
{date, time} ->
padded_time =
time |> String.pad_leading(4, "0")
case DateTime.new(
Date.from_iso8601!(date),
Time.new!(
String.to_integer(String.slice(padded_time, 0, 2)),
String.to_integer(String.slice(padded_time, 2, 2)),
0
),
"Etc/UTC"
) do
{:ok, dt} -> DateTime.truncate(dt, :second)
_ -> nil
end
end
end
parse_date_only = fn date_str ->
cond do
is_nil(date_str) or date_str == "" ->
nil
# ISO format: "2010-06-10"
String.match?(date_str, ~r/^\d{4}-\d{2}-\d{2}$/) ->
case DateTime.new(Date.from_iso8601!(date_str), ~T[00:00:00], "Etc/UTC") do
{:ok, dt} -> DateTime.truncate(dt, :second)
_ -> nil
end
# ARRL format: "22-Jun-07"
String.match?(date_str, ~r/^\d{1,2}-\w{3}-\d{2}$/) ->
months = %{
"Jan" => 1,
"Feb" => 2,
"Mar" => 3,
"Apr" => 4,
"May" => 5,
"Jun" => 6,
"Jul" => 7,
"Aug" => 8,
"Sep" => 9,
"Oct" => 10,
"Nov" => 11,
"Dec" => 12
}
[day, mon, yr] = String.split(date_str, "-")
short_year = String.to_integer(yr)
year = if short_year >= 30, do: 1900 + short_year, else: 2000 + short_year
case DateTime.new(
Date.new!(year, Map.fetch!(months, mon), String.to_integer(day)),
~T[00:00:00],
"Etc/UTC"
) do
{:ok, dt} -> DateTime.truncate(dt, :second)
_ -> nil
end
true ->
nil
end
end
# Contest log QSOs
contest_rows =
Enum.flat_map(data["contest_logs"], fn log ->
Enum.map(Map.get(log, "qsos", []), fn q ->
g1 = q["grid1"]
g2 = q["grid2"]
pos1 = lookup.(g1)
pos2 = lookup.(g2)
%{
station1: q["station1"],
station2: q["station2"],
qso_timestamp: parse_timestamp.(q["date"], q["time"]),
grid1: g1,
grid2: g2,
pos1: pos1,
pos2: pos2,
mode: q["mode"],
band: BandParser.parse(q["band"]),
distance_km: Haversine.distance_km(pos1, pos2),
inserted_at: now,
updated_at: now
}
end)
end)
# ARRL distance records
arrl_rows =
Enum.map(data["arrl_distance_records"], fn r ->
g1 = r["grid1"]
g2 = r["grid2"]
pos1 = lookup.(g1)
pos2 = lookup.(g2)
%{
id: Ecto.UUID.generate(),
station1: r["station1"],
station2: r["station2"],
qso_timestamp: parse_date_only.(r["date"]),
grid1: g1,
grid2: g2,
pos1: pos1,
pos2: pos2,
mode: r["mode"] || "N/A",
band: BandParser.parse(r["band"]),
distance_km:
if(r["distance_km"], do: Decimal.from_float(r["distance_km"] / 1), else: nil),
inserted_at: now,
updated_at: now
}
end)
# UKuG distance records
ukug_rows =
Enum.map(data["ukug_distance_records"], fn r ->
g1 = r["grid1"]
g2 = r["grid2"]
pos1 = lookup.(g1)
pos2 = lookup.(g2)
%{
id: Ecto.UUID.generate(),
station1: r["station1"],
station2: r["station2"],
qso_timestamp: parse_date_only.(r["date"]),
grid1: g1,
grid2: g2,
pos1: pos1,
pos2: pos2,
mode: r["mode"] || "N/A",
band: BandParser.parse(r["band_ghz"]),
distance_km:
if(r["distance_km"], do: Decimal.from_float(r["distance_km"] / 1), else: nil),
inserted_at: now,
updated_at: now
}
end)
all_rows =
(contest_rows ++ arrl_rows ++ ukug_rows)
|> Enum.filter(fn row -> row.band != nil and row.qso_timestamp != nil end)
IO.puts("Total records to insert: #{length(all_rows)}")
# -- Insert in batches --
IO.puts("Inserting into database in batches of #{batch_size}...")
all_rows
|> Enum.chunk_every(batch_size)
|> Enum.with_index(1)
|> Enum.each(fn {batch, i} ->
Repo.insert_all(Qso, batch)
IO.puts(" Batch #{i}: inserted #{length(batch)} records")
end)
total = Repo.aggregate(Qso, :count)
IO.puts("Done! #{total} QSOs in database.")