CommonVolumeRadarWorker and MechanismClassifyWorker were both
returning :ok on upstream failures, so Prometheus job-failure counters
stayed at zero during multi-hour NEXRAD outages and classifier bugs
went unnoticed.
- CommonVolumeRadarWorker: distinguish permanent (4xx) from transient
errors using the same pattern as RadarFrameWorker. Permanent errors
still mark :unavailable + :ok so the backfill stops re-queueing them.
Transient errors return {:error, reason} and leave radar_status alone
so the next retry can succeed.
- MechanismClassifyWorker: keep the row-status :failed update so
operators can see which contacts blew up, but return
{:error, inspect(e)} from the rescue instead of swallowing it as :ok.
185 lines
6 KiB
Elixir
185 lines
6 KiB
Elixir
defmodule Microwaveprop.Workers.MechanismClassifyWorkerTest do
|
|
use Microwaveprop.DataCase, async: true
|
|
|
|
alias Microwaveprop.Radio.Contact
|
|
alias Microwaveprop.Radio.ContactCommonVolumeRadar
|
|
alias Microwaveprop.Workers.MechanismClassifyWorker
|
|
|
|
defp create_contact(attrs \\ %{}) do
|
|
default = %{
|
|
station1: "W5AA",
|
|
station2: "K5TR",
|
|
qso_timestamp: ~U[2024-08-15 19:00:00Z],
|
|
mode: "CW",
|
|
band: Decimal.new("10000"),
|
|
grid1: "EM12",
|
|
grid2: "EM00",
|
|
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
|
pos2: %{"lat" => 30.3, "lon" => -97.7},
|
|
distance_km: Decimal.new("295")
|
|
}
|
|
|
|
{:ok, contact} =
|
|
%Contact{}
|
|
|> Contact.changeset(Map.merge(default, attrs))
|
|
|> Repo.insert()
|
|
|
|
contact
|
|
end
|
|
|
|
defp create_radar(contact, attrs) do
|
|
{:ok, row} =
|
|
%ContactCommonVolumeRadar{}
|
|
|> ContactCommonVolumeRadar.changeset(
|
|
Map.merge(
|
|
%{
|
|
contact_id: contact.id,
|
|
observed_at: DateTime.to_naive(contact.qso_timestamp),
|
|
max_dbz: 42.0,
|
|
mean_dbz: 28.0,
|
|
pixel_count: 100,
|
|
rain_pixel_count: 80,
|
|
heavy_rain_pixel_count: 15,
|
|
core_pixel_count: 3,
|
|
coverage_pct: 85.0,
|
|
common_volume_km2: 1_200.0
|
|
},
|
|
attrs
|
|
)
|
|
)
|
|
|> Repo.insert()
|
|
|
|
row
|
|
end
|
|
|
|
describe "perform/1" do
|
|
test "classifies a 10 GHz contact with heavy rain in CV as :rain_scatter" do
|
|
contact = create_contact()
|
|
create_radar(contact, %{})
|
|
|
|
assert :ok =
|
|
MechanismClassifyWorker.perform(%Oban.Job{
|
|
args: %{"contact_id" => contact.id}
|
|
})
|
|
|
|
reloaded = Repo.get!(Contact, contact.id)
|
|
assert reloaded.propagation_mechanism == "rain_scatter"
|
|
assert reloaded.mechanism_status == :complete
|
|
assert reloaded.propagation_mechanism_confidence == :high
|
|
end
|
|
|
|
test "classifies a plain 10 GHz short-path contact with no enrichment as :troposcatter" do
|
|
contact = create_contact()
|
|
|
|
assert :ok =
|
|
MechanismClassifyWorker.perform(%Oban.Job{
|
|
args: %{"contact_id" => contact.id}
|
|
})
|
|
|
|
reloaded = Repo.get!(Contact, contact.id)
|
|
assert reloaded.propagation_mechanism == "troposcatter"
|
|
assert reloaded.mechanism_status == :complete
|
|
assert reloaded.propagation_mechanism_confidence == :low
|
|
end
|
|
|
|
test "trusts user_declared_prop_mode='EME' over physics" do
|
|
contact =
|
|
create_contact(%{
|
|
user_declared_prop_mode: "EME",
|
|
band: Decimal.new("1296"),
|
|
distance_km: Decimal.new("400")
|
|
})
|
|
|
|
assert :ok =
|
|
MechanismClassifyWorker.perform(%Oban.Job{
|
|
args: %{"contact_id" => contact.id}
|
|
})
|
|
|
|
reloaded = Repo.get!(Contact, contact.id)
|
|
assert reloaded.propagation_mechanism == "eme"
|
|
assert reloaded.propagation_mechanism_confidence == :high
|
|
end
|
|
|
|
test "short 5 km path classifies as :line_of_sight" do
|
|
contact =
|
|
create_contact(%{
|
|
pos1: %{"lat" => 32.90, "lon" => -97.0},
|
|
pos2: %{"lat" => 32.93, "lon" => -97.0},
|
|
distance_km: Decimal.new("5")
|
|
})
|
|
|
|
assert :ok =
|
|
MechanismClassifyWorker.perform(%Oban.Job{
|
|
args: %{"contact_id" => contact.id}
|
|
})
|
|
|
|
reloaded = Repo.get!(Contact, contact.id)
|
|
assert reloaded.propagation_mechanism == "line_of_sight"
|
|
end
|
|
|
|
test "contact without positions is marked unavailable, not failed" do
|
|
contact = create_contact(%{pos1: nil, pos2: nil})
|
|
|
|
assert :ok =
|
|
MechanismClassifyWorker.perform(%Oban.Job{
|
|
args: %{"contact_id" => contact.id}
|
|
})
|
|
|
|
reloaded = Repo.get!(Contact, contact.id)
|
|
assert reloaded.mechanism_status == :unavailable
|
|
end
|
|
|
|
test "missing contact (deleted) returns :ok gracefully" do
|
|
fake_id = Ecto.UUID.generate()
|
|
|
|
assert :ok =
|
|
MechanismClassifyWorker.perform(%Oban.Job{
|
|
args: %{"contact_id" => fake_id}
|
|
})
|
|
end
|
|
|
|
test "propagates errors from classifier so Oban counts failures + retries, but still marks row :failed" do
|
|
# Force the classifier to blow up by using a non-integer band —
|
|
# `Decimal.to_integer/1` in build_inputs raises on fractional values.
|
|
# This models the "unexpected classifier explosion" that silently
|
|
# dropped failure counts in prod.
|
|
contact = create_contact(%{band: Decimal.new("10.5")})
|
|
|
|
assert {:error, _reason} =
|
|
MechanismClassifyWorker.perform(%Oban.Job{
|
|
args: %{"contact_id" => contact.id}
|
|
})
|
|
|
|
reloaded = Repo.get!(Contact, contact.id)
|
|
assert reloaded.mechanism_status == :failed
|
|
end
|
|
|
|
test "meteor-shower calendar matches across year boundaries" do
|
|
# Quadrantids peak Jan 4, ±3 day window. A 6 m 900 km contact on
|
|
# Dec 31 2025 is 4 days before the Jan 4 2026 peak — should still
|
|
# pass through shower-active matching (though classification may
|
|
# be meteor_scatter only if other mechanisms don't fire first).
|
|
# We assert indirectly: Dec 31 6m 900 km with no duct, no foEs
|
|
# classifies as :meteor_scatter when the shower window handles
|
|
# year-crossing correctly, or :troposcatter when it doesn't.
|
|
contact =
|
|
create_contact(%{
|
|
qso_timestamp: ~U[2025-12-31 10:00:00Z],
|
|
band: Decimal.new("50"),
|
|
distance_km: Decimal.new("900"),
|
|
station1: "W5YR"
|
|
})
|
|
|
|
assert :ok =
|
|
MechanismClassifyWorker.perform(%Oban.Job{
|
|
args: %{"contact_id" => contact.id}
|
|
})
|
|
|
|
reloaded = Repo.get!(Contact, contact.id)
|
|
# Dec 31 is 4 days from Jan 4 — outside the ±3 day window, so we
|
|
# should NOT classify as meteor scatter (Quadrantids) here. This
|
|
# proves the window math isn't accidentally matching everything.
|
|
refute reloaded.propagation_mechanism == "meteor_scatter"
|
|
end
|
|
end
|
|
end
|