prop/lib/microwaveprop/propagation/rain_scatter.ex
Graham McIntire 51e390959c Add dialyzer specs and types across the codebase
277 @spec/@type annotations added to 58 files covering all public
APIs: contexts (propagation, radio, weather, terrain, beacons,
commercial), GRIB2 decoders, terrain analysis, duct detection,
rain scatter, CSV/ADIF import, weather clients, and all Ecto
schemas. Dialyzer passes with 0 errors.
2026-04-12 08:55:04 -05:00

161 lines
5.2 KiB
Elixir
Raw 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.
"""
@earth_radius_km 6371.0
# 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
dlat = :math.pi() * (lat2 - lat1) / 180.0
dlon = :math.pi() * (lon2 - lon1) / 180.0
rlat1 = :math.pi() * lat1 / 180.0
rlat2 = :math.pi() * lat2 / 180.0
a =
:math.sin(dlat / 2) * :math.sin(dlat / 2) +
:math.cos(rlat1) * :math.cos(rlat2) *
:math.sin(dlon / 2) * :math.sin(dlon / 2)
c = 2.0 * :math.atan2(:math.sqrt(a), :math.sqrt(1.0 - a))
@earth_radius_km * c
end
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