Surface HRRR weather details on /path

Adds an Atmospheric Profile section to the link calculator that shows
the HRRR data actually feeding the prediction: valid/run time, a
per-point table (src/mid/dst) with surface temp, dewpoint, pressure,
HPBL, PWAT, surface refractivity and dN/dh, plus a skew-T log-P chart
for the midpoint vertical profile.

Pulls the skew-T chart function out of contact_live into a shared
component so both LiveViews can render it without duplication.
This commit is contained in:
Graham McIntire 2026-04-15 11:57:46 -05:00
parent 84ffdcd261
commit c3616b31c7
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 288 additions and 177 deletions

View file

@ -0,0 +1,180 @@
defmodule MicrowavepropWeb.Components.SkewTChart do
@moduledoc """
Function component that renders a skew-T log-P diagram from a
pressure-level profile (list of `%{"pres", "tmpc", "dwpc"}` maps).
Returns empty output when the profile has fewer than three usable
levels.
"""
use Phoenix.Component
alias MicrowavepropWeb.SkewT
attr :profile, :list, required: true
def skew_t_chart(assigns) do
chart = SkewT.build(assigns.profile)
usable_points = if chart, do: length(chart.t_points), else: 0
assigns = assign(assigns, chart: chart, usable_points: usable_points)
~H"""
<div :if={@chart && @usable_points >= 2} class="mt-3">
<div class="text-xs font-semibold uppercase tracking-wider opacity-60 mb-1">
Skew-T log-P
</div>
<svg
viewBox={"0 0 #{@chart.width} #{@chart.height}"}
class="w-full max-w-[600px] bg-base-100 rounded border border-base-300"
role="img"
aria-label="Skew-T log-P diagram"
>
<%!-- Plot background --%>
<rect
x={@chart.padding_left}
y={@chart.padding_top}
width={@chart.plot_width}
height={@chart.plot_height}
fill="currentColor"
class="text-base-200/40"
/>
<%!-- Clip background curves to the plot area so skewed lines
don't bleed over the axes. --%>
<defs>
<clipPath id="skewt-clip">
<rect
x={@chart.padding_left}
y={@chart.padding_top}
width={@chart.plot_width}
height={@chart.plot_height}
/>
</clipPath>
</defs>
<g clip-path="url(#skewt-clip)">
<%!-- Dry adiabats (orange curves) --%>
<path
:for={adiabat <- @chart.dry_adiabats}
d={adiabat.path}
fill="none"
stroke="#f59e0b"
stroke-width="0.6"
stroke-opacity="0.45"
/>
<%!-- Mixing ratio lines (dashed teal) --%>
<path
:for={line <- @chart.mixing_ratio_lines}
d={line.path}
fill="none"
stroke="#0d9488"
stroke-width="0.6"
stroke-opacity="0.5"
stroke-dasharray="2 3"
/>
<%!-- Isotherms (gray) --%>
<line
:for={iso <- @chart.isotherms}
x1={iso.x1}
y1={iso.y1}
x2={iso.x2}
y2={iso.y2}
stroke="currentColor"
stroke-width="0.5"
class="text-base-content/25"
/>
<%!-- Isobars (gray, across plot) --%>
<line
:for={bar <- @chart.isobars}
x1={bar.x_start}
y1={bar.y}
x2={bar.x_end}
y2={bar.y}
stroke="currentColor"
stroke-width="0.5"
class="text-base-content/25"
/>
<%!-- Dewpoint trace --%>
<path
d={@chart.td_path}
fill="none"
stroke={@chart.dewpoint_color}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle
:for={{x, y} <- @chart.td_points}
cx={x}
cy={y}
r="2"
fill={@chart.dewpoint_color}
/>
<%!-- Temperature trace --%>
<path
d={@chart.t_path}
fill="none"
stroke={@chart.temp_color}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle
:for={{x, y} <- @chart.t_points}
cx={x}
cy={y}
r="2"
fill={@chart.temp_color}
/>
</g>
<%!-- Pressure axis labels (left side) --%>
<text
:for={bar <- @chart.isobars}
x={@chart.padding_left - 6}
y={bar.label_y}
font-size="10"
text-anchor="end"
fill="currentColor"
class="text-base-content/60"
>
{bar.label}
</text>
<%!-- Temperature axis labels (bottom, only those inside the visible band) --%>
<text
:for={iso <- @chart.isotherms}
:if={iso.label_x && iso.t_c >= -40 && iso.t_c <= 40 && rem(iso.t_c, 20) == 0}
x={iso.label_x}
y={iso.label_y}
font-size="10"
text-anchor="middle"
fill="currentColor"
class="text-base-content/60"
>
{iso.t_c}°C
</text>
<%!-- Legend --%>
<g transform={"translate(#{@chart.padding_left + 8}, #{@chart.padding_top + 12})"}>
<line x1="0" y1="0" x2="18" y2="0" stroke={@chart.temp_color} stroke-width="2" />
<text x="22" y="3" font-size="10" fill="currentColor" class="text-base-content/70">
Temperature
</text>
<line x1="0" y1="14" x2="18" y2="14" stroke={@chart.dewpoint_color} stroke-width="2" />
<text x="22" y="17" font-size="10" fill="currentColor" class="text-base-content/70">
Dewpoint
</text>
</g>
</svg>
<div class="text-[11px] opacity-60 mt-1">
Orange curves: dry adiabats · dashed teal: saturation mixing ratio ·
thin gray: isotherms and isobars
</div>
</div>
"""
end
end

View file

@ -2,6 +2,8 @@ defmodule MicrowavepropWeb.ContactLive.Show do
@moduledoc false
use MicrowavepropWeb, :live_view
import MicrowavepropWeb.Components.SkewTChart
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.Scorer
alias Microwaveprop.Radio
@ -16,7 +18,6 @@ defmodule MicrowavepropWeb.ContactLive.Show do
alias Microwaveprop.Workers.HrrrFetchWorker
alias Microwaveprop.Workers.SolarIndexWorker
alias Microwaveprop.Workers.TerrainProfileWorker
alias MicrowavepropWeb.SkewT
require Logger
@ -2171,176 +2172,4 @@ defmodule MicrowavepropWeb.ContactLive.Show do
defp maybe_put_band(params, band) when is_integer(band), do: Map.put(params, "band", Integer.to_string(band))
defp maybe_put_band(params, band) when is_binary(band), do: Map.put(params, "band", band)
defp maybe_put_band(params, _), do: params
# Render a skew-T log-P diagram from a pressure-level profile
# (list of `%{"pres", "tmpc", "dwpc"}` maps). Returns empty output
# when the profile has fewer than three usable levels.
attr :profile, :list, required: true
defp skew_t_chart(assigns) do
chart = SkewT.build(assigns.profile)
usable_points = if chart, do: length(chart.t_points), else: 0
assigns = assign(assigns, chart: chart, usable_points: usable_points)
~H"""
<div :if={@chart && @usable_points >= 2} class="mt-3">
<div class="text-xs font-semibold uppercase tracking-wider opacity-60 mb-1">
Skew-T log-P
</div>
<svg
viewBox={"0 0 #{@chart.width} #{@chart.height}"}
class="w-full max-w-[600px] bg-base-100 rounded border border-base-300"
role="img"
aria-label="Skew-T log-P diagram"
>
<%!-- Plot background --%>
<rect
x={@chart.padding_left}
y={@chart.padding_top}
width={@chart.plot_width}
height={@chart.plot_height}
fill="currentColor"
class="text-base-200/40"
/>
<%!-- Clip background curves to the plot area so skewed lines
don't bleed over the axes. --%>
<defs>
<clipPath id="skewt-clip">
<rect
x={@chart.padding_left}
y={@chart.padding_top}
width={@chart.plot_width}
height={@chart.plot_height}
/>
</clipPath>
</defs>
<g clip-path="url(#skewt-clip)">
<%!-- Dry adiabats (orange curves) --%>
<path
:for={adiabat <- @chart.dry_adiabats}
d={adiabat.path}
fill="none"
stroke="#f59e0b"
stroke-width="0.6"
stroke-opacity="0.45"
/>
<%!-- Mixing ratio lines (dashed teal) --%>
<path
:for={line <- @chart.mixing_ratio_lines}
d={line.path}
fill="none"
stroke="#0d9488"
stroke-width="0.6"
stroke-opacity="0.5"
stroke-dasharray="2 3"
/>
<%!-- Isotherms (gray) --%>
<line
:for={iso <- @chart.isotherms}
x1={iso.x1}
y1={iso.y1}
x2={iso.x2}
y2={iso.y2}
stroke="currentColor"
stroke-width="0.5"
class="text-base-content/25"
/>
<%!-- Isobars (gray, across plot) --%>
<line
:for={bar <- @chart.isobars}
x1={bar.x_start}
y1={bar.y}
x2={bar.x_end}
y2={bar.y}
stroke="currentColor"
stroke-width="0.5"
class="text-base-content/25"
/>
<%!-- Dewpoint trace --%>
<path
d={@chart.td_path}
fill="none"
stroke={@chart.dewpoint_color}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle
:for={{x, y} <- @chart.td_points}
cx={x}
cy={y}
r="2"
fill={@chart.dewpoint_color}
/>
<%!-- Temperature trace --%>
<path
d={@chart.t_path}
fill="none"
stroke={@chart.temp_color}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle
:for={{x, y} <- @chart.t_points}
cx={x}
cy={y}
r="2"
fill={@chart.temp_color}
/>
</g>
<%!-- Pressure axis labels (left side) --%>
<text
:for={bar <- @chart.isobars}
x={@chart.padding_left - 6}
y={bar.label_y}
font-size="10"
text-anchor="end"
fill="currentColor"
class="text-base-content/60"
>
{bar.label}
</text>
<%!-- Temperature axis labels (bottom, only those inside the visible band) --%>
<text
:for={iso <- @chart.isotherms}
:if={iso.label_x && iso.t_c >= -40 && iso.t_c <= 40 && rem(iso.t_c, 20) == 0}
x={iso.label_x}
y={iso.label_y}
font-size="10"
text-anchor="middle"
fill="currentColor"
class="text-base-content/60"
>
{iso.t_c}°C
</text>
<%!-- Legend --%>
<g transform={"translate(#{@chart.padding_left + 8}, #{@chart.padding_top + 12})"}>
<line x1="0" y1="0" x2="18" y2="0" stroke={@chart.temp_color} stroke-width="2" />
<text x="22" y="3" font-size="10" fill="currentColor" class="text-base-content/70">
Temperature
</text>
<line x1="0" y1="14" x2="18" y2="14" stroke={@chart.dewpoint_color} stroke-width="2" />
<text x="22" y="17" font-size="10" fill="currentColor" class="text-base-content/70">
Dewpoint
</text>
</g>
</svg>
<div class="text-[11px] opacity-60 mt-1">
Orange curves: dry adiabats · dashed teal: saturation mixing ratio ·
thin gray: isotherms and isobars
</div>
</div>
"""
end
end

View file

@ -2,6 +2,8 @@ defmodule MicrowavepropWeb.PathLive do
@moduledoc false
use MicrowavepropWeb, :live_view
import MicrowavepropWeb.Components.SkewTChart
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.Scorer
@ -230,11 +232,17 @@ defmodule MicrowavepropWeb.PathLive do
midlat = (src.lat + dst.lat) / 2
midlon = (src.lon + dst.lon) / 2
hrrr_profiles =
[{src.lat, src.lon}, {midlat, midlon}, {dst.lat, dst.lon}]
|> Enum.map(fn {lat, lon} -> Weather.find_nearest_hrrr(lat, lon, now) end)
hrrr_points =
[
{"Source", src.lat, src.lon},
{"Midpoint", midlat, midlon},
{"Destination", dst.lat, dst.lon}
]
|> Enum.map(&label_hrrr_point(&1, now))
|> Enum.reject(&is_nil/1)
hrrr_profiles = Enum.map(hrrr_points, & &1.profile)
# Build conditions and score
{conditions, scoring} = build_scoring(hrrr_profiles, src, now, band_config)
@ -261,7 +269,8 @@ defmodule MicrowavepropWeb.PathLive do
loss_budget: loss_budget,
power_budget: power_budget,
forecast: forecast,
hrrr_count: length(hrrr_profiles)
hrrr_count: length(hrrr_profiles),
hrrr_points: hrrr_points
}}
end
end
@ -312,6 +321,13 @@ defmodule MicrowavepropWeb.PathLive do
Regex.match?(~r/^[A-Ra-r]{2}[0-9]{2}/i, input)
end
defp label_hrrr_point({label, lat, lon}, now) do
case Weather.find_nearest_hrrr(lat, lon, now) do
nil -> nil
profile -> %{label: label, profile: profile}
end
end
defp build_scoring([], _src, _now, _band_config), do: {nil, nil}
defp build_scoring(profiles, src, now, band_config) do
@ -845,6 +861,11 @@ defmodule MicrowavepropWeb.PathLive do
<% end %>
</div>
<%!-- Atmospheric Profile (HRRR) --%>
<%= if @result.hrrr_points != [] do %>
<.atmospheric_profile hrrr_points={@result.hrrr_points} />
<% end %>
<%!-- 18-Hour Forecast --%>
<%= if @result.forecast != [] do %>
<.forecast_chart forecast={@result.forecast} band_config={@result.band_config} />
@ -864,6 +885,87 @@ defmodule MicrowavepropWeb.PathLive do
"""
end
attr :hrrr_points, :list, required: true
defp atmospheric_profile(assigns) do
mid = Enum.find(assigns.hrrr_points, &(&1.label == "Midpoint")) || hd(assigns.hrrr_points)
valid_time = mid.profile.valid_time
run_time = Map.get(mid.profile, :run_time)
any_ducting? = Enum.any?(assigns.hrrr_points, & &1.profile.ducting_detected)
assigns =
assigns
|> assign(:mid, mid)
|> assign(:valid_time, valid_time)
|> assign(:run_time, run_time)
|> assign(:any_ducting, any_ducting?)
~H"""
<div class="bg-base-200 rounded-box p-4 mb-6">
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 mb-3">
<h3 class="text-base font-semibold">Atmospheric Profile</h3>
<span class="badge badge-outline badge-sm whitespace-nowrap">HRRR (3 km)</span>
<span class="text-xs opacity-60 whitespace-nowrap">
Valid {Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
</span>
<%= if @run_time do %>
<span class="text-xs opacity-60 whitespace-nowrap">
Run {Calendar.strftime(@run_time, "%Y-%m-%d %H:%M UTC")}
</span>
<% end %>
<span class="text-xs opacity-60 whitespace-nowrap">
{length(@hrrr_points)} / 3 path points
</span>
<%= if @any_ducting do %>
<span class="badge badge-warning badge-sm whitespace-nowrap">Ducting detected</span>
<% end %>
</div>
<div class="overflow-x-auto">
<table class="table table-xs table-zebra">
<thead>
<tr>
<th>Point</th>
<th>Lat, Lon</th>
<th>Temp</th>
<th>Dewpt</th>
<th>Press</th>
<th>HPBL</th>
<th>PWAT</th>
<th>N<sub>s</sub></th>
<th>dN/dh</th>
</tr>
</thead>
<tbody>
<%= for pt <- @hrrr_points do %>
<tr>
<td class="font-semibold">{pt.label}</td>
<td class="font-mono opacity-70">
{format_number(pt.profile.lat)}, {format_number(pt.profile.lon)}
</td>
<td>{format_number(pt.profile.surface_temp_c)}&deg;C</td>
<td>{format_number(pt.profile.surface_dewpoint_c)}&deg;C</td>
<td>{format_number(pt.profile.surface_pressure_mb)} mb</td>
<td>{format_number(pt.profile.hpbl_m)} m</td>
<td>{format_number(pt.profile.pwat_mm)} mm</td>
<td>{format_number(pt.profile.surface_refractivity)}</td>
<td>{format_number(pt.profile.min_refractivity_gradient)}</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<%= if @mid.profile.profile && @mid.profile.profile != [] do %>
<div class="text-xs opacity-60 mt-4">
Midpoint vertical profile
</div>
<.skew_t_chart profile={@mid.profile.profile} />
<% end %>
</div>
"""
end
attr :forecast, :list, required: true
attr :band_config, :map, required: true