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:
parent
7ba82f4601
commit
5f8c689035
2 changed files with 191 additions and 53 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue