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.
This commit is contained in:
parent
1c1a253fbe
commit
d3ca2e5791
4 changed files with 81 additions and 33 deletions
|
|
@ -188,9 +188,14 @@ defmodule Microwaveprop.Propagation.ScoresFile do
|
||||||
|
|
||||||
case File.ls(dir) do
|
case File.ls(dir) do
|
||||||
{:ok, files} ->
|
{: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
|
files
|
||||||
|> Enum.map(&parse_valid_time_dt/1)
|
|> Enum.map(&parse_valid_time_dt/1)
|
||||||
|> Enum.reject(&is_nil/1)
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|> Enum.uniq()
|
||||||
|> Enum.sort(DateTime)
|
|> Enum.sort(DateTime)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
|
|
|
||||||
54
lib/microwaveprop_web/live/contact_live/mechanism.ex
Normal file
54
lib/microwaveprop_web/live/contact_live/mechanism.ex
Normal file
|
|
@ -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
|
||||||
|
|
@ -19,6 +19,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
alias Microwaveprop.Workers.NarrFetchWorker
|
alias Microwaveprop.Workers.NarrFetchWorker
|
||||||
alias Microwaveprop.Workers.SolarIndexWorker
|
alias Microwaveprop.Workers.SolarIndexWorker
|
||||||
alias Microwaveprop.Workers.TerrainProfileWorker
|
alias Microwaveprop.Workers.TerrainProfileWorker
|
||||||
|
alias MicrowavepropWeb.ContactLive.Mechanism
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
|
@ -1140,10 +1141,10 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
<h2 class="text-base font-semibold mb-2">Propagation Mechanism</h2>
|
<h2 class="text-base font-semibold mb-2">Propagation Mechanism</h2>
|
||||||
<div class="mb-6 border border-base-300 rounded-lg p-4 text-sm">
|
<div class="mb-6 border border-base-300 rounded-lg p-4 text-sm">
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
<span class={["badge badge-sm", mechanism_badge_class(@mechanism)]}>
|
<span class={["badge badge-sm", Mechanism.badge_class(@mechanism)]}>
|
||||||
{mechanism_label(@mechanism)}
|
{Mechanism.label(@mechanism)}
|
||||||
</span>
|
</span>
|
||||||
<span class="opacity-70">{mechanism_explainer(@mechanism)}</span>
|
<span class="opacity-70">{Mechanism.explainer(@mechanism)}</span>
|
||||||
</div>
|
</div>
|
||||||
<%= if @radar do %>
|
<%= if @radar do %>
|
||||||
<div class="mt-3 flex flex-wrap gap-4 text-xs opacity-80">
|
<div class="mt-3 flex flex-wrap gap-4 text-xs opacity-80">
|
||||||
|
|
@ -1674,36 +1675,9 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
|
|
||||||
defp format_number(n, _), do: to_string(n)
|
defp format_number(n, _), do: to_string(n)
|
||||||
|
|
||||||
defp mechanism_label(:likely_rainscatter), do: "Likely Rain Scatter"
|
# mechanism_label/1, mechanism_badge_class/1, mechanism_explainer/1
|
||||||
defp mechanism_label(:rainscatter_possible), do: "Rain Scatter Possible"
|
# live in MicrowavepropWeb.ContactLive.Mechanism (pure helpers, no
|
||||||
defp mechanism_label(:tropo_duct), do: "Tropospheric Duct"
|
# LiveView coupling). Call via Mechanism.label/1 etc. in render.
|
||||||
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: ""
|
|
||||||
|
|
||||||
defp wind_speed_from_components(u, v) when is_number(u) and is_number(v) do
|
defp wind_speed_from_components(u, v) when is_number(u) and is_number(v) do
|
||||||
mps = :math.sqrt(u * u + v * v)
|
mps = :math.sqrt(u * u + v * v)
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,21 @@ defmodule Microwaveprop.Propagation.ScoresFileTest do
|
||||||
test "returns an empty list when the band dir doesn't exist" do
|
test "returns an empty list when the band dir doesn't exist" do
|
||||||
assert ScoresFile.list_valid_times(75_000) == []
|
assert ScoresFile.list_valid_times(75_000) == []
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe "latest_valid_time/0" do
|
describe "latest_valid_time/0" do
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue