Aliases: add module aliases for 9 nested module references Apply: replace apply/3 with direct module attribute calls Line length: break 1 long spec line Refactoring: extract helpers to reduce complexity and nesting in show.ex, radio.ex, weather workers, terrain, duct detection, backfill dashboard, contact map, and mix tasks
184 lines
5.2 KiB
Elixir
184 lines
5.2 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
|
|
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())} | {:error, term()}
|
|
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
|
|
dlat = deg_to_rad(lat2 - lat1)
|
|
dlon = deg_to_rad(lon2 - lon1)
|
|
rlat1 = deg_to_rad(lat1)
|
|
rlat2 = deg_to_rad(lat2)
|
|
|
|
a =
|
|
:math.sin(dlat / 2) ** 2 +
|
|
:math.cos(rlat1) * :math.cos(rlat2) * :math.sin(dlon / 2) ** 2
|
|
|
|
2 * 6371.0 * :math.asin(:math.sqrt(a))
|
|
end
|
|
|
|
defp deg_to_rad(deg), do: deg * :math.pi() / 180
|
|
end
|