From dbc00989cd2ddddd5212c23a635d081d7064e84c Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 1 Apr 2026 13:38:10 -0500 Subject: [PATCH] Add propagation analysis with summary and factor breakdown - Text summary explaining likely propagation mechanism (ducting, enhanced refraction, diffraction, troposcatter) based on terrain, HRRR, and sounding data - 10-factor scoring table with per-factor notes, weights, contributions - Composite score with tier badge - Grid squares added back to path info line - Analysis positioned between elevation chart and terrain profile --- .../live/contact_live/show.ex | 417 ++++++++++++++++++ 1 file changed, 417 insertions(+) diff --git a/lib/microwaveprop_web/live/contact_live/show.ex b/lib/microwaveprop_web/live/contact_live/show.ex index 3dd79e51..1e6f494c 100644 --- a/lib/microwaveprop_web/live/contact_live/show.ex +++ b/lib/microwaveprop_web/live/contact_live/show.ex @@ -2,6 +2,8 @@ defmodule MicrowavepropWeb.ContactLive.Show do @moduledoc false use MicrowavepropWeb, :live_view + alias Microwaveprop.Propagation.BandConfig + alias Microwaveprop.Propagation.Scorer alias Microwaveprop.Radio alias Microwaveprop.Terrain alias Microwaveprop.Terrain.ElevationClient @@ -23,6 +25,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do {hrrr, hrrr_status} = maybe_enqueue_hrrr(hrrr, contact) terrain = Terrain.get_terrain_profile(contact.id) elevation_profile = compute_elevation_profile(contact, hrrr_path, weather.soundings) + propagation_analysis = build_propagation_analysis(contact, hrrr, terrain, elevation_profile, weather.soundings) {:ok, assign(socket, @@ -35,6 +38,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do hrrr_status: hrrr_status, terrain: terrain, elevation_profile: elevation_profile, + propagation_analysis: propagation_analysis, terrain_expanded: false, hrrr_profile_expanded: false, obs_sort_by: "station_name", @@ -256,6 +260,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
{format_band_ghz(@contact.band)} · {@contact.mode} · {format_dist(@elevation_profile.dist_km)} + · {@contact.grid1 || "—"} → {@contact.grid2 || "—"}
@@ -293,6 +298,56 @@ defmodule MicrowavepropWeb.ContactLive.Show do <% end %> + <%= if @propagation_analysis do %> +
+ +

Propagation Analysis

+
+

{@propagation_analysis.summary}

+ <%= for detail <- @propagation_analysis.details do %> +

{detail}

+ <% end %> +
+ + <%= if @propagation_analysis.factors do %> +
+
+ + + + + + + + + + + <%= for f <- @propagation_analysis.factor_rows do %> + + + + + + + + <% end %> + + + + + + + + +
FactorScoreWeightContributionNotes
{f.name}{f.score}/100{f.weight}%{f.contribution}{f.note}
Composite{@propagation_analysis.composite_score}/100 + + {propagation_tier_label(@propagation_analysis.composite_score)} + +
+ + <% end %> + <% end %> +

Terrain Profile

@@ -857,4 +912,366 @@ defmodule MicrowavepropWeb.ContactLive.Show do defp deg_to_rad(deg), do: deg * :math.pi() / 180 defp rad_to_deg(rad), do: rad * 180 / :math.pi() defp rem_float(a, b), do: a - Float.floor(a / b) * b + + # ── Propagation analysis ────────────────────────────────────────── + + defp build_propagation_analysis(contact, hrrr, terrain, elevation_profile, soundings) do + band_mhz = if contact.band, do: Decimal.to_integer(contact.band), else: 10_000 + band_config = BandConfig.get(band_mhz) || BandConfig.get(10_000) + dist_km = contact.distance_km && Decimal.to_float(contact.distance_km) + + {factors, factor_rows, composite} = compute_factors(hrrr, contact, band_config) + summary = build_summary(terrain, elevation_profile, hrrr, soundings, dist_km, band_mhz) + details = build_details(hrrr, soundings, elevation_profile, band_mhz) + + %{ + summary: summary, + details: details, + factors: factors, + factor_rows: factor_rows, + composite_score: composite + } + end + + defp compute_factors(nil, _contact, _band_config) do + {nil, [], nil} + end + + defp compute_factors(hrrr, contact, band_config) do + lon = contact.pos1["lon"] || contact.pos1["lng"] + temp_c = hrrr.surface_temp_c + dewpoint_c = hrrr.surface_dewpoint_c + + if is_nil(temp_c) or is_nil(dewpoint_c) do + {nil, [], nil} + else + temp_f = Scorer.c_to_f(temp_c) + dewpoint_f = Scorer.c_to_f(dewpoint_c) + + conditions = %{ + abs_humidity: Scorer.absolute_humidity(temp_c, dewpoint_c), + temp_f: temp_f, + dewpoint_f: dewpoint_f, + wind_speed_kts: nil, + sky_cover_pct: nil, + utc_hour: contact.qso_timestamp.hour, + utc_minute: contact.qso_timestamp.minute, + month: contact.qso_timestamp.month, + longitude: lon, + pressure_mb: hrrr.surface_pressure_mb, + prev_pressure_mb: nil, + rain_rate_mmhr: 0.0, + min_refractivity_gradient: hrrr.min_refractivity_gradient, + bl_depth_m: hrrr.hpbl_m, + pwat_mm: hrrr.pwat_mm + } + + result = Scorer.composite_score(conditions, band_config) + weights = BandConfig.weights() + + factor_rows = + [ + {:humidity, "Humidity", humidity_note(conditions.abs_humidity, band_config)}, + {:time_of_day, "Time of Day", time_note(contact.qso_timestamp, lon)}, + {:td_depression, "T-Td Depression", td_note(temp_f, dewpoint_f)}, + {:refractivity, "Refractivity", refractivity_note(hrrr.min_refractivity_gradient)}, + {:sky, "Sky Cover", sky_note(conditions.sky_cover_pct)}, + {:season, "Season", season_note(contact.qso_timestamp.month, band_config)}, + {:wind, "Wind", wind_note(conditions.wind_speed_kts)}, + {:rain, "Rain", rain_note(conditions.rain_rate_mmhr)}, + {:pwat, "PWAT", pwat_note(hrrr.pwat_mm, band_config)}, + {:pressure, "Pressure", pressure_note(hrrr.surface_pressure_mb)} + ] + |> Enum.map(fn {key, name, note} -> + score = result.factors[key] + weight = weights[key] + + %{ + name: name, + score: score, + weight: round(weight * 100), + contribution: Float.round(score * weight, 1), + note: note + } + end) + |> Enum.sort_by(& &1.contribution, :desc) + + {result.factors, factor_rows, result.score} + end + end + + defp build_summary(terrain, elevation_profile, hrrr, soundings, dist_km, band_mhz) do + terrain_status = terrain_summary(terrain, elevation_profile) + mechanism = propagation_mechanism(terrain, elevation_profile, hrrr, soundings, dist_km) + band_note = band_summary(band_mhz, hrrr) + + [terrain_status, mechanism, band_note] + |> Enum.reject(&is_nil/1) + |> Enum.join(" ") + end + + defp terrain_summary(nil, nil), do: "No terrain data available." + + defp terrain_summary(terrain, elevation_profile) do + verdict = (elevation_profile && elevation_profile.verdict) || (terrain && terrain.verdict) + + case verdict do + "CLEAR" -> "Line of sight is clear between stations." + "FRESNEL_MINOR" -> "Line of sight is clear but with minor Fresnel zone encroachment." + "FRESNEL_PARTIAL" -> "Line of sight has partial Fresnel zone obstruction." + "BLOCKED" -> + obs_dist = elevation_profile && elevation_profile.first_obstruction_km + + if obs_dist do + "Path is terrain-obstructed at #{:erlang.float_to_binary(obs_dist * 0.621371, decimals: 1)} mi (#{:erlang.float_to_binary(obs_dist, decimals: 1)} km) from station 1." + else + "Path is terrain-obstructed." + end + + _ -> nil + end + end + + defp propagation_mechanism(terrain, elevation_profile, hrrr, soundings, dist_km) do + blocked? = terrain && terrain.verdict == "BLOCKED" + ducting? = has_ducting?(hrrr, soundings) + enhanced_refraction? = hrrr && is_number(hrrr.min_refractivity_gradient) and hrrr.min_refractivity_gradient < -100 + long_path? = dist_km && dist_km > 100 + + ducts = elevation_profile && elevation_profile.ducts || [] + likely_duct = Enum.find(ducts, & &1[:likely]) + + cond do + blocked? && ducting? && likely_duct -> + "Signal likely propagated via atmospheric ducting (#{duct_description(likely_duct)}), bending over the terrain obstruction." + + blocked? && ducting? -> + "Ducting conditions detected — signal likely propagated through a tropospheric duct, bypassing the terrain obstruction." + + blocked? && enhanced_refraction? -> + grad = round(hrrr.min_refractivity_gradient) + "Enhanced refraction (dN/dh = #{grad} N-units/km) likely bent the signal over the obstruction. Conditions are super-refractive but below full ducting threshold." + + blocked? -> + diff_db = terrain && terrain.diffraction_db + if diff_db && diff_db > 0 do + "Path is obstructed with #{:erlang.float_to_binary(diff_db, decimals: 1)} dB diffraction loss. Contact may have been enabled by knife-edge diffraction or tropospheric scatter." + else + "Path is obstructed. Contact likely enabled by tropospheric scatter or transient ducting conditions." + end + + ducting? && long_path? && likely_duct -> + "Ducting conditions present (#{duct_description(likely_duct)}) — likely extending range beyond normal line of sight." + + ducting? && long_path? -> + "Ducting conditions present — likely contributing to extended range." + + enhanced_refraction? && long_path? -> + "Enhanced refraction present — may have contributed to extended range." + + true -> + nil + end + end + + defp duct_description(%{source: source, base_m_msl: base, top_m_msl: top, strength: strength}) do + base_ft = round(base * 3.28084) + top_ft = round(top * 3.28084) + src = case source do + "sounding" -> "sounding-detected" + "inferred" -> "estimated" + _ -> "detected" + end + "#{src} layer at #{base_ft}-#{top_ft} ft, #{strength} M-units" + end + + defp duct_description(_), do: "detected layer" + + defp has_ducting?(hrrr, soundings) do + hrrr_ducting = hrrr && hrrr.ducting_detected + sounding_ducting = is_list(soundings) && Enum.any?(soundings, & &1.ducting_detected) + hrrr_ducting || sounding_ducting + end + + defp band_summary(band_mhz, hrrr) when band_mhz <= 12_000 do + if hrrr && is_number(hrrr.pwat_mm) && hrrr.pwat_mm > 20 do + "At 10 GHz, the elevated moisture (PWAT #{:erlang.float_to_binary(hrrr.pwat_mm, decimals: 1)} mm) enhances refractivity and ducting potential." + else + nil + end + end + + defp band_summary(band_mhz, hrrr) when band_mhz >= 24_000 do + if hrrr && is_number(hrrr.pwat_mm) && hrrr.pwat_mm > 25 do + "At #{round(band_mhz / 1000)} GHz, high moisture (PWAT #{:erlang.float_to_binary(hrrr.pwat_mm, decimals: 1)} mm) causes significant water vapor absorption." + else + nil + end + end + + defp band_summary(_, _), do: nil + + defp build_details(hrrr, soundings, elevation_profile, _band_mhz) do + details = [] + + details = + if hrrr && is_number(hrrr.min_refractivity_gradient) do + grad = round(hrrr.min_refractivity_gradient) + label = cond do + grad < -157 -> "ducting" + grad < -100 -> "super-refractive" + grad < -79 -> "enhanced" + true -> "normal" + end + details ++ ["Refractivity gradient: #{grad} N-units/km (#{label})."] + else + details + end + + details = + if hrrr && is_number(hrrr.hpbl_m) do + hpbl = round(hrrr.hpbl_m) + note = if hpbl < 300, do: " — shallow boundary layer favors ducting", else: "" + details ++ ["Boundary layer depth: #{hpbl} m#{note}."] + else + details + end + + ducting_soundings = if is_list(soundings), do: Enum.filter(soundings, & &1.ducting_detected), else: [] + + details = + if ducting_soundings != [] do + names = Enum.map_join(ducting_soundings, ", ", fn s -> s.station.name || s.station.station_code end) + details ++ ["Ducting detected by soundings at: #{names}."] + else + details + end + + details = + if elevation_profile do + ducts = elevation_profile.ducts || [] + likely = Enum.find(ducts, & &1[:likely]) + + if likely do + details ++ ["Most likely propagation duct: #{duct_description(likely)}."] + else + details + end + else + details + end + + details + end + + # Factor note helpers + defp humidity_note(abs_h, band_config) do + h = Float.round(abs_h, 1) + effect = if band_config.humidity_effect == :beneficial, do: "beneficial", else: "harmful" + "#{h} g/m³ (#{effect} at this band)" + end + + defp time_note(ts, lon) do + solar_hour = ts.hour + ts.minute / 60 + (lon || 0) / 15 + solar_hour = rem_float(solar_hour + 24, 24) + h = round(solar_hour) + label = cond do + h >= 4 and h < 8 -> "dawn" + h >= 8 and h < 12 -> "morning" + h >= 12 and h < 17 -> "afternoon" + h >= 17 and h < 21 -> "evening" + true -> "night" + end + "Solar hour ~#{h} (#{label})" + end + + defp td_note(temp_f, dewpoint_f) when is_number(temp_f) and is_number(dewpoint_f) do + depression = Float.round(temp_f - dewpoint_f, 1) + label = cond do + depression < 3 -> "near saturation" + depression < 8 -> "moist" + depression < 15 -> "moderate" + true -> "dry" + end + "T-Td = #{depression}°F (#{label})" + end + + defp td_note(_, _), do: "—" + + defp refractivity_note(nil), do: "—" + + defp refractivity_note(grad) do + g = round(grad) + label = cond do + g < -157 -> "ducting" + g < -100 -> "super-refractive" + g < -79 -> "enhanced" + true -> "normal" + end + "dN/dh = #{g} (#{label})" + end + + defp sky_note(nil), do: "—" + defp sky_note(pct), do: "#{round(pct)}% cloud cover" + + defp season_note(month, band_config) do + month_names = ~w(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec) + name = Enum.at(month_names, month - 1) + effect = if band_config.humidity_effect == :beneficial, do: "summer peak", else: "winter peak" + "#{name} (#{effect} band)" + end + + defp wind_note(nil), do: "—" + + defp wind_note(kts) do + k = Float.round(kts, 1) + label = cond do + k < 5 -> "calm" + k < 15 -> "light" + k < 25 -> "moderate" + true -> "strong" + end + "#{k} kts (#{label})" + end + + defp rain_note(rate) when rate > 0, do: "#{Float.round(rate, 1)} mm/hr" + defp rain_note(_), do: "None" + + defp pwat_note(nil, _), do: "—" + + defp pwat_note(mm, band_config) do + effect = if band_config.humidity_effect == :beneficial, do: "refractivity aid", else: "absorption" + "#{Float.round(mm, 1)} mm (#{effect})" + end + + defp pressure_note(nil), do: "—" + + defp pressure_note(mb) do + label = cond do + mb < 1005 -> "low" + mb < 1015 -> "moderate" + true -> "high" + end + "#{Float.round(mb, 1)} mb (#{label})" + end + + defp propagation_tier_label(score) do + cond do + score >= 80 -> "EXCELLENT" + score >= 65 -> "GOOD" + score >= 50 -> "MARGINAL" + score >= 33 -> "POOR" + true -> "NEGLIGIBLE" + end + end + + defp propagation_tier_class(score) do + cond do + score >= 80 -> "badge badge-sm badge-success" + score >= 65 -> "badge badge-sm badge-info" + score >= 50 -> "badge badge-sm badge-warning" + score >= 33 -> "badge badge-sm badge-error" + true -> "badge badge-sm badge-ghost" + end + end end