- 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.
177 lines
5.1 KiB
Elixir
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
|