defmodule Microwaveprop.Propagation.RainScatterClassifier do @moduledoc """ Rule-based classifier that labels a contact with its most likely propagation mechanism: rain scatter vs tropospheric ducting vs plain troposcatter vs unknown. Takes a small input map so callers can assemble it from whatever data they have (schema rows at submit time, aggregated SQL in backfill, etc.) without this module depending on Ecto. ## Inputs %{ band_mhz: integer(), distance_km: float(), # common-volume radar stats (nil if no radar sweep covers the CV) radar: nil | %{ max_dbz: float() | nil, heavy_rain_pixel_count: non_neg_integer(), coverage_pct: float() | nil }, # true if HRRR detected a trapping layer at *either* endpoint duct_either_endpoint: boolean() } ## Outputs * `:likely_rainscatter` — 5-11 GHz, path ≤ 800 km, heavy rain (≥ 35 dBZ with several pixels) inside the common volume, no duct at either end. * `:rainscatter_possible` — light rain (25-35 dBZ) in the CV, no duct; could be rainscatter, could be weak tropo. * `:tropo_duct` — HRRR ducting signature at either endpoint dominates. * `:troposcatter` — no duct, no meaningful rain → default tropospheric forward scatter, which carries most "clear-air" microwave contacts. * `:unknown` — not enough information (no radar sweep, poor coverage). These labels are coarse on purpose: with post-hoc HRRR + n0q data you can't prove the mechanism, but you *can* separate the cohorts enough to calibrate band-weights and to show the likely mode on a QSO detail page. """ # Rainscatter is feasible roughly 5-11 GHz. Below, scattering cross-section # is too small; above, absorption kills the signal before it scatters. @rs_band_min_mhz 5_000 @rs_band_max_mhz 11_000 # Geometry limit: common volume stops existing beyond ~2R = 800 km. @rs_max_distance_km 800.0 # Reflectivity thresholds (dBZ) on the n0q composite. @light_rain_dbz 25.0 @heavy_rain_dbz 35.0 # How many heavy pixels count as a "real" thunderstorm cell in the CV. @heavy_pixel_floor 3 # Minimum radar coverage pct to trust the reading. Below this the CV # scan didn't have enough valid pixels to say either way. @min_coverage_pct 25.0 @type result :: :likely_rainscatter | :rainscatter_possible | :tropo_duct | :troposcatter | :unknown @spec classify(map()) :: result() def classify(%{duct_either_endpoint: true}), do: :tropo_duct def classify(%{band_mhz: band, distance_km: dist} = input) do if rainscatter_geometry?(band, dist) do classify_from_radar(input) else fallback_non_rainscatter(input) end end defp rainscatter_geometry?(band_mhz, distance_km) do band_mhz >= @rs_band_min_mhz and band_mhz <= @rs_band_max_mhz and distance_km <= @rs_max_distance_km end defp classify_from_radar(%{radar: nil}), do: :unknown defp classify_from_radar(%{radar: radar}) do coverage = radar[:coverage_pct] || 0.0 max_dbz = radar[:max_dbz] || 0.0 heavy = radar[:heavy_rain_pixel_count] || 0 cond do coverage < @min_coverage_pct -> :unknown max_dbz >= @heavy_rain_dbz and heavy >= @heavy_pixel_floor -> :likely_rainscatter max_dbz >= @light_rain_dbz -> :rainscatter_possible true -> :troposcatter end end defp fallback_non_rainscatter(%{radar: nil}), do: :unknown defp fallback_non_rainscatter(_), do: :troposcatter end