defmodule Microwaveprop.Workers.MechanismClassifyWorker do @moduledoc """ Per-contact propagation-mechanism classification. Assembles the full input map for `MechanismClassifier`: * `user_declared_prop_mode` — from the contact's ADIF PROP_MODE * `duct_either_endpoint` — from the HRRR profile at either endpoint * `native_best_duct_ghz` — from the nearest `hrrr_native_profiles` row * `radar` — from the pre-computed `contact_common_volume_radar` row * `kp_index` — from the daily `solar_indices` row * `foes_mhz` — from the nearest `ionosonde_observations` row * `active_meteor_shower` — looked up from the static shower calendar Results land on the contact as `propagation_mechanism` (string), `propagation_mechanism_confidence` (enum), and `mechanism_status`. Unique on `contact_id` so the submit-time enqueue path and the backfill cron collapse to a single job per contact. """ use Oban.Pro.Worker, queue: :mechanism, max_attempts: 3, unique: [ period: :infinity, states: :incomplete, keys: [:contact_id] ] import Ecto.Query alias Microwaveprop.Ionosphere.Observation, as: IonosondeObservation alias Microwaveprop.Propagation.MechanismClassifier alias Microwaveprop.Radio.Contact alias Microwaveprop.Radio.ContactCommonVolumeRadar alias Microwaveprop.Repo alias Microwaveprop.Weather.HrrrProfile require Logger @impl Oban.Pro.Worker def process(%Oban.Job{args: %{"contact_id" => contact_id}}) do Microwaveprop.Instrument.span([:worker, :mechanism_classify], %{contact_id: contact_id}, fn -> case Repo.get(Contact, contact_id) do nil -> :ok %Contact{pos1: p1, pos2: p2} = contact when is_map(p1) and is_map(p2) -> classify_and_persist(contact) contact -> mark_status(contact, :unavailable, nil, nil) :ok end end) end defp classify_and_persist(%Contact{} = contact) do inputs = build_inputs(contact) %{mechanism: mechanism, confidence: confidence} = MechanismClassifier.classify(inputs) mark_status(contact, :complete, Atom.to_string(mechanism), confidence) :ok rescue e -> Logger.warning("MechanismClassifyWorker: failed for contact #{contact.id}: #{inspect(e)}") # Persist row state so operators can see which contacts blew up, but # return {:error, _} to Oban so the failure registers in job-failure # counters and the job is retried rather than silently swallowed. mark_status(contact, :failed, nil, nil) {:error, inspect(e)} end defp build_inputs(%Contact{} = contact) do %{ band_mhz: Decimal.to_integer(contact.band), distance_km: distance_km(contact), qso_timestamp: contact.qso_timestamp, pos1: contact.pos1, pos2: contact.pos2, user_declared_prop_mode: contact.user_declared_prop_mode, radar: radar_for(contact), duct_either_endpoint: duct_at_either_endpoint?(contact), native_best_duct_ghz: native_duct_at_either_endpoint(contact), kp_index: kp_for(contact), foes_mhz: foes_for(contact), active_meteor_shower: active_shower_at(contact.qso_timestamp) } end defp distance_km(%Contact{distance_km: nil}), do: 0.0 defp distance_km(%Contact{distance_km: d}), do: Decimal.to_float(d) defp radar_for(%Contact{id: id}) do case Repo.get_by(ContactCommonVolumeRadar, contact_id: id) do nil -> nil row -> %{ max_dbz: row.max_dbz, heavy_rain_pixel_count: row.heavy_rain_pixel_count, coverage_pct: row.coverage_pct } end end defp duct_at_either_endpoint?(%Contact{pos1: p1, pos2: p2, qso_timestamp: ts}) do Enum.any?([p1, p2], fn pos -> case nearest_hrrr(pos, ts) do nil -> false %HrrrProfile{ducting_detected: true} -> true _ -> false end end) end defp native_duct_at_either_endpoint(%Contact{pos1: p1, pos2: p2, qso_timestamp: ts}) do [p1, p2] |> Enum.map(&nearest_native_duct(&1, ts)) |> Enum.reject(&is_nil/1) |> case do [] -> nil values -> Enum.max(values) end end defp nearest_hrrr(pos, ts) do lat = pos["lat"] lon = pos["lon"] if lat && lon do Repo.one( from(h in HrrrProfile, where: h.lat >= ^(lat - 0.07) and h.lat <= ^(lat + 0.07) and h.lon >= ^(lon - 0.07) and h.lon <= ^(lon + 0.07) and h.valid_time >= ^DateTime.add(ts, -3600, :second) and h.valid_time <= ^DateTime.add(ts, 3600, :second), order_by: fragment("ABS(EXTRACT(EPOCH FROM ? - ?))", h.valid_time, ^ts), limit: 1 ) ) end end defp nearest_native_duct(pos, ts) do lat = pos["lat"] lon = pos["lon"] if lat && lon do Repo.one( from(h in "hrrr_native_profiles", where: h.lat >= ^(lat - 0.07) and h.lat <= ^(lat + 0.07) and h.lon >= ^(lon - 0.07) and h.lon <= ^(lon + 0.07) and h.valid_time >= ^DateTime.add(ts, -3600, :second) and h.valid_time <= ^DateTime.add(ts, 3600, :second), order_by: fragment("ABS(EXTRACT(EPOCH FROM ? - ?))", h.valid_time, ^ts), limit: 1, select: h.best_duct_band_ghz ) ) end end defp kp_for(%Contact{qso_timestamp: ts}) do date = DateTime.to_date(ts) # solar_indices.kp_values is the daily array of 3-hour Kp readings. # Take the day's peak — that's the signal that matters for aurora. from(s in "solar_indices", where: s.date == ^date, select: s.kp_values, limit: 1) |> Repo.one() |> case do nil -> nil [] -> nil values when is_list(values) -> values |> Enum.reject(&is_nil/1) |> peak_or_nil() end end defp peak_or_nil([]), do: nil defp peak_or_nil(values), do: values |> Enum.max() |> round() # Nearest ionosonde observation within ±1h of QSO timestamp. We don't # restrict by location — foEs is spatially noisy but at least the # "nearest station in the Americas" will catch US-continental Es events. defp foes_for(%Contact{qso_timestamp: ts}) do Repo.one( from(io in IonosondeObservation, where: io.valid_time >= ^DateTime.add(ts, -3600, :second) and io.valid_time <= ^DateTime.add(ts, 3600, :second), where: not is_nil(io.fo_es_mhz), order_by: fragment("ABS(EXTRACT(EPOCH FROM ? - ?))", io.valid_time, ^ts), limit: 1, select: io.fo_es_mhz ) ) end # Static meteor-shower calendar (peak month/day, approximate). Any # contact within ±3 days of a peak counts as "during the shower." # Peaks are stored as {month, day} so year substitution doesn't # poison year-boundary showers (Jan Quadrantids vs Dec Ursids). @showers [ {{1, 4}, "Quadrantids"}, {{4, 22}, "Lyrids"}, {{5, 6}, "Eta Aquariids"}, {{7, 30}, "Southern Delta Aquariids"}, {{8, 12}, "Perseids"}, {{10, 21}, "Orionids"}, {{11, 17}, "Leonids"}, {{12, 14}, "Geminids"}, {{12, 22}, "Ursids"} ] @shower_window_days 3 defp active_shower_at(%DateTime{} = ts) do date = DateTime.to_date(ts) Enum.find_value(@showers, fn {{month, day}, name} -> if shower_active?(date, month, day), do: name end) end # Returns true when `date` falls within `@shower_window_days` of the # shower peak, handling year-boundary showers (e.g. Ursids peak Dec 22 # can match a QSO on Dec 25 in year N or, when the window widens, # early January in year N+1). We check the peak projected onto the # contact's year AND the adjacent years so the ±3-day window works # correctly across Dec 31 → Jan 1. defp shower_active?(%Date{year: year} = date, month, day) do Enum.any?([year - 1, year, year + 1], fn y -> case Date.new(y, month, day) do {:ok, peak} -> abs(Date.diff(peak, date)) <= @shower_window_days _ -> false end end) end defp mark_status(%Contact{} = contact, status, mechanism, confidence) do contact |> Ecto.Changeset.change(%{ mechanism_status: status, propagation_mechanism: mechanism, propagation_mechanism_confidence: confidence }) |> Repo.update!() end end