Classifies every contact's likely non-LOS propagation mechanism and persists the result on contacts.propagation_mechanism. Mechanism is determined in priority order: 1. user_declared_prop_mode (ADIF PROP_MODE from the operator log) 2. EME — moon-ephemeris check, ≥2m band, >1800 km path 3. aurora — Kp≥5 + high-lat path, 50-432 MHz 4. sporadic-E — foEs × 5 ≥ band_mhz, 400-2500 km path 5. meteor_scatter — ±3 days of a shower peak, VHF/UHF 6. rain_scatter — common-volume radar heavy rain, 5-11 GHz ≤800 km 7. tropo_duct — HRRR native_best_duct ≥ band or ducting_detected 8. line_of_sight — ≤50 km path 9. troposcatter — default Persisted via MechanismClassifyWorker (queue: :mechanism, unique on contact_id). Submit-time enqueue path includes :mechanism by default; BackfillEnqueueWorker cron now handles :mechanism alongside existing types so prod continuously backfills any contact with propagation_mechanism_status in (:pending, :queued, :failed). Also added :radar to the cron's type list so common-volume radar backfill runs automatically rather than only via `mix radar_backfill`. New modules: - Microwaveprop.Propagation.MoonEphemeris — Meeus low-precision moon position, accuracy ±1° — enough for the mutual-visibility EME test - Microwaveprop.Propagation.MechanismClassifier — plug-in priority chain over the evidence map - Microwaveprop.Workers.MechanismClassifyWorker — assembles inputs from HRRR / native profiles / common-volume radar / solar_indices / ionosonde + calls the classifier ADIF importer now reads PROP_MODE into user_declared_prop_mode so operator-tagged mechanisms (EME/ES/MS/RS/AS/AUR) become ground truth.
225 lines
7.1 KiB
Elixir
225 lines
7.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
|
|
`propagation_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.Worker,
|
|
queue: :mechanism,
|
|
max_attempts: 3,
|
|
unique: [
|
|
period: :infinity,
|
|
states: [:available, :scheduled, :executing, :retryable],
|
|
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.Worker
|
|
def perform(%Oban.Job{args: %{"contact_id" => contact_id}}) do
|
|
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
|
|
|
|
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)}")
|
|
|
|
mark_status(contact, :failed, nil, nil)
|
|
:ok
|
|
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 dates, approximate). Any contact
|
|
# within ±3 days of a peak counts as "during the shower."
|
|
@showers [
|
|
{~D[2024-01-04], "Quadrantids"},
|
|
{~D[2024-04-22], "Lyrids"},
|
|
{~D[2024-05-06], "Eta Aquariids"},
|
|
{~D[2024-07-30], "Southern Delta Aquariids"},
|
|
{~D[2024-08-12], "Perseids"},
|
|
{~D[2024-10-21], "Orionids"},
|
|
{~D[2024-11-17], "Leonids"},
|
|
{~D[2024-12-14], "Geminids"},
|
|
{~D[2024-12-22], "Ursids"}
|
|
]
|
|
|
|
defp active_shower_at(%DateTime{} = ts) do
|
|
date = DateTime.to_date(ts)
|
|
|
|
Enum.find_value(@showers, fn {peak_mmdd, name} ->
|
|
diff = abs(Date.diff(peak_mmdd, %{date | year: peak_mmdd.year}))
|
|
if diff <= 3, do: name
|
|
end)
|
|
end
|
|
|
|
defp mark_status(%Contact{} = contact, status, mechanism, confidence) do
|
|
contact
|
|
|> Ecto.Changeset.change(%{
|
|
propagation_mechanism_status: status,
|
|
propagation_mechanism: mechanism,
|
|
propagation_mechanism_confidence: confidence
|
|
})
|
|
|> Repo.update!()
|
|
end
|
|
end
|