defmodule SRTM.Client do @moduledoc """ This module holds the client for querying elevation data. """ alias __MODULE__, as: Client alias SRTM.{Error, DataCell, Source} defstruct [:client, :cache_path, :data_cells, :sources] @opaque t :: %__MODULE__{ client: Tesla.Client.t(), cache_path: String.t(), data_cells: map, sources: list } @doc """ Creates a client struct that holds configuration and parsed HGT files. If the directory at the given `path` doesn't exist, creates it. ## Options The supported options are: * `:sources` - the SRTM source providers (defaults to [ESA](http://step.esa.int/auxdata/dem/SRTMGL1/) and [USGS](https://dds.cr.usgs.gov/srtm/version2_1/)) ## Examples iex> SRTM.Client.new("./cache") {:ok, %SRTM.Client{}} """ @spec new(path :: Path.t(), opts :: list) :: {:ok, t} | {:error, error :: Error.t()} def new(path, opts \\ []) do sources = case Keyword.get(opts, :sources) do [_ | _] = sources -> sources _ -> [Source.ESA, Source.USGS] end path = Path.expand(path) case File.mkdir_p(path) do {:error, reason} -> {:error, %Error{reason: :io_error, message: "Creation of #{path} failed: #{reason}"}} :ok -> middleware = [ {Tesla.Middleware.Headers, [{"user-agent", "github.com/adriankumpf/srtm"}]} ] client = Tesla.client(middleware, {Tesla.Adapter.Mint, recv_timeout: 30_000}) {:ok, %__MODULE__{client: client, cache_path: path, data_cells: %{}, sources: sources}} end end @doc """ Removes parsed HGT files from the in-memory cache. ## Options The supported options are: * `:keep` - the number of most recently used HGT files to keep (default: 0) ## Examples iex> SRTM.Client.purge_in_memory_cache(client, keep: 1) {:ok, %SRTM.Client{}} """ @spec purge_in_memory_cache(client :: t, opts :: list) :: {:ok, t} def purge_in_memory_cache(%Client{} = client, opts \\ []) do keep = Keyword.get(opts, :keep, 0) purged_data_cells = client.data_cells |> Enum.sort_by(fn {_, {:ok, %DataCell{last_used: d}}} -> d end, &order_by_date_desc/2) |> Enum.take(keep) |> Enum.into(%{}) {:ok, %Client{client | data_cells: purged_data_cells}} end @doc false def get_elevation(%Client{} = client, latitude, longitude) do case get_data_cell(client, {latitude, longitude}) do {:ok, %DataCell{} = dc, %Client{} = client} -> elevation = DataCell.get_elevation(dc, latitude, longitude) {:ok, elevation, client} {:error, :out_of_bounds} -> {:ok, nil, client} {:error, reason} -> {:error, reason} end end defp get_data_cell(%Client{data_cells: data_cells, sources: sources} = client, {lat, lng}) do {cell_lat, cell_lng} = {floor(lat), floor(lng)} load_cell = fn -> with {:ok, hgt_path} <- fetch([Source.Cache | sources], [client, {lat, lng}]) do DataCell.from_file(hgt_path) end end with {:ok, data_cell} <- Map.get_lazy(data_cells, {lat, lng}, load_cell) do data_cell = %DataCell{data_cell | last_used: DateTime.utc_now()} data_cells = Map.put(data_cells, {cell_lat, cell_lng}, {:ok, data_cell}) {:ok, data_cell, %Client{client | data_cells: data_cells}} end end defp fetch(sources, args, acc \\ {:error, :unreachable}) defp fetch(_sources, _args, {:ok, hgt_file}), do: {:ok, hgt_file} defp fetch([], _args, {:error, reason}), do: {:error, reason} defp fetch([source | rest], args, _acc), do: fetch(rest, args, apply(source, :fetch, args)) defp order_by_date_desc(d0, d1) do case DateTime.compare(d0, d1) do :gt -> true _ -> false end end end