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