prop/lib/microwaveprop/terrain/srtm.ex
Graham McIntire d61fbd346e
fix(dialyzer): clear 125+ warnings under strict flags
Enabled :error_handling, :unknown, :unmatched_returns, :extra_return,
:missing_return in an earlier commit and landed a 129-warning baseline.
Four parallel agents each fixed a directory slice:

- Core contexts (29): Radio, Release, Weather, Beacons, Cache,
  Backtest.Features, Terrain.Srtm, Ionosphere.GiroClient,
  Propagation.RunTiming, Accounts.Scope, RepoListener. Fixes were
  (a) prefix side-effect calls (Task.start, Phoenix.PubSub,
  Logger, :ets.new) with _ = ; (b) tighten/widen specs that didn't
  match actual returns; (c) add missing @type t declarations;
  (d) drop dead parse_int(nil) clause.

- Propagation + weather subdirs (15): FreshnessMonitor, NotifyListener,
  ScoreCache, ScoreCacheReconciler, Weather.FrontalAnalysis,
  Weather.Grib2.Extractor, Weather.Grib2.Wgrib2, GridCache,
  HrrrPointEnqueuer, NexradCache. Same patterns — mostly _ = on
  PubSub / :ets / Repo.insert_all; widened two specs (float ->
  number) where integer returns were reachable.

- Workers (35): BackfillEnqueue, CanadianSoundingFetch,
  ContactImport, ContactWeatherEnqueue, GefsFetch, IemreFetch,
  NarrFetch, SolarIndex, TerrainProfile, WeatherFetch. Prefixed
  Repo.update_all / Radio.set_enrichment_status! / Weather.upsert_*
  side-effect calls. Fixed one :pattern_match in
  CanadianSoundingFetch.most_recent_sounding_time/1 where a
  tautological cond guard generated unreachable code.

- Web + Mix tasks + lib_ml (46 of 50): controllers, LiveViews,
  UserAuth, and 11 mix tasks. Same prefix strategy. 4 remaining
  warnings originate in LiveTable.LiveResource dep macro expansion
  and can't be fixed without forking the dep — added .dialyzer_ignore.exs
  to suppress just those specific file:line pairs.

Also wired ignore_warnings in mix.exs dialyzer config.

mix dialyzer --format short | grep ^lib/ | wc -l -> 0
mix test: 2163 tests, 3 pre-existing flakes, 0 regressions.
2026-04-21 10:30:06 -05:00

190 lines
5.4 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
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