prop/priv/repo/import_contacts.exs
Graham McIntire 8a969e315c
refactor: normalize pos1/pos2 JSONB key to 'lon' everywhere
57,186 prod contacts stored pos1/pos2 with 'lng'; 1,133 used 'lon'.
Every Elixir caller carried a `pos["lon"] || pos["lng"]` fallback
— which just caused a SQL widget to silently miscount 98% of contacts
(count_narr_done used `pos1->>'lon'` directly, no fallback, so every
lng-keyed row returned NULL and failed the coverage check).

- Migration rewrites every pos1/pos2 JSONB in place, renaming 'lng' to
  'lon' and dropping 'lng'.
- Removes all 20+ `|| pos["lng"]` fallbacks across lib/, workers,
  scorer, weather, radio.ex, contact show view, and recalibrator.
- lib_ml/propagation_analyze.ex SQL now reads pos1->>'lon' directly
  (was reading 'lng' only, which would have broken after migration).
- priv/repo/import_contacts.exs one-time seed script now emits 'lon'
  with string keys, matching production shape.
- Test fixtures in 4 test files normalized to 'lon'.
- Two lng-characterization tests deleted — nonsensical post-normalize.
- Updated notebook + old import_weather script to match.
- JS hook contact_map_hook.ts TypeScript type narrowed to 'lon'.
2026-04-17 09:10:32 -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, "lon" => 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, "lon" => lng1}, %{"lat" => lat2, "lon" => 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.")