Add rain scatter prediction to map detail panel
When clicking a grid point, fetches the latest NEXRAD composite reflectivity and identifies rain cells within 300 km that could enable rain scatter contacts. Shows: - Scatter classification (excellent/good/marginal/none) - Top 3 cells with dBZ, distance, bearing, and relative signal - Colored circle markers on the map at rain cell locations - Markers sized by reflectivity, colored by intensity Uses simplified bistatic radar equation accounting for reflectivity, frequency-dependent scattering (Rayleigh/Mie), and R^4 path loss. NEXRAD cells sampled every ~5 km within bounding box for efficiency.
This commit is contained in:
parent
2e9d6a7c7f
commit
e8ae407be9
4 changed files with 291 additions and 1 deletions
|
|
@ -358,11 +358,43 @@ function buildPopupHTML(detail, viewshedLoading) {
|
|||
${explanations}
|
||||
</div>
|
||||
${detail.factors.duct_info ? buildDuctInfoHTML(detail.factors.duct_info) : ""}
|
||||
${detail.rain_scatter && detail.rain_scatter.cells.length > 0 ? buildScatterHTML(detail.rain_scatter) : ""}
|
||||
${detail.forecast ? buildForecastSvg(detail.forecast) : ""}
|
||||
${viewshedStatus}
|
||||
</div>`
|
||||
}
|
||||
|
||||
function buildScatterHTML(scatter) {
|
||||
const cls = scatter.classification
|
||||
const cells = scatter.cells
|
||||
const clsColors = { excellent: "#059669", good: "#0d9488", marginal: "#ca8a04", none: "#666" }
|
||||
const clsColor = clsColors[cls] || "#666"
|
||||
const clsLabel = cls.charAt(0).toUpperCase() + cls.slice(1)
|
||||
|
||||
const top3 = cells.slice(0, 3)
|
||||
const rows = top3.map(c => {
|
||||
const dir = bearingLabel(c.bearing)
|
||||
return `<div style="font-size:11px;padding:1px 0;">
|
||||
${c.dbz} dBZ at ${Math.round(c.distance_km)} km ${dir} (${c.scatter_db} dB)
|
||||
</div>`
|
||||
}).join("")
|
||||
|
||||
return `
|
||||
<div style="padding:4px 10px 6px;border-top:1px solid rgba(255,255,255,0.15);">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:3px;">
|
||||
<span style="font-size:10px;font-weight:700;opacity:0.5;text-transform:uppercase;letter-spacing:0.5px;">Rain Scatter</span>
|
||||
<span style="font-size:11px;font-weight:700;color:${clsColor};">${clsLabel}</span>
|
||||
</div>
|
||||
${rows}
|
||||
${cells.length > 3 ? `<div style="font-size:10px;opacity:0.5;">+${cells.length - 3} more cells</div>` : ""}
|
||||
</div>`
|
||||
}
|
||||
|
||||
function bearingLabel(deg) {
|
||||
const dirs = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"]
|
||||
return dirs[Math.round(deg / 22.5) % 16]
|
||||
}
|
||||
|
||||
function buildDuctInfoHTML(info) {
|
||||
const layers = info.ducts || []
|
||||
let layerRows = ""
|
||||
|
|
@ -530,6 +562,29 @@ export const PropagationMap = {
|
|||
this.detailPanel.innerHTML = buildPopupHTML(merged, this.viewshedLoading)
|
||||
this.showDetailPanel()
|
||||
|
||||
// Draw rain scatter cells on the map
|
||||
if (this.scatterMarkers) {
|
||||
this.scatterMarkers.clearLayers()
|
||||
} else {
|
||||
this.scatterMarkers = L.layerGroup().addTo(this.map)
|
||||
}
|
||||
if (detail.rain_scatter && detail.rain_scatter.cells.length > 0) {
|
||||
for (const c of detail.rain_scatter.cells) {
|
||||
const opacity = Math.min(0.9, Math.max(0.3, (c.scatter_db + 30) / 30))
|
||||
const color = c.dbz >= 45 ? "#dc2626" : c.dbz >= 35 ? "#ea580c" : "#ca8a04"
|
||||
const marker = L.circleMarker([c.lat, c.lon], {
|
||||
radius: Math.min(12, Math.max(4, c.dbz / 5)),
|
||||
color: color,
|
||||
fillColor: color,
|
||||
fillOpacity: opacity * 0.5,
|
||||
weight: 1.5,
|
||||
opacity: opacity,
|
||||
interactive: false
|
||||
})
|
||||
this.scatterMarkers.addLayer(marker)
|
||||
}
|
||||
}
|
||||
|
||||
// Draw propagation reach polygon based on contiguous good cells
|
||||
if (this.gridLookup && this.clickedLatLng) {
|
||||
// Use MARGINAL threshold (50) as minimum for propagation reach
|
||||
|
|
@ -677,6 +732,7 @@ export const PropagationMap = {
|
|||
this.detailPanel.innerHTML = ""
|
||||
}
|
||||
this.rangeCircles.clearLayers()
|
||||
if (this.scatterMarkers) this.scatterMarkers.clearLayers()
|
||||
this.clickedLatLng = null
|
||||
this.lastDetail = null
|
||||
},
|
||||
|
|
|
|||
143
lib/microwaveprop/propagation/rain_scatter.ex
Normal file
143
lib/microwaveprop/propagation/rain_scatter.ex
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
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
|
||||
|
|
@ -58,6 +58,78 @@ defmodule Microwaveprop.Weather.NexradClient do
|
|||
"#{@base_url}/#{date_path}/GIS/uscomp/n0q_#{file_ts}.png"
|
||||
end
|
||||
|
||||
@doc """
|
||||
Fetch the latest n0q frame and extract all rain cells with reflectivity
|
||||
above `min_dbz` within `radius_km` of the given point. Returns
|
||||
`{:ok, [{lat, lon, dbz}]}` or `{:error, reason}`.
|
||||
|
||||
Samples every ~5 km (10 pixels) within the bounding box for efficiency.
|
||||
"""
|
||||
@spec fetch_rain_cells(float(), float(), float(), float()) :: {:ok, [{float(), float(), float()}]} | {:error, term()}
|
||||
def fetch_rain_cells(lat, lon, radius_km \\ 300.0, min_dbz \\ 25.0) do
|
||||
now = DateTime.utc_now()
|
||||
rounded = round_to_5min(now)
|
||||
url = frame_url(rounded)
|
||||
|
||||
req_opts = Application.get_env(:microwaveprop, :nexrad_req_options, [])
|
||||
|
||||
case Req.get(url, [receive_timeout: 60_000, retry: false] ++ req_opts) do
|
||||
{:ok, %{status: 200, body: body}} when is_binary(body) ->
|
||||
case decode_png_to_pixels(body) do
|
||||
{:ok, pixels, width} ->
|
||||
cells = extract_rain_cells(pixels, width, lat, lon, radius_km, min_dbz)
|
||||
{:ok, cells}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
|
||||
{:ok, %{status: status}} ->
|
||||
{:error, "NEXRAD n0q HTTP #{status}"}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_rain_cells(pixels, width, center_lat, center_lon, radius_km, min_dbz) do
|
||||
height = div(byte_size(pixels), width)
|
||||
|
||||
# Convert radius to approximate pixel count (~0.005 deg/px, ~0.5 km/px at mid-lat)
|
||||
dlat = radius_km / 111.0
|
||||
dlon = radius_km / (111.0 * :math.cos(:math.pi() * center_lat / 180.0))
|
||||
|
||||
lat_min = center_lat - dlat
|
||||
lat_max = center_lat + dlat
|
||||
lon_min = center_lon - dlon
|
||||
lon_max = center_lon + dlon
|
||||
|
||||
{x_min, y_max_px} = latlon_to_pixel(lat_min, lon_min)
|
||||
{x_max, y_min_px} = latlon_to_pixel(lat_max, lon_max)
|
||||
|
||||
x_min = max(x_min, 0)
|
||||
x_max = min(x_max, width - 1)
|
||||
y_min_px = max(y_min_px, 0)
|
||||
y_max_px = min(y_max_px, height - 1)
|
||||
|
||||
# Sample every 10 pixels (~5 km) for efficiency
|
||||
step = 10
|
||||
|
||||
for y <- y_min_px..y_max_px//step,
|
||||
x <- x_min..x_max//step,
|
||||
offset = y * width + x,
|
||||
offset >= 0,
|
||||
offset < byte_size(pixels),
|
||||
<<_::binary-size(offset), pixel_val::8, _::binary>> = pixels,
|
||||
pixel_val > 0,
|
||||
dbz = pixel_to_dbz(pixel_val),
|
||||
dbz >= min_dbz do
|
||||
cell_lat = @lat_max - y * @deg_per_pixel
|
||||
cell_lon = @lon_min + x * @deg_per_pixel
|
||||
{Float.round(cell_lat, 3), Float.round(cell_lon, 3), Float.round(dbz, 1)}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Convert (lat, lon) to pixel coordinates in the n0q image.
|
||||
|
||||
|
|
|
|||
|
|
@ -134,7 +134,9 @@ defmodule MicrowavepropWeb.MapLive do
|
|||
%{time: DateTime.to_iso8601(f.valid_time), score: f.score}
|
||||
end)
|
||||
|
||||
payload = if detail, do: Map.put(detail, :forecast, forecast_data), else: %{}
|
||||
scatter = fetch_rain_scatter(lat, lon, band)
|
||||
|
||||
payload = if detail, do: detail |> Map.put(:forecast, forecast_data) |> Map.put(:rain_scatter, scatter), else: %{}
|
||||
{:noreply, push_event(socket, "point_detail", payload)}
|
||||
end
|
||||
|
||||
|
|
@ -253,6 +255,23 @@ defmodule MicrowavepropWeb.MapLive do
|
|||
end
|
||||
end
|
||||
|
||||
defp fetch_rain_scatter(lat, lon, band_mhz) do
|
||||
alias Microwaveprop.Propagation.RainScatter
|
||||
alias Microwaveprop.Weather.NexradClient
|
||||
|
||||
freq_ghz = band_mhz / 1000.0
|
||||
|
||||
case NexradClient.fetch_rain_cells(lat, lon) do
|
||||
{:ok, rain_cells} ->
|
||||
cells = RainScatter.find_scatter_cells(rain_cells, lat, lon, freq_ghz)
|
||||
classification = RainScatter.classify(cells)
|
||||
%{cells: cells, classification: to_string(classification)}
|
||||
|
||||
{:error, _} ->
|
||||
%{cells: [], classification: "none"}
|
||||
end
|
||||
end
|
||||
|
||||
defp band_info(band_mhz) do
|
||||
config = BandConfig.get(band_mhz)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue