Current section

Files

Jump to
sidereon lib sidereon terrain mmap_terrain.ex
Raw

lib/sidereon/terrain/mmap_terrain.ex

defmodule Sidereon.Terrain.MmapTerrain do
@moduledoc """
Memory-mappable terrain store conversion and lookup.
A terrain store is a single byte container built from DTED tiles. The reader
can be opened from bytes with `from_bytes/1` or from a path with `from_path/1`.
Heights returned by this module are typed as orthometric height `H` in metres
above the EGM96 mean sea level geoid. Use the ellipsoidal conversion functions
when a WGS84 ellipsoidal height `h = H + N` is needed.
Terrain lookup axes are always `(longitude_deg, latitude_deg)`.
"""
alias __MODULE__.{
Egm96FifteenMinuteGeoid,
EllipsoidalHeightM,
OrthometricHeightM,
TerrainDatumError,
TerrainGeoidModel,
TerrainStoreError,
TerrainStoreTileIndex
}
alias Sidereon.NIF
alias Sidereon.Terrain.MmapTerrain
@enforce_keys [:handle]
defstruct [:handle]
@typedoc """
Parsed terrain store handle.
"""
@type t :: %__MODULE__{handle: reference()}
@typedoc """
Terrain interpolation method.
"""
@type interpolation :: :bilinear | :nearest_posting
@typedoc """
Terrain store conversion or parse error.
"""
@type terrain_store_error :: TerrainStoreError.t()
@typedoc """
Terrain datum conversion or geoid-grid loading error.
"""
@type terrain_datum_error :: TerrainDatumError.t()
defmodule VerticalDatum do
@moduledoc """
Vertical datum carried by terrain store tile records.
"""
@typedoc """
Orthometric height above the EGM96 mean sea level geoid.
"""
@type t :: :egm96_msl_orthometric
@doc false
@spec from_nif(atom() | String.t()) :: t() | term()
def from_nif(:egm96_msl_orthometric), do: :egm96_msl_orthometric
def from_nif("egm96_msl_orthometric"), do: :egm96_msl_orthometric
def from_nif(other), do: other
end
defmodule OrthometricHeightM do
@moduledoc """
Orthometric terrain height `H` in metres above the EGM96 mean sea level geoid.
"""
alias MmapTerrain.{EllipsoidalHeightM, TerrainDatumError, TerrainGeoidModel}
alias Sidereon.NIF
@enforce_keys [:value_m]
defstruct [:value_m]
@typedoc """
Orthometric height `H` in metres.
"""
@type t :: %__MODULE__{value_m: float()}
@doc """
Build an orthometric height `H` in metres.
"""
@spec new(number()) :: t()
def new(value_m), do: %__MODULE__{value_m: value_m / 1.0}
@doc """
Return the orthometric height `H` in metres.
"""
@spec metres(t()) :: float()
def metres(%__MODULE__{value_m: value_m}), do: value_m
@doc """
Convert this orthometric height to WGS84 ellipsoidal height with degree coordinates.
Coordinates are geoid order `(latitude_deg, longitude_deg)`. The default
geoid model is the embedded EGM96 1-degree grid. To use the EGM96
15-minute grid, pass `TerrainGeoidModel.egm96_fifteen_minute/1` with a
loaded `Egm96FifteenMinuteGeoid`.
"""
@spec to_ellipsoidal_height_deg(t(), number(), number(), TerrainGeoidModel.t() | :egm96_one_degree) ::
{:ok, EllipsoidalHeightM.t()} | {:error, TerrainDatumError.t() | term()}
def to_ellipsoidal_height_deg(height, latitude_deg, longitude_deg, model \\ TerrainGeoidModel.egm96_one_degree())
def to_ellipsoidal_height_deg(%__MODULE__{value_m: value_m}, latitude_deg, longitude_deg, model) do
with {:ok, {model_name, geoid_handle}} <- TerrainGeoidModel.to_nif(model) do
NIF.terrain_store_orthometric_to_ellipsoidal_height_deg(
value_m,
latitude_deg / 1.0,
longitude_deg / 1.0,
model_name,
geoid_handle
)
|> ellipsoidal_result()
end
rescue
e in ErlangError -> {:error, e.original}
end
@doc """
Convert this orthometric height to WGS84 ellipsoidal height with radian coordinates.
Coordinates are geoid order `(latitude_rad, longitude_rad)`. The default
geoid model is the embedded EGM96 1-degree grid.
"""
@spec to_ellipsoidal_height_rad(t(), number(), number(), TerrainGeoidModel.t() | :egm96_one_degree) ::
{:ok, EllipsoidalHeightM.t()} | {:error, TerrainDatumError.t() | term()}
def to_ellipsoidal_height_rad(height, latitude_rad, longitude_rad, model \\ TerrainGeoidModel.egm96_one_degree())
def to_ellipsoidal_height_rad(%__MODULE__{value_m: value_m}, latitude_rad, longitude_rad, model) do
with {:ok, {model_name, geoid_handle}} <- TerrainGeoidModel.to_nif(model) do
NIF.terrain_store_orthometric_to_ellipsoidal_height_rad(
value_m,
latitude_rad / 1.0,
longitude_rad / 1.0,
model_name,
geoid_handle
)
|> ellipsoidal_result()
end
rescue
e in ErlangError -> {:error, e.original}
end
defp ellipsoidal_result({:ok, value_m}), do: {:ok, EllipsoidalHeightM.new(value_m)}
defp ellipsoidal_result({:error, reason}), do: {:error, TerrainDatumError.from_nif(reason)}
defp ellipsoidal_result(other), do: {:error, other}
end
defmodule EllipsoidalHeightM do
@moduledoc """
WGS84 ellipsoidal terrain height `h` in metres.
"""
@enforce_keys [:value_m]
defstruct [:value_m]
@typedoc """
Ellipsoidal height `h` in metres above the WGS84 ellipsoid.
"""
@type t :: %__MODULE__{value_m: float()}
@doc """
Build an ellipsoidal height `h` in metres.
"""
@spec new(number()) :: t()
def new(value_m), do: %__MODULE__{value_m: value_m / 1.0}
@doc """
Return the ellipsoidal height `h` in metres.
"""
@spec metres(t()) :: float()
def metres(%__MODULE__{value_m: value_m}), do: value_m
end
defmodule TerrainStoreTileIndex do
@moduledoc """
Metadata for one tile record in a terrain store.
"""
alias Sidereon.Terrain.MmapTerrain.VerticalDatum
@enforce_keys [
:lat_index,
:lon_index,
:min_longitude_deg,
:min_latitude_deg,
:max_longitude_deg,
:max_latitude_deg,
:lon_count,
:lat_count,
:data_offset,
:data_len,
:checksum64,
:vertical_datum
]
defstruct [
:lat_index,
:lon_index,
:min_longitude_deg,
:min_latitude_deg,
:max_longitude_deg,
:max_latitude_deg,
:lon_count,
:lat_count,
:data_offset,
:data_len,
:checksum64,
:vertical_datum
]
@typedoc """
Parsed tile index record.
"""
@type t :: %__MODULE__{
lat_index: integer(),
lon_index: integer(),
min_longitude_deg: float(),
min_latitude_deg: float(),
max_longitude_deg: float(),
max_latitude_deg: float(),
lon_count: non_neg_integer(),
lat_count: non_neg_integer(),
data_offset: non_neg_integer(),
data_len: non_neg_integer(),
checksum64: non_neg_integer(),
vertical_datum: VerticalDatum.t()
}
@doc false
@spec from_nif(map()) :: t()
def from_nif(fields) do
%__MODULE__{
lat_index: fields.lat_index,
lon_index: fields.lon_index,
min_longitude_deg: fields.min_longitude_deg,
min_latitude_deg: fields.min_latitude_deg,
max_longitude_deg: fields.max_longitude_deg,
max_latitude_deg: fields.max_latitude_deg,
lon_count: fields.lon_count,
lat_count: fields.lat_count,
data_offset: fields.data_offset,
data_len: fields.data_len,
checksum64: fields.checksum64,
vertical_datum: VerticalDatum.from_nif(fields.vertical_datum)
}
end
end
defmodule Egm96FifteenMinuteGeoid do
@moduledoc """
Loaded EGM96 15-minute `WW15MGH.DAC` geoid grid.
This grid is only used when a caller explicitly chooses the
`:egm96_fifteen_minute` terrain geoid model. Missing files return a typed
`TerrainDatumError` and do not fall back to the embedded 1-degree grid.
"""
alias Sidereon.NIF
alias Sidereon.Terrain.MmapTerrain.TerrainDatumError
@enforce_keys [:handle]
defstruct [:handle]
@typedoc """
Loaded EGM96 15-minute geoid handle.
"""
@type t :: %__MODULE__{handle: reference()}
@doc """
Load `WW15MGH.DAC` bytes as an EGM96 15-minute geoid grid.
"""
@spec from_ww15mgh_dac_bytes(binary()) :: {:ok, t()} | {:error, TerrainDatumError.t() | term()}
def from_ww15mgh_dac_bytes(bytes) when is_binary(bytes) do
NIF.terrain_store_egm96_fifteen_minute_geoid_from_bytes(bytes)
|> geoid_result()
rescue
e in ErlangError -> {:error, e.original}
end
@doc """
Load a `WW15MGH.DAC` path as an EGM96 15-minute geoid grid.
If the file is absent, the result is
`{:error, %TerrainDatumError{kind: :missing_egm96_dac}}` with the path and a
remediation string.
"""
@spec from_ww15mgh_dac_path(String.t()) :: {:ok, t()} | {:error, TerrainDatumError.t() | term()}
def from_ww15mgh_dac_path(path) when is_binary(path) do
NIF.terrain_store_egm96_fifteen_minute_geoid_from_path(path)
|> geoid_result()
rescue
e in ErlangError -> {:error, e.original}
end
defp geoid_result({:ok, handle}) when is_reference(handle), do: {:ok, %__MODULE__{handle: handle}}
defp geoid_result({:error, reason}), do: {:error, TerrainDatumError.from_nif(reason)}
defp geoid_result(other), do: {:error, other}
end
defmodule TerrainGeoidModel do
@moduledoc """
Geoid tier used for terrain orthometric-to-ellipsoidal conversion.
"""
alias Sidereon.Terrain.MmapTerrain.Egm96FifteenMinuteGeoid
@enforce_keys [:tier]
defstruct [:tier, :geoid]
@typedoc """
Terrain geoid model.
"""
@type t :: %__MODULE__{
tier: :egm96_one_degree | :egm96_fifteen_minute,
geoid: Egm96FifteenMinuteGeoid.t() | nil
}
@doc """
Use the embedded EGM96 1-degree grid.
"""
@spec egm96_one_degree() :: t()
def egm96_one_degree, do: %__MODULE__{tier: :egm96_one_degree}
@doc """
Use a caller-supplied EGM96 15-minute grid.
"""
@spec egm96_fifteen_minute(Egm96FifteenMinuteGeoid.t()) :: t()
def egm96_fifteen_minute(%Egm96FifteenMinuteGeoid{} = geoid) do
%__MODULE__{tier: :egm96_fifteen_minute, geoid: geoid}
end
@doc false
@spec to_nif(t() | :egm96_one_degree) :: {:ok, {String.t(), reference() | nil}} | {:error, term()}
def to_nif(:egm96_one_degree), do: {:ok, {"egm96_one_degree", nil}}
def to_nif(%__MODULE__{tier: :egm96_one_degree}), do: {:ok, {"egm96_one_degree", nil}}
def to_nif(%__MODULE__{tier: :egm96_fifteen_minute, geoid: %Egm96FifteenMinuteGeoid{handle: handle}}) do
{:ok, {"egm96_fifteen_minute", handle}}
end
def to_nif(_other), do: {:error, :bad_geoid_model}
end
defmodule TerrainStoreError do
@moduledoc """
Terrain store conversion, parse, and checksum error.
"""
@enforce_keys [:kind]
defstruct [
:kind,
:path,
:message,
:reason,
:version,
:tag,
:lat_index,
:lon_index,
:expected,
:found
]
@typedoc """
Typed terrain store error.
"""
@type t :: %__MODULE__{
kind:
:io
| :parse
| :unsupported_version
| :unsupported_datum
| :duplicate_tile
| :checksum,
path: String.t() | nil,
message: String.t() | nil,
reason: String.t() | nil,
version: non_neg_integer() | nil,
tag: non_neg_integer() | nil,
lat_index: integer() | nil,
lon_index: integer() | nil,
expected: non_neg_integer() | nil,
found: non_neg_integer() | nil
}
@doc false
@spec from_nif(term()) :: t() | term()
def from_nif({:io, path, message}), do: %__MODULE__{kind: :io, path: path, message: message}
def from_nif({:parse, reason}), do: %__MODULE__{kind: :parse, reason: reason}
def from_nif({:unsupported_version, version}), do: %__MODULE__{kind: :unsupported_version, version: version}
def from_nif({:unsupported_datum, tag}), do: %__MODULE__{kind: :unsupported_datum, tag: tag}
def from_nif({:duplicate_tile, lat_index, lon_index}) do
%__MODULE__{kind: :duplicate_tile, lat_index: lat_index, lon_index: lon_index}
end
def from_nif({:checksum, lat_index, lon_index, expected, found}) do
%__MODULE__{
kind: :checksum,
lat_index: lat_index,
lon_index: lon_index,
expected: expected,
found: found
}
end
def from_nif(other), do: other
end
defmodule TerrainDatumError do
@moduledoc """
Terrain vertical-datum conversion and optional geoid-grid loading error.
"""
@enforce_keys [:kind]
defstruct [:kind, :path, :message, :reason, :remediation]
@typedoc """
Typed terrain datum error.
"""
@type t :: %__MODULE__{
kind: :terrain | :geoid | :io | :missing_egm96_dac,
path: String.t() | nil,
message: String.t() | nil,
reason: String.t() | nil,
remediation: String.t() | nil
}
@doc false
@spec from_nif(term()) :: t() | term()
def from_nif({:terrain, reason}), do: %__MODULE__{kind: :terrain, reason: reason}
def from_nif({:geoid, reason}), do: %__MODULE__{kind: :geoid, reason: reason}
def from_nif({:io, path, message}), do: %__MODULE__{kind: :io, path: path, message: message}
def from_nif({:missing_egm96_dac, path, remediation}) do
%__MODULE__{kind: :missing_egm96_dac, path: path, remediation: remediation}
end
def from_nif(other), do: other
end
@doc """
Convert a DTED tile tree into terrain store bytes.
"""
@spec dted_tree_to_mmap_store(String.t()) :: {:ok, binary()} | {:error, terrain_store_error() | term()}
def dted_tree_to_mmap_store(root) when is_binary(root) do
NIF.terrain_store_dted_tree_to_mmap_store(root)
|> store_binary_result()
rescue
e in ErlangError -> {:error, e.original}
end
@doc """
Convert a DTED tile tree and write terrain store bytes to `out_path`.
"""
@spec write_dted_tree_to_mmap_store(String.t(), String.t()) :: :ok | {:error, terrain_store_error() | term()}
def write_dted_tree_to_mmap_store(root, out_path) when is_binary(root) and is_binary(out_path) do
case NIF.terrain_store_write_dted_tree_to_mmap_store(root, out_path) do
:ok -> :ok
{:error, reason} -> {:error, TerrainStoreError.from_nif(reason)}
other -> {:error, other}
end
rescue
e in ErlangError -> {:error, e.original}
end
@doc """
Return the FNV-1a checksum for terrain store bytes.
"""
@spec terrain_store_checksum64(binary()) :: non_neg_integer()
def terrain_store_checksum64(bytes) when is_binary(bytes), do: NIF.terrain_store_checksum64_bytes(bytes)
@doc """
Parse terrain store bytes into a reader handle.
"""
@spec from_bytes(binary()) :: {:ok, t()} | {:error, terrain_store_error() | term()}
def from_bytes(bytes) when is_binary(bytes) do
NIF.terrain_store_mmap_from_bytes(bytes)
|> store_handle_result()
rescue
e in ErlangError -> {:error, e.original}
end
@doc """
Parse owned terrain store bytes into a reader handle.
"""
@spec from_vec(binary()) :: {:ok, t()} | {:error, terrain_store_error() | term()}
def from_vec(bytes) when is_binary(bytes) do
NIF.terrain_store_mmap_from_vec(bytes)
|> store_handle_result()
rescue
e in ErlangError -> {:error, e.original}
end
@doc """
Read a terrain store file into a reader handle.
"""
@spec from_path(String.t()) :: {:ok, t()} | {:error, terrain_store_error() | term()}
def from_path(path) when is_binary(path) do
NIF.terrain_store_mmap_from_path(path)
|> store_handle_result()
rescue
e in ErlangError -> {:error, e.original}
end
@doc """
Return orthometric terrain height at `(longitude_deg, latitude_deg)`.
"""
@spec height_m(t(), number(), number(), keyword()) :: {:ok, OrthometricHeightM.t()} | {:error, term()}
def height_m(%__MODULE__{} = terrain, longitude_deg, latitude_deg, opts \\ []) do
height_m_with_options(terrain, longitude_deg, latitude_deg, opts)
end
@doc """
Return orthometric terrain height at `(longitude_deg, latitude_deg)` with lookup options.
"""
@spec height_m_with_options(t(), number(), number(), keyword()) :: {:ok, OrthometricHeightM.t()} | {:error, term()}
def height_m_with_options(%__MODULE__{handle: handle}, longitude_deg, latitude_deg, opts) do
with {:ok, interpolation} <- interpolation(opts) do
NIF.terrain_store_height_m_with_options(handle, longitude_deg / 1.0, latitude_deg / 1.0, interpolation)
|> orthometric_result()
end
rescue
e in ErlangError -> {:error, e.original}
end
@doc """
Return typed orthometric terrain height at `(longitude_deg, latitude_deg)`.
"""
@spec orthometric_height_m(t(), number(), number(), keyword()) ::
{:ok, OrthometricHeightM.t()} | {:error, term()}
def orthometric_height_m(%__MODULE__{} = terrain, longitude_deg, latitude_deg, opts \\ []) do
orthometric_height_m_with_options(terrain, longitude_deg, latitude_deg, opts)
end
@doc """
Return typed orthometric terrain height with lookup options.
"""
@spec orthometric_height_m_with_options(t(), number(), number(), keyword()) ::
{:ok, OrthometricHeightM.t()} | {:error, term()}
def orthometric_height_m_with_options(%__MODULE__{handle: handle}, longitude_deg, latitude_deg, opts) do
with {:ok, interpolation} <- interpolation(opts) do
NIF.terrain_store_orthometric_height_m_with_options(
handle,
longitude_deg / 1.0,
latitude_deg / 1.0,
interpolation
)
|> orthometric_result()
end
rescue
e in ErlangError -> {:error, e.original}
end
@doc """
Evaluate `(longitude_deg, latitude_deg)` points as orthometric heights.
"""
@spec height_batch(t(), [{number(), number()}], keyword()) ::
[{:ok, OrthometricHeightM.t()} | {:error, term()}] | {:error, term()}
def height_batch(%__MODULE__{} = terrain, points, opts \\ []) do
orthometric_height_batch(terrain, points, opts)
end
@doc """
Evaluate `(longitude_deg, latitude_deg)` points as typed orthometric heights.
"""
@spec orthometric_height_batch(t(), [{number(), number()}], keyword()) ::
[{:ok, OrthometricHeightM.t()} | {:error, term()}] | {:error, term()}
def orthometric_height_batch(%__MODULE__{handle: handle}, points, opts \\ []) when is_list(points) do
with {:ok, interpolation} <- interpolation(opts),
{:ok, normalized} <- points(points) do
NIF.terrain_store_orthometric_height_batch(handle, normalized, interpolation)
|> Enum.map(&orthometric_result/1)
end
rescue
e in ErlangError -> {:error, e.original}
end
@doc """
Return WGS84 ellipsoidal terrain height using the embedded EGM96 1-degree grid.
"""
@spec ellipsoidal_height_m(t(), number(), number(), keyword()) ::
{:ok, EllipsoidalHeightM.t()} | {:error, terrain_datum_error() | term()}
def ellipsoidal_height_m(%__MODULE__{} = terrain, longitude_deg, latitude_deg, opts \\ []) do
ellipsoidal_height_m_with_options(terrain, longitude_deg, latitude_deg, opts)
end
@doc """
Return WGS84 ellipsoidal terrain height with lookup options.
"""
@spec ellipsoidal_height_m_with_options(t(), number(), number(), keyword()) ::
{:ok, EllipsoidalHeightM.t()} | {:error, terrain_datum_error() | term()}
def ellipsoidal_height_m_with_options(%__MODULE__{handle: handle}, longitude_deg, latitude_deg, opts) do
with {:ok, interpolation} <- interpolation(opts) do
NIF.terrain_store_ellipsoidal_height_m_with_options(
handle,
longitude_deg / 1.0,
latitude_deg / 1.0,
interpolation
)
|> ellipsoidal_result()
end
rescue
e in ErlangError -> {:error, e.original}
end
@doc """
Return WGS84 ellipsoidal terrain height with an explicit geoid model.
Passing `TerrainGeoidModel.egm96_fifteen_minute/1` requires a loaded
`Egm96FifteenMinuteGeoid`; it never falls back to the embedded 1-degree grid.
"""
@spec ellipsoidal_height_m_with_model(
t(),
number(),
number(),
TerrainGeoidModel.t() | :egm96_one_degree,
keyword()
) :: {:ok, EllipsoidalHeightM.t()} | {:error, terrain_datum_error() | term()}
def ellipsoidal_height_m_with_model(%__MODULE__{handle: handle}, longitude_deg, latitude_deg, model, opts \\ []) do
with {:ok, interpolation} <- interpolation(opts),
{:ok, {model_name, geoid_handle}} <- TerrainGeoidModel.to_nif(model) do
NIF.terrain_store_ellipsoidal_height_m_with_model(
handle,
longitude_deg / 1.0,
latitude_deg / 1.0,
interpolation,
model_name,
geoid_handle
)
|> ellipsoidal_result()
end
rescue
e in ErlangError -> {:error, e.original}
end
@doc """
Return parsed tile index records.
"""
@spec tile_index(t()) :: [TerrainStoreTileIndex.t()]
def tile_index(%__MODULE__{handle: handle}) do
handle
|> NIF.terrain_store_tile_index()
|> Enum.map(&TerrainStoreTileIndex.from_nif/1)
end
@doc """
Return the file-level vertical datum.
"""
@spec vertical_datum(t()) :: VerticalDatum.t()
def vertical_datum(%__MODULE__{handle: handle}) do
handle
|> NIF.terrain_store_vertical_datum()
|> VerticalDatum.from_nif()
end
@doc """
Return a checksum for a parsed store handle or terrain store bytes.
"""
@spec checksum64(t() | binary()) :: non_neg_integer()
def checksum64(%__MODULE__{handle: handle}), do: NIF.terrain_store_mmap_checksum64(handle)
def checksum64(bytes) when is_binary(bytes), do: terrain_store_checksum64(bytes)
@doc """
Return canonical terrain store bytes from a parsed handle.
"""
@spec to_bytes(t()) :: binary()
def to_bytes(%__MODULE__{handle: handle}), do: NIF.terrain_store_to_bytes(handle)
defp store_binary_result({:ok, bytes}) when is_binary(bytes), do: {:ok, bytes}
defp store_binary_result({:error, reason}), do: {:error, TerrainStoreError.from_nif(reason)}
defp store_binary_result(other), do: {:error, other}
defp store_handle_result({:ok, handle}) when is_reference(handle), do: {:ok, %__MODULE__{handle: handle}}
defp store_handle_result({:error, reason}), do: {:error, TerrainStoreError.from_nif(reason)}
defp store_handle_result(other), do: {:error, other}
defp orthometric_result({:ok, value_m}), do: {:ok, OrthometricHeightM.new(value_m)}
defp orthometric_result({:error, reason}), do: {:error, reason}
defp orthometric_result(other), do: {:error, other}
defp ellipsoidal_result({:ok, value_m}), do: {:ok, EllipsoidalHeightM.new(value_m)}
defp ellipsoidal_result({:error, reason}), do: {:error, TerrainDatumError.from_nif(reason)}
defp ellipsoidal_result(other), do: {:error, other}
defp interpolation(opts) do
case Keyword.get(opts, :interpolation, :bilinear) do
:bilinear -> {:ok, "bilinear"}
:nearest_posting -> {:ok, "nearest_posting"}
other -> {:error, {:bad_interpolation, other}}
end
end
defp points(points) do
{:ok,
Enum.map(points, fn {longitude_deg, latitude_deg} ->
{longitude_deg / 1.0, latitude_deg / 1.0}
end)}
rescue
_ -> {:error, :bad_points}
end
end