prop/test/microwaveprop/workers/mechanism_classify_worker_test.exs
Graham McIntire 2874e6a93b
perf+fix: parallelize path HRRR queries; fix meteor-shower year boundary
Audit pass found two real issues (most agent-flagged items were false
positives — the hrrr_native_profiles composite index exists,
pwat_mm/bl_depth_m are already in the path conditions map, and the
/1.0 in MechanismClassifier is the standard Elixir int→float coerce).

1. Path calculator's 9-point HRRR sampling was running sequentially,
   ~9× the latency of a single query. Task.async_stream with
   max_concurrency: 9 makes them concurrent so path calc pays roughly
   one query's worth of time instead of nine.

2. MechanismClassifyWorker's meteor-shower window used hardcoded 2024
   peak dates with `%{date | year: peak.year}` projection. That broke
   across year boundaries — a Dec 30 contact couldn't match a Jan 4
   Quadrantids peak even in-range of the ±3 day window. Switched to
   {month, day} tuples and check the peak projected onto year N-1, N,
   N+1 so the window works correctly near Dec 31 / Jan 1.
2026-04-18 12:08:42 -05:00

169 lines
5.4 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.propagation_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.propagation_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.propagation_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 "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