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'.
310 lines
7.9 KiB
Elixir
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.")
|