diff --git a/lib/microwaveprop/workers/mechanism_classify_worker.ex b/lib/microwaveprop/workers/mechanism_classify_worker.ex index f1030ef6..b30163cb 100644 --- a/lib/microwaveprop/workers/mechanism_classify_worker.ex +++ b/lib/microwaveprop/workers/mechanism_classify_worker.ex @@ -190,26 +190,44 @@ defmodule Microwaveprop.Workers.MechanismClassifyWorker do ) end - # Static meteor-shower calendar (peak dates, approximate). Any contact - # within ±3 days of a peak counts as "during the shower." + # 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 [ - {~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"} + {{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 {peak_mmdd, name} -> - diff = abs(Date.diff(peak_mmdd, %{date | year: peak_mmdd.year})) - if diff <= 3, do: name + 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 diff --git a/lib/microwaveprop_web/live/path_live.ex b/lib/microwaveprop_web/live/path_live.ex index f4dad3b7..2eb61c85 100644 --- a/lib/microwaveprop_web/live/path_live.ex +++ b/lib/microwaveprop_web/live/path_live.ex @@ -257,8 +257,12 @@ defmodule MicrowavepropWeb.PathLive do hrrr_points = src |> path_sample_points(dst, 9) - |> Enum.map(&label_hrrr_point(&1, now)) - |> Enum.reject(&is_nil/1) + |> Task.async_stream(&label_hrrr_point(&1, now), max_concurrency: 9, timeout: 10_000, on_timeout: :kill_task) + |> Enum.flat_map(fn + {:ok, nil} -> [] + {:ok, point} -> [point] + {:exit, _} -> [] + end) hrrr_profiles = Enum.map(hrrr_points, & &1.profile) diff --git a/test/microwaveprop/workers/mechanism_classify_worker_test.exs b/test/microwaveprop/workers/mechanism_classify_worker_test.exs index 81c8909c..eefc0203 100644 --- a/test/microwaveprop/workers/mechanism_classify_worker_test.exs +++ b/test/microwaveprop/workers/mechanism_classify_worker_test.exs @@ -137,5 +137,33 @@ defmodule Microwaveprop.Workers.MechanismClassifyWorkerTest do 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