prop/priv/repo/import_weather.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

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