prop/lib/microwaveprop/propagation/rain_scatter.ex
Graham McIntire 2defa3db7a
refactor: deduplicate helpers and simplify Scorer
- Unify haversine_km / deg_to_rad: Geo is the canonical home (atan2
  form for antipodal stability); Radio, common_volume, rain_scatter,
  ionosphere, terrain/srtm, terrain/elevation_client now delegate.
- Drop safe_min/safe_max wrappers in favor of Enum.min/max defaults.
- Move parse_float / parse_int to MicrowavepropWeb.LiveHelpers; eme,
  path, and rover LiveViews import from there.
- Extract MicrowavepropWeb.LocationResolver from the eme/path
  resolve_source / resolve_location duplicate. Standardize the kind
  key and "Invalid grid square" error message.
- Convert Scorer cond chains (humidity, td_depression, sky, wind,
  rain, pwat, pressure) to multi-clause guarded defs, matching the
  existing classify_time_period style.
- extract_profile_fields uses a named map accumulator instead of a
  6-tuple; build_path_conditions guards on %{temps: []} / %{dewpoints: []}.
- Collapse scores_file clamp/3 to max |> min.
- humidity_effect_label lives in BandConfig; map_live and path_live
  both use it.
2026-04-28 14:14:34 -05:00

146 lines
4.9 KiB
Elixir
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule Microwaveprop.Propagation.RainScatter do
@moduledoc """
Estimates rain scatter potential from NEXRAD composite reflectivity.
Rain scatter enables microwave contacts at 100-300+ km by scattering
signals off precipitation cells. Signal strength depends on reflectivity
(rain intensity), frequency, and geometry (distance from each station
to the rain cell).
Uses a simplified bistatic radar equation:
scatter_db ≈ 10*log10(Z) + 10*log10(V) - 20*log10(R1) - 20*log10(R2)
+ frequency_gain - path_losses
where Z is reflectivity factor (from dBZ), V is effective scattering
volume, and R1/R2 are distances from each endpoint to the cell.
"""
# Minimum reflectivity to consider for scatter (dBZ)
@min_dbz 25.0
# Maximum scatter range from either endpoint (km)
@max_range_km 300.0
@doc """
Find rain cells with scatter potential for a given point and band.
Takes a list of `{lat, lon, dbz}` rain cells (from NEXRAD extraction),
the observer's position, and frequency in GHz.
Returns a list of scatter cell maps sorted by potential (strongest first),
each with: lat, lon, dbz, distance_km, scatter_db (relative signal estimate),
and bearing from the observer.
"""
@spec find_scatter_cells(
[{float(), float(), float()}],
float(),
float(),
float()
) :: [
%{
lat: float(),
lon: float(),
dbz: float(),
distance_km: float(),
bearing: float(),
scatter_db: float()
}
]
def find_scatter_cells(rain_cells, obs_lat, obs_lon, freq_ghz) do
rain_cells
|> Enum.filter(fn {_lat, _lon, dbz} -> dbz >= @min_dbz end)
|> Enum.map(fn {lat, lon, dbz} ->
dist_km = haversine_km(obs_lat, obs_lon, lat, lon)
bearing = bearing_deg(obs_lat, obs_lon, lat, lon)
# Scatter signal estimate (relative dB)
# Higher reflectivity = more scattering targets
# Closer cells = stronger signal (inverse square from both endpoints)
# Higher frequency = stronger Rayleigh scattering (up to ~10 GHz, then Mie)
scatter_db = estimate_scatter_db(dbz, dist_km, freq_ghz)
%{
lat: Float.round(lat, 3),
lon: Float.round(lon, 3),
dbz: Float.round(dbz, 1),
distance_km: Float.round(dist_km, 1),
bearing: Float.round(bearing, 0),
scatter_db: Float.round(scatter_db, 1)
}
end)
|> Enum.filter(&(&1.distance_km <= @max_range_km and &1.distance_km >= 10))
|> Enum.sort_by(&(-&1.scatter_db))
|> Enum.take(20)
end
@doc """
Classify overall scatter potential for a point.
Returns :excellent, :good, :marginal, or :none based on
the best available scatter cell.
"""
@spec classify([%{scatter_db: float()}]) :: :excellent | :good | :marginal | :none
def classify(scatter_cells) do
case scatter_cells do
[] ->
:none
[best | _] ->
cond do
best.scatter_db >= -10 -> :excellent
best.scatter_db >= -20 -> :good
best.scatter_db >= -30 -> :marginal
true -> :none
end
end
end
# Simplified scatter signal estimate in relative dB.
#
# Based on bistatic radar equation for volume scattering:
# P_rx ∝ Z * σ_scatter * V / (R^4)
#
# For a single cell at distance R from the observer (assuming the
# other station is also near R for a rough estimate):
# scatter_db ≈ dBZ + freq_factor - 40*log10(R_km) + volume_term
defp estimate_scatter_db(dbz, dist_km, freq_ghz) do
# Reflectivity contribution (dBZ is already in log scale)
z_term = dbz
# Frequency factor: Rayleigh scattering ∝ f^4 below ~10 GHz,
# transitions to Mie at higher frequencies (weaker dependence).
# Normalize to 10 GHz as reference.
freq_factor =
cond do
freq_ghz <= 10 -> 40 * :math.log10(max(freq_ghz, 0.5) / 10.0)
freq_ghz <= 50 -> 20 * :math.log10(freq_ghz / 10.0)
true -> 20 * :math.log10(50.0 / 10.0)
end
# Path loss: R^4 for bistatic (R^2 each way)
# Normalize to 100 km reference distance
range_loss = 40 * :math.log10(max(dist_km, 1) / 100.0)
# Effective scattering volume (~1 km^3 rain cell)
volume_term = 10.0
# Baseline offset to center the scale around useful values
baseline = -50.0
baseline + z_term + freq_factor - range_loss + volume_term
end
defp haversine_km(lat1, lon1, lat2, lon2), do: Microwaveprop.Geo.haversine_km(lat1, lon1, lat2, lon2)
defp bearing_deg(lat1, lon1, lat2, lon2) do
rlat1 = :math.pi() * lat1 / 180.0
rlat2 = :math.pi() * lat2 / 180.0
dlon = :math.pi() * (lon2 - lon1) / 180.0
x = :math.sin(dlon) * :math.cos(rlat2)
y = :math.cos(rlat1) * :math.sin(rlat2) - :math.sin(rlat1) * :math.cos(rlat2) * :math.cos(dlon)
bearing = :math.atan2(x, y) * 180.0 / :math.pi()
Float.round(:math.fmod(bearing + 360.0, 360.0), 1)
end
end