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.")