prop/lib/microwaveprop/terrain/srtm.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

177 lines
5.1 KiB
Elixir

defmodule Microwaveprop.Terrain.Srtm do
@moduledoc false
require Logger
@samples 3601
@void -32_768
@base_url "https://elevation-tiles-prod.s3.amazonaws.com/skadi"
@spec tile_filename(float(), float()) :: String.t()
def tile_filename(lat, lon) do
lat_floor = floor(lat)
lon_floor = floor(lon)
lat_prefix = if lat_floor >= 0, do: "N", else: "S"
lon_prefix = if lon_floor >= 0, do: "E", else: "W"
lat_str = lat_floor |> abs() |> Integer.to_string() |> String.pad_leading(2, "0")
lon_str = lon_floor |> abs() |> Integer.to_string() |> String.pad_leading(3, "0")
"#{lat_prefix}#{lat_str}#{lon_prefix}#{lon_str}.hgt"
end
@spec download_tile(float(), float(), String.t()) :: {:ok, String.t()} | {:error, term()}
def download_tile(lat, lon, tiles_dir) do
Microwaveprop.Instrument.span([:srtm, :download_tile], %{lat: lat, lon: lon}, fn ->
do_download_tile(lat, lon, tiles_dir)
end)
end
defp do_download_tile(lat, lon, tiles_dir) do
filename = tile_filename(lat, lon)
lat_dir = String.slice(filename, 0, 3)
url = "#{@base_url}/#{lat_dir}/#{filename}.gz"
path = Path.join(tiles_dir, filename)
Logger.info("Downloading SRTM tile #{filename} from S3")
case Req.get(url, req_options()) do
{:ok, %{status: 200, body: body}} ->
decompressed = :zlib.gunzip(body)
File.write!(path, decompressed)
Logger.info("SRTM tile #{filename} downloaded (#{byte_size(decompressed)} bytes)")
{:ok, path}
{:ok, %{status: 404}} ->
Logger.warning("SRTM tile #{filename} not available on S3 (404)")
{:error, :not_available}
{:ok, %{status: status}} ->
Logger.error("SRTM tile #{filename} download failed: HTTP #{status}")
{:error, "SRTM download HTTP #{status}"}
{:error, reason} ->
Logger.error("SRTM tile #{filename} download error: #{inspect(reason)}")
{:error, "SRTM download error: #{inspect(reason)}"}
end
end
@spec lookup(float(), float(), String.t(), keyword()) ::
{:ok, integer()} | {:error, :no_tile} | {:error, :void}
def lookup(lat, lon, tiles_dir, opts \\ []) do
path = Path.join(tiles_dir, tile_filename(lat, lon))
case :file.open(path, [:read, :binary, :raw]) do
{:ok, fd} ->
read_elevation(fd, lat, lon)
{:error, :enoent} ->
maybe_download_and_read(lat, lon, path, tiles_dir, opts)
end
end
defp maybe_download_and_read(lat, lon, path, tiles_dir, opts) do
if opts[:download] && File.dir?(tiles_dir) do
download_and_read(lat, lon, path, tiles_dir)
else
{:error, :no_tile}
end
end
defp download_and_read(lat, lon, path, tiles_dir) do
case download_tile(lat, lon, tiles_dir) do
{:ok, _} ->
case :file.open(path, [:read, :binary, :raw]) do
{:ok, fd} -> read_elevation(fd, lat, lon)
{:error, _} -> {:error, :no_tile}
end
{:error, _} ->
{:error, :no_tile}
end
end
@spec fetch_elevation_profile(float(), float(), float(), float(), String.t(), pos_integer(), keyword()) ::
{:ok, list(map())}
def fetch_elevation_profile(lat1, lon1, lat2, lon2, tiles_dir, n \\ 64, opts \\ []) do
pts = sample_path(lat1, lon1, lat2, lon2, n)
dist_km = haversine_km(lat1, lon1, lat2, lon2)
profile =
Enum.map(pts, fn pt ->
elev =
case lookup(pt.lat, pt.lon, tiles_dir, opts) do
{:ok, elev} -> elev
# Missing tile (water/ocean) or void value — treat as sea level
{:error, _} -> 0
end
%{lat: pt.lat, lon: pt.lon, d: pt.d, elev: elev, dist_km: pt.d * dist_km}
end)
{:ok, profile}
end
defp read_elevation(fd, lat, lon) do
row = round((floor(lat) + 1 - lat) * (@samples - 1))
col = round((lon - floor(lon)) * (@samples - 1))
offset = (row * @samples + col) * 2
result =
case :file.pread(fd, offset, 2) do
{:ok, <<elev::signed-big-integer-size(16)>>} when elev == @void ->
{:error, :void}
{:ok, <<elev::signed-big-integer-size(16)>>} ->
{:ok, elev}
_ ->
{:error, :void}
end
_ = :file.close(fd)
result
end
defp req_options do
defaults = [
compressed: false,
decode_body: false,
retry: &retry?/2,
max_retries: 3,
retry_delay: &retry_delay/1
]
overrides = Application.get_env(:microwaveprop, :srtm_req_options, [])
Keyword.merge(defaults, overrides)
end
defp retry?(_request, response) do
case response do
%Req.Response{status: status} when status in [429, 500, 502, 503, 504] -> true
%{__exception__: true} -> true
_ -> false
end
end
defp retry_delay(n) do
base = Integer.pow(2, n) * 1_000
jitter = :rand.uniform(1_000)
base + jitter
end
defp sample_path(lat1, lon1, lat2, lon2, n) do
for i <- 0..n do
f = i / n
%{
lat: lat1 + f * (lat2 - lat1),
lon: lon1 + f * (lon2 - lon1),
d: f
}
end
end
defp haversine_km(lat1, lon1, lat2, lon2), do: Microwaveprop.Geo.haversine_km(lat1, lon1, lat2, lon2)
end