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.
54 lines
2.2 KiB
Elixir
54 lines
2.2 KiB
Elixir
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
|