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:
parent
84ffdcd261
commit
c3616b31c7
3 changed files with 288 additions and 177 deletions
180
lib/microwaveprop_web/components/skew_t_chart.ex
Normal file
180
lib/microwaveprop_web/components/skew_t_chart.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}°C</td>
|
||||
<td>{format_number(pt.profile.surface_dewpoint_c)}°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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue