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)
|
const labelY = Math.max(drawTop + 12, top + 12)
|
||||||
ctx.fillStyle = "rgba(34, 197, 94, 0.8)"
|
ctx.fillStyle = "rgba(34, 197, 94, 0.8)"
|
||||||
ctx.font = "9px sans-serif"
|
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()
|
ctx.restore()
|
||||||
}
|
}
|
||||||
|
|
@ -64,7 +68,8 @@ export const ElevationProfile = {
|
||||||
const ducts = (data.ducts || []).map(d => ({
|
const ducts = (data.ducts || []).map(d => ({
|
||||||
base_ft: d.base_m_msl * M2FT,
|
base_ft: d.base_m_msl * M2FT,
|
||||||
top_ft: d.top_m_msl * M2FT,
|
top_ft: d.top_m_msl * M2FT,
|
||||||
strength: d.strength
|
strength: d.strength,
|
||||||
|
source: d.source || "hrrr"
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const allElevs = [...elevations, ...losLine]
|
const allElevs = [...elevations, ...losLine]
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,11 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
|
|
||||||
weather = load_weather(contact)
|
weather = load_weather(contact)
|
||||||
solar = load_solar(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)
|
{hrrr, hrrr_status} = maybe_enqueue_hrrr(hrrr, contact)
|
||||||
terrain = Terrain.get_terrain_profile(contact.id)
|
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,
|
{:ok,
|
||||||
assign(socket,
|
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 sounding_sort_key(_), do: fn s -> s.station.name || s.station.station_code end
|
||||||
|
|
||||||
defp load_weather(contact) do
|
defp load_weather(contact) do
|
||||||
lat = contact.pos1 && contact.pos1["lat"]
|
lat1 = contact.pos1 && contact.pos1["lat"]
|
||||||
lon = contact.pos1 && (contact.pos1["lon"] || contact.pos1["lng"])
|
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
|
if lat1 && lon1 do
|
||||||
Weather.weather_for_contact(%{lat: lat, lon: lon, timestamp: contact.qso_timestamp},
|
# Search along the path midpoint with radius covering both endpoints
|
||||||
radius_km: 300
|
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
|
else
|
||||||
%{surface_observations: [], soundings: []}
|
%{surface_observations: [], soundings: []}
|
||||||
end
|
end
|
||||||
|
|
@ -203,7 +227,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
if(@contact.flagged_invalid, do: "btn-warning", else: "btn-ghost")
|
if(@contact.flagged_invalid, do: "btn-warning", else: "btn-ghost")
|
||||||
]}>
|
]}>
|
||||||
<.icon name="hero-flag" class="w-4 h-4" />
|
<.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>
|
</button>
|
||||||
<.link navigate={~p"/contacts"} class="btn btn-sm btn-ghost">
|
<.link navigate={~p"/contacts"} class="btn btn-sm btn-ghost">
|
||||||
<.icon name="hero-arrow-left" class="w-4 h-4" /> Back to Contacts
|
<.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 %>
|
<%= if @surface_observations == [] do %>
|
||||||
<p class="text-sm text-base-content/50 italic">No surface observations found nearby.</p>
|
<p class="text-sm text-base-content/50 italic">No surface observations found nearby.</p>
|
||||||
<% else %>
|
<% else %>
|
||||||
<.table
|
<div class="overflow-x-auto">
|
||||||
id="surface-obs"
|
<table class="table table-xs table-zebra">
|
||||||
rows={@surface_observations}
|
<thead>
|
||||||
row_id={fn obs -> "obs-#{obs.id}" end}
|
<tr>
|
||||||
sort_by={@obs_sort_by}
|
<th>Station</th>
|
||||||
sort_order={@obs_sort_order}
|
<th>Time</th>
|
||||||
sort_target="obs"
|
<th>Temp</th>
|
||||||
>
|
<th>Dewpt</th>
|
||||||
<:col :let={obs} label="Station" sort_field="station_name">
|
<th>RH%</th>
|
||||||
{obs.station.name || obs.station.station_code}
|
<th>Wind</th>
|
||||||
</:col>
|
<th>Pres (mb)</th>
|
||||||
<:col :let={obs} label="Time" sort_field="observed_at">
|
<th>Sky</th>
|
||||||
{Calendar.strftime(obs.observed_at, "%H:%M")}
|
</tr>
|
||||||
</:col>
|
</thead>
|
||||||
<:col :let={obs} label="Temp (F)" sort_field="temp_f">{obs.temp_f || "—"}</:col>
|
<tbody>
|
||||||
<:col :let={obs} label="Dewpoint (F)" sort_field="dewpoint_f">
|
<%= for obs <- @surface_observations do %>
|
||||||
{obs.dewpoint_f || "—"}
|
<tr>
|
||||||
</:col>
|
<td>{obs.station.name || obs.station.station_code}</td>
|
||||||
<:col :let={obs} label="RH%" sort_field="relative_humidity">
|
<td>{Calendar.strftime(obs.observed_at, "%H:%M")}</td>
|
||||||
{format_number(obs.relative_humidity)}
|
<td>{obs.temp_f || "—"}</td>
|
||||||
</:col>
|
<td>{obs.dewpoint_f || "—"}</td>
|
||||||
<:col :let={obs} label="Wind">{format_wind(obs)}</:col>
|
<td>{format_number(obs.relative_humidity)}</td>
|
||||||
<:col :let={obs} label="Pressure (mb)" sort_field="sea_level_pressure_mb">
|
<td>{format_wind(obs)}</td>
|
||||||
{obs.sea_level_pressure_mb || "—"}
|
<td>{obs.sea_level_pressure_mb || "—"}</td>
|
||||||
</:col>
|
<td>{obs.sky_condition || "—"}</td>
|
||||||
<:col :let={obs} label="Sky">{obs.sky_condition || "—"}</:col>
|
</tr>
|
||||||
</.table>
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
|
|
@ -588,7 +615,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
defp terrain_verdict_class("BLOCKED"), do: "badge-error"
|
defp terrain_verdict_class("BLOCKED"), do: "badge-error"
|
||||||
defp terrain_verdict_class(_), do: "badge-ghost"
|
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,
|
with %{"lat" => lat1} <- contact.pos1,
|
||||||
lon1 when is_number(lon1) <- contact.pos1["lon"] || contact.pos1["lng"],
|
lon1 when is_number(lon1) <- contact.pos1["lon"] || contact.pos1["lng"],
|
||||||
%{"lat" => lat2} <- contact.pos2,
|
%{"lat" => lat2} <- contact.pos2,
|
||||||
|
|
@ -612,7 +639,12 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
p -> p.dist_km
|
p -> p.dist_km
|
||||||
end
|
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:
|
points:
|
||||||
|
|
@ -634,24 +666,125 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
end
|
end
|
||||||
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
|
if hrrr_ducts != [] do
|
||||||
case hrrr.duct_characteristics do
|
hrrr_ducts
|
||||||
ducts when is_list(ducts) and ducts != [] ->
|
else
|
||||||
Enum.map(ducts, fn d ->
|
sounding_ducts = extract_sounding_ducts(soundings, surface_elev)
|
||||||
%{
|
|
||||||
base_m_msl: surface_elev + (d["base"] || 0),
|
|
||||||
top_m_msl: surface_elev + (d["top"] || 0),
|
|
||||||
strength: d["strength"]
|
|
||||||
}
|
|
||||||
end)
|
|
||||||
|
|
||||||
_ ->
|
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
|
||||||
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(nil), do: 10.0
|
||||||
|
|
||||||
defp band_to_ghz(band) do
|
defp band_to_ghz(band) do
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue