prop/lib/microwaveprop/workers/mechanism_classify_worker.ex
2026-06-16 12:38:08 -05:00

247 lines
8.1 KiB
Elixir

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