prop/lib/microwaveprop/propagation/common_volume.ex
Graham McIntire 2defa3db7a
refactor: deduplicate helpers and simplify Scorer
- Unify haversine_km / deg_to_rad: Geo is the canonical home (atan2
  form for antipodal stability); Radio, common_volume, rain_scatter,
  ionosphere, terrain/srtm, terrain/elevation_client now delegate.
- Drop safe_min/safe_max wrappers in favor of Enum.min/max defaults.
- Move parse_float / parse_int to MicrowavepropWeb.LiveHelpers; eme,
  path, and rover LiveViews import from there.
- Extract MicrowavepropWeb.LocationResolver from the eme/path
  resolve_source / resolve_location duplicate. Standardize the kind
  key and "Invalid grid square" error message.
- Convert Scorer cond chains (humidity, td_depression, sky, wind,
  rain, pwat, pressure) to multi-clause guarded defs, matching the
  existing classify_time_period style.
- extract_profile_fields uses a named map accumulator instead of a
  6-tuple; build_path_conditions guards on %{temps: []} / %{dewpoints: []}.
- Collapse scores_file clamp/3 to max |> min.
- humidity_effect_label lives in BandConfig; map_live and path_live
  both use it.
2026-04-28 14:14:34 -05:00

81 lines
2.9 KiB
Elixir

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