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'.
290 lines
8.6 KiB
Elixir
290 lines
8.6 KiB
Elixir
import Ecto.Query
|
|
|
|
alias Microwaveprop.Repo
|
|
alias Microwaveprop.Weather
|
|
alias Microwaveprop.Weather.IemClient
|
|
alias Microwaveprop.Weather.SolarClient
|
|
alias Microwaveprop.Workers.WeatherFetchWorker
|
|
|
|
# --- Helpers ---
|
|
|
|
defmodule ImportHelpers do
|
|
@moduledoc false
|
|
|
|
@km_per_deg 111.0
|
|
|
|
@us_networks ~w(
|
|
AL_ASOS AK_ASOS AZ_ASOS AR_ASOS CA_ASOS CO_ASOS CT_ASOS DE_ASOS FL_ASOS
|
|
GA_ASOS HI_ASOS ID_ASOS IL_ASOS IN_ASOS IA_ASOS KS_ASOS KY_ASOS LA_ASOS
|
|
ME_ASOS MD_ASOS MA_ASOS MI_ASOS MN_ASOS MS_ASOS MO_ASOS MT_ASOS NE_ASOS
|
|
NV_ASOS NH_ASOS NJ_ASOS NM_ASOS NY_ASOS NC_ASOS ND_ASOS OH_ASOS OK_ASOS
|
|
OR_ASOS PA_ASOS RI_ASOS SC_ASOS SD_ASOS TN_ASOS TX_ASOS UT_ASOS VT_ASOS
|
|
VA_ASOS WA_ASOS WV_ASOS WI_ASOS WY_ASOS DC_ASOS PR_ASOS
|
|
)
|
|
|
|
def us_networks, do: @us_networks
|
|
|
|
def nearest_stations(lat, lon, stations, radius_km) do
|
|
Enum.filter(stations, fn s ->
|
|
dlat = (s.lat - lat) * @km_per_deg
|
|
dlon = (s.lon - lon) * @km_per_deg * :math.cos(lat * :math.pi() / 180)
|
|
:math.sqrt(dlat * dlat + dlon * dlon) <= radius_km
|
|
end)
|
|
end
|
|
|
|
def sounding_times_around(dt) do
|
|
# Return the 00Z and 12Z sounding times that bracket the QSO time
|
|
date = DateTime.to_date(dt)
|
|
hour = dt.hour
|
|
|
|
times =
|
|
if hour < 12 do
|
|
[
|
|
DateTime.new!(Date.add(date, -1), ~T[12:00:00], "Etc/UTC"),
|
|
DateTime.new!(date, ~T[00:00:00], "Etc/UTC")
|
|
]
|
|
else
|
|
[
|
|
DateTime.new!(date, ~T[00:00:00], "Etc/UTC"),
|
|
DateTime.new!(date, ~T[12:00:00], "Etc/UTC")
|
|
]
|
|
end
|
|
|
|
Enum.uniq(times)
|
|
end
|
|
end
|
|
|
|
# --- Phase 0: Import solar indices ---
|
|
|
|
IO.puts("=== Phase 0: Importing solar indices (since 2000-01-01) ===")
|
|
|
|
existing_solar = Weather.existing_solar_dates()
|
|
IO.puts("Found #{MapSet.size(existing_solar)} solar index dates already in DB")
|
|
|
|
IO.puts("Downloading GFZ solar index file...")
|
|
|
|
case SolarClient.fetch_solar_indices() do
|
|
{:ok, all_records} ->
|
|
IO.puts("Parsed #{length(all_records)} total daily records from GFZ file")
|
|
|
|
records =
|
|
all_records
|
|
|> SolarClient.filter_since(~D[2000-01-01])
|
|
|> Enum.reject(fn r -> MapSet.member?(existing_solar, r.date) end)
|
|
|
|
IO.puts("#{length(records)} new records to import (skipping dates already in DB)")
|
|
|
|
records
|
|
|> Enum.with_index(1)
|
|
|> Enum.each(fn {record, idx} ->
|
|
if rem(idx, 500) == 0, do: IO.puts(" Upserting solar index #{idx}/#{length(records)}...")
|
|
Weather.upsert_solar_index(record)
|
|
end)
|
|
|
|
IO.puts("Solar index import complete")
|
|
|
|
{:error, reason} ->
|
|
IO.puts("Warning: failed to fetch solar indices: #{inspect(reason)}")
|
|
end
|
|
|
|
# --- Phase 1: Discover and seed stations from IEM ---
|
|
|
|
IO.puts("=== Phase 1: Discovering stations from IEM ===")
|
|
|
|
# Fetch ASOS stations from all US state networks
|
|
IO.puts("Fetching ASOS stations from #{length(ImportHelpers.us_networks())} state networks...")
|
|
|
|
asos_stations =
|
|
ImportHelpers.us_networks()
|
|
|> Task.async_stream(
|
|
fn network ->
|
|
state = network |> String.split("_") |> hd()
|
|
|
|
case IemClient.fetch_network(network) do
|
|
{:ok, stations} ->
|
|
Enum.map(stations, &Map.put(&1, :state, state))
|
|
|
|
{:error, reason} ->
|
|
IO.puts(" Warning: failed to fetch #{network}: #{inspect(reason)}")
|
|
[]
|
|
end
|
|
end,
|
|
max_concurrency: 10,
|
|
timeout: 30_000,
|
|
ordered: false
|
|
)
|
|
|> Enum.flat_map(fn
|
|
{:ok, stations} -> stations
|
|
{:exit, _reason} -> []
|
|
end)
|
|
|
|
IO.puts("Discovered #{length(asos_stations)} ASOS stations across all US networks")
|
|
|
|
# Fetch RAOB (sounding) stations
|
|
IO.puts("Fetching RAOB stations...")
|
|
|
|
raob_stations =
|
|
case IemClient.fetch_network("RAOB") do
|
|
{:ok, stations} ->
|
|
stations
|
|
|
|
{:error, reason} ->
|
|
IO.puts(" Warning: failed to fetch RAOB network: #{inspect(reason)}")
|
|
[]
|
|
end
|
|
|
|
IO.puts("Discovered #{length(raob_stations)} RAOB stations")
|
|
|
|
# Seed ASOS stations
|
|
IO.puts("Seeding ASOS stations...")
|
|
|
|
asos_station_map =
|
|
Enum.reduce(asos_stations, %{}, fn attrs, acc ->
|
|
{:ok, station} =
|
|
Weather.find_or_create_station(Map.put(attrs, :station_type, "asos"))
|
|
|
|
Map.put(acc, station.station_code, station)
|
|
end)
|
|
|
|
IO.puts("Seeded #{map_size(asos_station_map)} unique ASOS stations")
|
|
|
|
# Seed sounding stations
|
|
IO.puts("Seeding sounding stations...")
|
|
|
|
sounding_station_map =
|
|
Enum.reduce(raob_stations, %{}, fn attrs, acc ->
|
|
{:ok, station} =
|
|
Weather.find_or_create_station(Map.put(attrs, :station_type, "sounding"))
|
|
|
|
Map.put(acc, station.station_code, station)
|
|
end)
|
|
|
|
IO.puts("Seeded #{map_size(sounding_station_map)} unique sounding stations")
|
|
|
|
# Build lookup lists with lat/lon for nearest-station searches
|
|
asos_station_list =
|
|
asos_stations
|
|
|> Enum.map(fn s -> Map.put(s, :station_type, "asos") end)
|
|
|> Enum.uniq_by(& &1.station_code)
|
|
|
|
sounding_station_list =
|
|
raob_stations
|
|
|> Enum.map(fn s -> Map.put(s, :station_type, "sounding") end)
|
|
|> Enum.uniq_by(& &1.station_code)
|
|
|
|
# --- Phase 2: Enqueue weather fetches for each QSO ---
|
|
|
|
IO.puts("\n=== Phase 2: Enqueueing weather fetch jobs ===")
|
|
|
|
qsos =
|
|
Microwaveprop.Radio.Qso
|
|
|> where([q], not is_nil(q.pos1))
|
|
|> order_by([q], q.qso_timestamp)
|
|
|> Repo.all()
|
|
|
|
IO.puts("Processing #{length(qsos)} QSOs with positions...")
|
|
|
|
# Track which station+time combos we've already enqueued to avoid duplicates
|
|
asos_seen = :ets.new(:asos_seen, [:set, :public])
|
|
raob_seen = :ets.new(:raob_seen, [:set, :public])
|
|
|
|
total = length(qsos)
|
|
asos_enqueued = :counters.new(1, [:atomics])
|
|
raob_enqueued = :counters.new(1, [:atomics])
|
|
skipped = :counters.new(1, [:atomics])
|
|
|
|
Enum.with_index(qsos, 1)
|
|
|> Enum.each(fn {qso, idx} ->
|
|
if rem(idx, 500) == 0, do: IO.puts(" Scanned #{idx}/#{total} QSOs")
|
|
|
|
lat = qso.pos1["lat"] || qso.pos1["latitude"]
|
|
lon = qso.pos1["lon"] || qso.pos1["longitude"]
|
|
|
|
if lat && lon do
|
|
# Find nearby ASOS stations (within 150km)
|
|
nearby_asos = ImportHelpers.nearest_stations(lat, lon, asos_station_list, 150)
|
|
|
|
Enum.each(nearby_asos, fn s ->
|
|
start_dt = DateTime.add(qso.qso_timestamp, -2 * 3600, :second)
|
|
end_dt = DateTime.add(qso.qso_timestamp, 2 * 3600, :second)
|
|
key = {s.station_code, DateTime.to_date(start_dt), div(start_dt.hour, 4)}
|
|
|
|
if !:ets.member(asos_seen, key) do
|
|
:ets.insert(asos_seen, {key, true})
|
|
station = Map.get(asos_station_map, s.station_code)
|
|
|
|
if station && !Weather.has_surface_observations?(station.id, start_dt, end_dt) do
|
|
%{
|
|
"fetch_type" => "asos",
|
|
"station_id" => station.id,
|
|
"station_code" => s.station_code,
|
|
"start_dt" => DateTime.to_iso8601(start_dt),
|
|
"end_dt" => DateTime.to_iso8601(end_dt)
|
|
}
|
|
|> WeatherFetchWorker.new()
|
|
|> Oban.insert()
|
|
|
|
:counters.add(asos_enqueued, 1, 1)
|
|
else
|
|
:counters.add(skipped, 1, 1)
|
|
end
|
|
end
|
|
end)
|
|
|
|
# Find nearby sounding stations (within 300km)
|
|
nearby_soundings = ImportHelpers.nearest_stations(lat, lon, sounding_station_list, 300)
|
|
|
|
Enum.each(nearby_soundings, fn s ->
|
|
sounding_times = ImportHelpers.sounding_times_around(qso.qso_timestamp)
|
|
|
|
Enum.each(sounding_times, fn sounding_time ->
|
|
key = {s.station_code, sounding_time}
|
|
|
|
if !:ets.member(raob_seen, key) do
|
|
:ets.insert(raob_seen, {key, true})
|
|
station = Map.get(sounding_station_map, s.station_code)
|
|
|
|
if station && !Weather.has_sounding?(station.id, sounding_time) do
|
|
%{
|
|
"fetch_type" => "raob",
|
|
"station_id" => station.id,
|
|
"station_code" => s.station_code,
|
|
"sounding_time" => DateTime.to_iso8601(sounding_time)
|
|
}
|
|
|> WeatherFetchWorker.new()
|
|
|> Oban.insert()
|
|
|
|
:counters.add(raob_enqueued, 1, 1)
|
|
else
|
|
:counters.add(skipped, 1, 1)
|
|
end
|
|
end
|
|
end)
|
|
end)
|
|
end
|
|
end)
|
|
|
|
:ets.delete(asos_seen)
|
|
:ets.delete(raob_seen)
|
|
|
|
# --- Summary ---
|
|
|
|
asos_count = Repo.aggregate(Microwaveprop.Weather.SurfaceObservation, :count)
|
|
sounding_count = Repo.aggregate(Microwaveprop.Weather.Sounding, :count)
|
|
station_count = Repo.aggregate(Microwaveprop.Weather.Station, :count)
|
|
solar_count = Repo.aggregate(Microwaveprop.Weather.SolarIndex, :count)
|
|
job_count = Oban.Job |> Repo.aggregate(:count)
|
|
|
|
IO.puts("""
|
|
|
|
Scan complete:
|
|
Solar index days: #{solar_count}
|
|
Stations: #{station_count}
|
|
Surface observations: #{asos_count} (existing)
|
|
Soundings: #{sounding_count} (existing)
|
|
ASOS jobs enqueued: #{:counters.get(asos_enqueued, 1)}
|
|
RAOB jobs enqueued: #{:counters.get(raob_enqueued, 1)}
|
|
Total Oban jobs: #{job_count}
|
|
|
|
Oban will now process the weather queue (concurrency: 3) with automatic retries.
|
|
""")
|