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
This commit is contained in:
parent
4aaa028ed1
commit
dbc00989cd
1 changed files with 417 additions and 0 deletions
|
|
@ -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
|
|||
<div class="text-sm font-mono mb-2 text-center">
|
||||
{format_band_ghz(@contact.band)} · {@contact.mode}
|
||||
· {format_dist(@elevation_profile.dist_km)}
|
||||
· {@contact.grid1 || "—"} → {@contact.grid2 || "—"}
|
||||
</div>
|
||||
<table class="text-sm font-mono mb-2 mx-auto">
|
||||
<tr>
|
||||
|
|
@ -293,6 +298,56 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
|||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @propagation_analysis do %>
|
||||
<div class="divider" />
|
||||
|
||||
<h2 class="text-base font-semibold mb-2">Propagation Analysis</h2>
|
||||
<div class="text-sm mb-3">
|
||||
<p>{@propagation_analysis.summary}</p>
|
||||
<%= for detail <- @propagation_analysis.details do %>
|
||||
<p class="mt-1 text-base-content/70">{detail}</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if @propagation_analysis.factors do %>
|
||||
<div class="overflow-x-auto mb-2">
|
||||
<table class="table table-xs table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Factor</th>
|
||||
<th>Score</th>
|
||||
<th>Weight</th>
|
||||
<th>Contribution</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= for f <- @propagation_analysis.factor_rows do %>
|
||||
<tr>
|
||||
<td class="font-semibold">{f.name}</td>
|
||||
<td>{f.score}/100</td>
|
||||
<td>{f.weight}%</td>
|
||||
<td>{f.contribution}</td>
|
||||
<td class="text-base-content/60">{f.note}</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="font-semibold">
|
||||
<td>Composite</td>
|
||||
<td colspan="2">{@propagation_analysis.composite_score}/100</td>
|
||||
<td colspan="2">
|
||||
<span class={propagation_tier_class(@propagation_analysis.composite_score)}>
|
||||
{propagation_tier_label(@propagation_analysis.composite_score)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<h2 class="text-base font-semibold mb-2" id="terrain-heading">Terrain Profile</h2>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue