defmodule Microwaveprop.Propagation.CommonVolume do @moduledoc """ Geometry for the "common volume" of a microwave QSO — the lens-shaped intersection of two circles of radius R around the endpoints. Rain-scatter requires both stations' beams to intersect a precipitating cell, so the cell has to live inside both 400 km-radius neighborhoods. This module provides the geometric primitives a classifier needs: * `in_common_volume?/4` — is a point inside both disks? * `bounding_box/3` — lat/lon rectangle that contains the lens * `area_km2/3` — area of the intersection, for coverage normalization Distances are spherical-great-circle (haversine) at mean Earth radius. """ @km_per_deg_lat 111.32 @type latlon :: {float(), float()} @type bbox :: %{min_lat: float(), max_lat: float(), min_lon: float(), max_lon: float()} @doc "Is `point` within `radius_km` of both endpoints?" @spec in_common_volume?(latlon(), latlon(), latlon(), float()) :: boolean() def in_common_volume?(pos1, pos2, point, radius_km) do haversine_km(pos1, point) <= radius_km and haversine_km(pos2, point) <= radius_km end @doc """ Lat/lon rectangle containing the common volume. Returns `:empty` when the two circles don't overlap. """ @spec bounding_box(latlon(), latlon(), float()) :: bbox() | :empty def bounding_box({lat1, lon1} = pos1, {lat2, lon2} = pos2, radius_km) do if haversine_km(pos1, pos2) > 2 * radius_km do :empty else lat_pad = radius_km / @km_per_deg_lat # Approximate longitude degrees-per-km at the higher-latitude endpoint # (narrower at higher lat, so more conservative). ref_lat = max(abs(lat1), abs(lat2)) km_per_deg_lon = @km_per_deg_lat * max(:math.cos(ref_lat * :math.pi() / 180.0), 0.01) lon_pad = radius_km / km_per_deg_lon %{ min_lat: max(lat1 - lat_pad, lat2 - lat_pad), max_lat: min(lat1 + lat_pad, lat2 + lat_pad), min_lon: max(lon1 - lon_pad, lon2 - lon_pad), max_lon: min(lon1 + lon_pad, lon2 + lon_pad) } end end @doc """ Area (km²) of the lens-shaped intersection of two circles of radius `radius_km` centered at `pos1` and `pos2`. Returns `0.0` when the circles don't overlap. Uses the standard two-circle lens formula with the great-circle distance between centers. """ @spec area_km2(latlon(), latlon(), float()) :: float() def area_km2(pos1, pos2, radius_km) do d = haversine_km(pos1, pos2) cond do d >= 2 * radius_km -> 0.0 d <= 0.0 -> :math.pi() * radius_km * radius_km true -> r = radius_km # Lens area = 2 * [r² * arccos(d/2r) - (d/4) * sqrt(4r² - d²)] part_arc = r * r * :math.acos(d / (2 * r)) part_tri = d / 4 * :math.sqrt(4 * r * r - d * d) 2 * (part_arc - part_tri) end end defp haversine_km({lat1, lon1}, {lat2, lon2}), do: Microwaveprop.Geo.haversine_km(lat1, lon1, lat2, lon2) end