From d3ca2e5791ef539c7291ff3692a36bb1472c3484 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 21 Apr 2026 17:21:06 -0500 Subject: [PATCH] fix(map): dedupe duplicated forecast-timeline chips + extract Mechanism helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The main /map timeline was rendering each forecast hour twice ('Now 22:00' + '0h 22:00', '+1h 23:00' + '+1h 23:00', …). Root cause: during the rolling `.ntms` → `.prop` rename window, both extensions live on NFS for the same valid_time. `list_score_files` matches either, so `list_valid_times_raw` mapped 2 files → 2 DateTimes with no dedupe. Propagation.available_valid_times inherited the dup, push_timeline pushed it to the JS hook, renderTimeline drew one chip per entry — hence the visible duplicate. Fix: `Enum.uniq` before the sort. Regression test writes a `.prop` file, copies it as the same valid_time's `.ntms` legacy twin, and asserts list_valid_times returns a single DateTime. Also land a small refactor that was queued behind this: move the three pure mechanism display helpers (label/1, badge_class/1, explainer/1) out of the 2500-line contact_live/show.ex into a new MicrowavepropWeb.ContactLive.Mechanism module. No behavior change — just shrinks the LiveView and gives the display mapping a testable home. --- lib/microwaveprop/propagation/scores_file.ex | 5 ++ .../live/contact_live/mechanism.ex | 54 +++++++++++++++++++ .../live/contact_live/show.ex | 40 +++----------- .../propagation/scores_file_test.exs | 15 ++++++ 4 files changed, 81 insertions(+), 33 deletions(-) create mode 100644 lib/microwaveprop_web/live/contact_live/mechanism.ex diff --git a/lib/microwaveprop/propagation/scores_file.ex b/lib/microwaveprop/propagation/scores_file.ex index 1a78ff39..1e232cc8 100644 --- a/lib/microwaveprop/propagation/scores_file.ex +++ b/lib/microwaveprop/propagation/scores_file.ex @@ -188,9 +188,14 @@ defmodule Microwaveprop.Propagation.ScoresFile do case File.ls(dir) do {:ok, files} -> + # Dedupe: during the `.ntms` → `.prop` rollout, both extensions + # can coexist for the same valid_time. `list_score_files` accepts + # either form, so without uniq the caller sees each forecast hour + # twice (visible as a duplicated chip in the map's timeline). files |> Enum.map(&parse_valid_time_dt/1) |> Enum.reject(&is_nil/1) + |> Enum.uniq() |> Enum.sort(DateTime) _ -> diff --git a/lib/microwaveprop_web/live/contact_live/mechanism.ex b/lib/microwaveprop_web/live/contact_live/mechanism.ex new file mode 100644 index 00000000..35a61138 --- /dev/null +++ b/lib/microwaveprop_web/live/contact_live/mechanism.ex @@ -0,0 +1,54 @@ +defmodule MicrowavepropWeb.ContactLive.Mechanism do + @moduledoc """ + Display helpers for the propagation-mechanism badge shown on + `/contacts/:id`. The underlying classification lives in + `Microwaveprop.Propagation.MechanismClassifier`; this module just + translates the atom into a human label, a daisyUI badge class, and + a one-line explainer. + + Extracted from the `ContactLive.Show` module as part of the + 2026-04 cleanup — pure functions with no LiveView coupling belong + in their own file so the view itself shrinks toward render logic. + """ + + @type mechanism :: + :likely_rainscatter + | :rainscatter_possible + | :tropo_duct + | :troposcatter + | :unknown + | nil + + @spec label(mechanism()) :: String.t() + def label(:likely_rainscatter), do: "Likely Rain Scatter" + def label(:rainscatter_possible), do: "Rain Scatter Possible" + def label(:tropo_duct), do: "Tropospheric Duct" + def label(:troposcatter), do: "Troposcatter" + def label(:unknown), do: "Unclassified" + def label(_), do: "Analyzing…" + + @spec badge_class(mechanism()) :: String.t() + def badge_class(:likely_rainscatter), do: "badge-info" + def badge_class(:rainscatter_possible), do: "badge-info badge-outline" + def badge_class(:tropo_duct), do: "badge-warning" + def badge_class(:troposcatter), do: "badge-ghost" + def badge_class(:unknown), do: "badge-ghost" + def badge_class(_), do: "badge-ghost" + + @spec explainer(mechanism()) :: String.t() + def explainer(:likely_rainscatter), + do: + "Heavy precipitation detected inside the common volume between the two stations — the most likely carrier at this frequency and distance." + + def explainer(:rainscatter_possible), + do: "Light-to-moderate precipitation in the common volume, no ducting signature — scatter is plausible but marginal." + + def explainer(:tropo_duct), + do: "HRRR profile shows a trapping layer (refractivity gradient below −157 N/km) at one of the endpoints." + + def explainer(:troposcatter), + do: "No duct, no meaningful rain inside the common volume — default tropospheric forward scatter." + + def explainer(:unknown), do: "Not enough radar/profile coverage to distinguish mechanisms." + def explainer(_), do: "" +end diff --git a/lib/microwaveprop_web/live/contact_live/show.ex b/lib/microwaveprop_web/live/contact_live/show.ex index eebad038..633e6b00 100644 --- a/lib/microwaveprop_web/live/contact_live/show.ex +++ b/lib/microwaveprop_web/live/contact_live/show.ex @@ -19,6 +19,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do alias Microwaveprop.Workers.NarrFetchWorker alias Microwaveprop.Workers.SolarIndexWorker alias Microwaveprop.Workers.TerrainProfileWorker + alias MicrowavepropWeb.ContactLive.Mechanism require Logger @@ -1140,10 +1141,10 @@ defmodule MicrowavepropWeb.ContactLive.Show do

Propagation Mechanism

- - {mechanism_label(@mechanism)} + + {Mechanism.label(@mechanism)} - {mechanism_explainer(@mechanism)} + {Mechanism.explainer(@mechanism)}
<%= if @radar do %>
@@ -1674,36 +1675,9 @@ defmodule MicrowavepropWeb.ContactLive.Show do defp format_number(n, _), do: to_string(n) - defp mechanism_label(:likely_rainscatter), do: "Likely Rain Scatter" - defp mechanism_label(:rainscatter_possible), do: "Rain Scatter Possible" - defp mechanism_label(:tropo_duct), do: "Tropospheric Duct" - defp mechanism_label(:troposcatter), do: "Troposcatter" - defp mechanism_label(:unknown), do: "Unclassified" - defp mechanism_label(_), do: "Analyzing…" - - defp mechanism_badge_class(:likely_rainscatter), do: "badge-info" - defp mechanism_badge_class(:rainscatter_possible), do: "badge-info badge-outline" - defp mechanism_badge_class(:tropo_duct), do: "badge-warning" - defp mechanism_badge_class(:troposcatter), do: "badge-ghost" - defp mechanism_badge_class(:unknown), do: "badge-ghost" - defp mechanism_badge_class(_), do: "badge-ghost" - - defp mechanism_explainer(:likely_rainscatter), - do: - "Heavy precipitation detected inside the common volume between the two stations — the most likely carrier at this frequency and distance." - - defp mechanism_explainer(:rainscatter_possible), - do: "Light-to-moderate precipitation in the common volume, no ducting signature — scatter is plausible but marginal." - - defp mechanism_explainer(:tropo_duct), - do: "HRRR profile shows a trapping layer (refractivity gradient below −157 N/km) at one of the endpoints." - - defp mechanism_explainer(:troposcatter), - do: "No duct, no meaningful rain inside the common volume — default tropospheric forward scatter." - - defp mechanism_explainer(:unknown), do: "Not enough radar/profile coverage to distinguish mechanisms." - - defp mechanism_explainer(_), do: "" + # mechanism_label/1, mechanism_badge_class/1, mechanism_explainer/1 + # live in MicrowavepropWeb.ContactLive.Mechanism (pure helpers, no + # LiveView coupling). Call via Mechanism.label/1 etc. in render. defp wind_speed_from_components(u, v) when is_number(u) and is_number(v) do mps = :math.sqrt(u * u + v * v) diff --git a/test/microwaveprop/propagation/scores_file_test.exs b/test/microwaveprop/propagation/scores_file_test.exs index 564dbd64..309b4552 100644 --- a/test/microwaveprop/propagation/scores_file_test.exs +++ b/test/microwaveprop/propagation/scores_file_test.exs @@ -192,6 +192,21 @@ defmodule Microwaveprop.Propagation.ScoresFileTest do test "returns an empty list when the band dir doesn't exist" do assert ScoresFile.list_valid_times(75_000) == [] end + + test "dedupes when a valid_time exists as both `.prop` and `.ntms`" do + # Simulate the rolling rename window: the new writer just dropped a + # .prop file; the prior cycle's .ntms file for the same valid_time + # is still on NFS. A naive implementation surfaces both → the /map + # timeline renders the same chip twice. + valid_time = ~U[2026-04-21 22:00:00Z] + ScoresFile.write!(10_000, valid_time, []) + + base = Application.fetch_env!(:microwaveprop, :propagation_scores_dir) + legacy = Path.join([base, "10000", "2026-04-21T22-00-00Z.ntms"]) + :ok = File.cp!(ScoresFile.path_for(10_000, valid_time), legacy) + + assert ScoresFile.list_valid_times(10_000) == [valid_time] + end end describe "latest_valid_time/0" do