247 lines
8.1 KiB
Elixir
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
|