prop/lib/microwaveprop_web/live/contact_live/mechanism.ex
Graham McIntire d3ca2e5791
fix(map): dedupe duplicated forecast-timeline chips + extract Mechanism helpers
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.
2026-04-21 17:21:11 -05:00

54 lines
2.2 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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