Improve duct detection and compact contact detail layout

- Use all HRRR profiles along path (start, mid, end) for duct detection
- Fall back to sounding ducts when HRRR too coarse to detect layers
- Merge overlapping ducts, show top 3 strongest with source labels
- Limit surface observations to 5 closest stations along path
- Compact solar conditions as inline row
- Compact band/mode/distance info line
- Fix button text to "Flag as Invalid"
This commit is contained in:
Graham McIntire 2026-04-01 13:28:42 -05:00
parent 7ba82f4601
commit 5f8c689035
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 191 additions and 53 deletions

View file

@ -40,7 +40,11 @@ const ductPlugin = {
const labelY = Math.max(drawTop + 12, top + 12)
ctx.fillStyle = "rgba(34, 197, 94, 0.8)"
ctx.font = "9px sans-serif"
ctx.fillText(`Duct ${Math.round(duct.base_ft)}${Math.round(duct.top_ft)} ft (${duct.strength} M-units)`, left + 4, labelY)
const labels = {hrrr: "Duct", sounding: "Duct (sounding)", inferred: "Est. duct"}
const units = {hrrr: "M-units", sounding: "M-units", inferred: "dN/dh÷10"}
const prefix = labels[duct.source] || "Duct"
const unit = units[duct.source] || "M-units"
ctx.fillText(`${prefix} ${Math.round(duct.base_ft)}${Math.round(duct.top_ft)} ft (${duct.strength} ${unit})`, left + 4, labelY)
}
ctx.restore()
}
@ -64,7 +68,8 @@ export const ElevationProfile = {
const ducts = (data.ducts || []).map(d => ({
base_ft: d.base_m_msl * M2FT,
top_ft: d.top_m_msl * M2FT,
strength: d.strength
strength: d.strength,
source: d.source || "hrrr"
}))
const allElevs = [...elevations, ...losLine]

View file

@ -18,10 +18,11 @@ defmodule MicrowavepropWeb.ContactLive.Show do
weather = load_weather(contact)
solar = load_solar(contact)
hrrr = Weather.hrrr_for_contact(contact)
hrrr_path = Weather.hrrr_profiles_for_path(contact)
hrrr = List.first(hrrr_path)
{hrrr, hrrr_status} = maybe_enqueue_hrrr(hrrr, contact)
terrain = Terrain.get_terrain_profile(contact.id)
elevation_profile = compute_elevation_profile(contact, hrrr)
elevation_profile = compute_elevation_profile(contact, hrrr_path, weather.soundings)
{:ok,
assign(socket,
@ -133,13 +134,36 @@ defmodule MicrowavepropWeb.ContactLive.Show do
defp sounding_sort_key(_), do: fn s -> s.station.name || s.station.station_code end
defp load_weather(contact) do
lat = contact.pos1 && contact.pos1["lat"]
lon = contact.pos1 && (contact.pos1["lon"] || contact.pos1["lng"])
lat1 = contact.pos1 && contact.pos1["lat"]
lon1 = contact.pos1 && (contact.pos1["lon"] || contact.pos1["lng"])
lat2 = contact.pos2 && contact.pos2["lat"]
lon2 = contact.pos2 && (contact.pos2["lon"] || contact.pos2["lng"])
if lat && lon do
Weather.weather_for_contact(%{lat: lat, lon: lon, timestamp: contact.qso_timestamp},
radius_km: 300
)
if lat1 && lon1 do
# Search along the path midpoint with radius covering both endpoints
mid_lat = if lat2, do: (lat1 + lat2) / 2, else: lat1
mid_lon = if lon2, do: (lon1 + lon2) / 2, else: lon1
half_dist = if lat2 && lon2, do: haversine_km(lat1, lon1, lat2, lon2) / 2, else: 0
radius = max(50, half_dist + 50)
weather =
Weather.weather_for_contact(
%{lat: mid_lat, lon: mid_lon, timestamp: contact.qso_timestamp},
radius_km: radius
)
# Keep only the 5 closest stations by distance to path midpoint
%{
surface_observations:
weather.surface_observations
|> Enum.sort_by(fn obs ->
slat = obs.station.lat
slon = obs.station.lon
abs(slat - mid_lat) + abs(slon - mid_lon)
end)
|> Enum.take(5),
soundings: weather.soundings
}
else
%{surface_observations: [], soundings: []}
end
@ -203,7 +227,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
if(@contact.flagged_invalid, do: "btn-warning", else: "btn-ghost")
]}>
<.icon name="hero-flag" class="w-4 h-4" />
{if @contact.flagged_invalid, do: "Unflag", else: "Flag Invalid"}
{if @contact.flagged_invalid, do: "Unflag", else: "Flag as Invalid"}
</button>
<.link navigate={~p"/contacts"} class="btn btn-sm btn-ghost">
<.icon name="hero-arrow-left" class="w-4 h-4" /> Back to Contacts
@ -506,33 +530,36 @@ defmodule MicrowavepropWeb.ContactLive.Show do
<%= if @surface_observations == [] do %>
<p class="text-sm text-base-content/50 italic">No surface observations found nearby.</p>
<% else %>
<.table
id="surface-obs"
rows={@surface_observations}
row_id={fn obs -> "obs-#{obs.id}" end}
sort_by={@obs_sort_by}
sort_order={@obs_sort_order}
sort_target="obs"
>
<:col :let={obs} label="Station" sort_field="station_name">
{obs.station.name || obs.station.station_code}
</:col>
<:col :let={obs} label="Time" sort_field="observed_at">
{Calendar.strftime(obs.observed_at, "%H:%M")}
</:col>
<:col :let={obs} label="Temp (F)" sort_field="temp_f">{obs.temp_f || ""}</:col>
<:col :let={obs} label="Dewpoint (F)" sort_field="dewpoint_f">
{obs.dewpoint_f || ""}
</:col>
<:col :let={obs} label="RH%" sort_field="relative_humidity">
{format_number(obs.relative_humidity)}
</:col>
<:col :let={obs} label="Wind">{format_wind(obs)}</:col>
<:col :let={obs} label="Pressure (mb)" sort_field="sea_level_pressure_mb">
{obs.sea_level_pressure_mb || ""}
</:col>
<:col :let={obs} label="Sky">{obs.sky_condition || ""}</:col>
</.table>
<div class="overflow-x-auto">
<table class="table table-xs table-zebra">
<thead>
<tr>
<th>Station</th>
<th>Time</th>
<th>Temp</th>
<th>Dewpt</th>
<th>RH%</th>
<th>Wind</th>
<th>Pres (mb)</th>
<th>Sky</th>
</tr>
</thead>
<tbody>
<%= for obs <- @surface_observations do %>
<tr>
<td>{obs.station.name || obs.station.station_code}</td>
<td>{Calendar.strftime(obs.observed_at, "%H:%M")}</td>
<td>{obs.temp_f || ""}</td>
<td>{obs.dewpoint_f || ""}</td>
<td>{format_number(obs.relative_humidity)}</td>
<td>{format_wind(obs)}</td>
<td>{obs.sea_level_pressure_mb || ""}</td>
<td>{obs.sky_condition || ""}</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</Layouts.app>
"""
@ -588,7 +615,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
defp terrain_verdict_class("BLOCKED"), do: "badge-error"
defp terrain_verdict_class(_), do: "badge-ghost"
defp compute_elevation_profile(contact, hrrr) do
defp compute_elevation_profile(contact, hrrr_path, soundings) do
with %{"lat" => lat1} <- contact.pos1,
lon1 when is_number(lon1) <- contact.pos1["lon"] || contact.pos1["lng"],
%{"lat" => lat2} <- contact.pos2,
@ -612,7 +639,12 @@ defmodule MicrowavepropWeb.ContactLive.Show do
p -> p.dist_km
end
ducts = extract_ducts(hrrr, tx_elev)
ducts =
hrrr_path
|> extract_ducts(soundings, tx_elev)
|> merge_nearby_ducts()
|> Enum.sort_by(& &1.strength, :desc)
|> Enum.take(3)
%{
points:
@ -634,24 +666,125 @@ defmodule MicrowavepropWeb.ContactLive.Show do
end
end
defp extract_ducts(nil, _surface_elev), do: []
# Extract duct layers from best available source across all HRRR path profiles:
# 1. HRRR explicit ducts (M-profile detected) from any path point
# 2. Sounding explicit ducts (high vertical resolution, most reliable)
# 3. Inferred from strongest HRRR refractivity gradient along path
defp extract_ducts(hrrr_path, soundings, surface_elev) do
hrrr_ducts = extract_hrrr_ducts(hrrr_path, surface_elev)
defp extract_ducts(hrrr, surface_elev) do
case hrrr.duct_characteristics do
ducts when is_list(ducts) and ducts != [] ->
Enum.map(ducts, fn d ->
%{
base_m_msl: surface_elev + (d["base"] || 0),
top_m_msl: surface_elev + (d["top"] || 0),
strength: d["strength"]
}
end)
if hrrr_ducts != [] do
hrrr_ducts
else
sounding_ducts = extract_sounding_ducts(soundings, surface_elev)
_ ->
[]
if sounding_ducts != [] do
sounding_ducts
else
# Use the profile with the strongest (most negative) gradient
best =
hrrr_path
|> Enum.filter(&is_number(&1.min_refractivity_gradient))
|> Enum.min_by(& &1.min_refractivity_gradient, fn -> nil end)
infer_duct_from_gradient(best, surface_elev)
end
end
end
defp extract_hrrr_ducts(hrrr_path, surface_elev) when is_list(hrrr_path) do
hrrr_path
|> Enum.flat_map(fn h ->
case h.duct_characteristics do
ducts when is_list(ducts) and ducts != [] ->
Enum.map(ducts, fn d ->
%{
base_m_msl: surface_elev + (d["base"] || 0),
top_m_msl: surface_elev + (d["top"] || 0),
strength: d["strength"],
source: "hrrr"
}
end)
_ ->
[]
end
end)
|> Enum.uniq_by(fn d -> {round(d.base_m_msl), round(d.top_m_msl)} end)
end
defp extract_sounding_ducts(soundings, surface_elev) when is_list(soundings) do
soundings
|> Enum.filter(& &1.ducting_detected)
|> Enum.flat_map(fn s ->
case s.duct_characteristics do
ducts when is_list(ducts) and ducts != [] ->
Enum.map(ducts, fn d ->
%{
base_m_msl: surface_elev + (d["base"] || 0),
top_m_msl: surface_elev + (d["top"] || 0),
strength: d["strength"],
source: "sounding"
}
end)
_ ->
[]
end
end)
|> Enum.uniq_by(fn d -> {round(d.base_m_msl), round(d.top_m_msl)} end)
end
defp extract_sounding_ducts(_, _), do: []
# HRRR pressure levels are too coarse (~250m) to detect thin ducting layers
# via the M-profile method. When min_refractivity_gradient indicates enhanced
# propagation, infer a duct layer within the boundary layer.
defp infer_duct_from_gradient(nil, _surface_elev), do: []
defp infer_duct_from_gradient(hrrr, surface_elev) do
grad = hrrr.min_refractivity_gradient
hpbl = hrrr.hpbl_m
if is_number(grad) and grad < -100 and is_number(hpbl) and hpbl > 0 do
duct_top = min(hpbl * 0.6, 500)
strength = abs(grad) / 10
[
%{
base_m_msl: surface_elev,
top_m_msl: surface_elev + duct_top,
strength: Float.round(strength, 1),
source: "inferred"
}
]
else
[]
end
end
# Merge duct layers that overlap or are within 100m of each other
defp merge_nearby_ducts(ducts) do
ducts
|> Enum.sort_by(& &1.base_m_msl)
|> Enum.reduce([], fn duct, acc ->
case acc do
[prev | rest] when duct.base_m_msl <= prev.top_m_msl + 100 ->
merged = %{
prev
| top_m_msl: max(prev.top_m_msl, duct.top_m_msl),
strength: max(prev.strength, duct.strength)
}
[merged | rest]
_ ->
[duct | acc]
end
end)
|> Enum.reverse()
end
defp band_to_ghz(nil), do: 10.0
defp band_to_ghz(band) do