diff --git a/assets/js/elevation_profile_hook.js b/assets/js/elevation_profile_hook.js index e3351ddc..aa5f4753 100644 --- a/assets/js/elevation_profile_hook.js +++ b/assets/js/elevation_profile_hook.js @@ -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] diff --git a/lib/microwaveprop_web/live/contact_live/show.ex b/lib/microwaveprop_web/live/contact_live/show.ex index 52daea54..dac0b96c 100644 --- a/lib/microwaveprop_web/live/contact_live/show.ex +++ b/lib/microwaveprop_web/live/contact_live/show.ex @@ -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"} <.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 %>

No surface observations found nearby.

<% 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 :let={obs} label="Time" sort_field="observed_at"> - {Calendar.strftime(obs.observed_at, "%H:%M")} - - <:col :let={obs} label="Temp (F)" sort_field="temp_f">{obs.temp_f || "—"} - <:col :let={obs} label="Dewpoint (F)" sort_field="dewpoint_f"> - {obs.dewpoint_f || "—"} - - <:col :let={obs} label="RH%" sort_field="relative_humidity"> - {format_number(obs.relative_humidity)} - - <:col :let={obs} label="Wind">{format_wind(obs)} - <:col :let={obs} label="Pressure (mb)" sort_field="sea_level_pressure_mb"> - {obs.sea_level_pressure_mb || "—"} - - <:col :let={obs} label="Sky">{obs.sky_condition || "—"} - +
+ + + + + + + + + + + + + + + <%= for obs <- @surface_observations do %> + + + + + + + + + + + <% end %> + +
StationTimeTempDewptRH%WindPres (mb)Sky
{obs.station.name || obs.station.station_code}{Calendar.strftime(obs.observed_at, "%H:%M")}{obs.temp_f || "—"}{obs.dewpoint_f || "—"}{format_number(obs.relative_humidity)}{format_wind(obs)}{obs.sea_level_pressure_mb || "—"}{obs.sky_condition || "—"}
+
<% end %> """ @@ -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