prop/lib/microwaveprop/propagation/rain_scatter.ex
Graham McIntire a66d3094ca Add contact edit approval system with admin review queue
Registered users can suggest edits to any contact's core fields
(callsigns, grids, band, mode, timestamp). Edits enter an admin
approval queue with field-by-field diff view. On approve, changes
are applied and enrichment re-enqueued if grids/band changed.
Users receive email notification on approve or reject.

Also updates dependabot.yml for mix ecosystem.
2026-04-11 16:15:49 -05:00

145 lines
4.8 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.
"""
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.
"""
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