diff --git a/docs/examples.rst b/docs/examples.rst index 011b0e8..7ad9dea 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -2,6 +2,7 @@ Examples ======== .. nbgallery:: + /_temp/excesstopography.ipynb /_temp/plotting.ipynb /_temp/test_genGrid /_temp/test_GridObject diff --git a/examples/excesstopography.ipynb b/examples/excesstopography.ipynb new file mode 100644 index 0000000..5ea96fa --- /dev/null +++ b/examples/excesstopography.ipynb @@ -0,0 +1,98 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Excesstopography\n", + "================\n", + "\n", + "This example will showcase how the excesstopography function can be used." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import topotoolbox as topo\n", + "dem = topo.gen_random()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "There are two methods that can be used to calculate the new GridObject. 'fsm2d' and 'fmm2d', where 'fsm2d' is the default since it requires less memory and is generally faster. This function needs a threshold matrix to calculate the excess topography. If no value/matrix is provided by the user, a default matrix filled with the value 0.2 is used." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "excess_fsm = dem.excesstopography(threshold=0.5)\n", + "topo.show(dem, excess_fsm)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "excess_fmm = dem.excesstopography(method='fmm2d')\n", + "topo.show(dem, excess_fmm)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If some sections od the GridObject should be differently than others, use another GridObject or np.ndarray to add custom value for the threshold slopes. Make sure that the shape of your threshold matches the one of your GridObject." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "# Generate custom matrix\n", + "custom_matrix = np.empty(dem.shape, order='F')\n", + "midpoint = dem.shape[0] // 2\n", + "custom_matrix[:midpoint, :] = 0.5\n", + "custom_matrix[midpoint:, :] = 0.2\n", + "\n", + "\n", + "excess_custom = dem.excesstopography(threshold=custom_matrix)\n", + "topo.show(dem, excess_custom)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/lib/grid.cpp b/src/lib/grid.cpp index f215ee3..b1e4f8e 100644 --- a/src/lib/grid.cpp +++ b/src/lib/grid.cpp @@ -39,10 +39,50 @@ void wrap_identifyflats(py::array_t output, py::array_t dem, ptr identifyflats(output_ptr, dem_ptr, nrows, ncols); } +// wrap_excesstopography_fsm2d: +// Parameters: +// excess: A NumPy array to store the excess topography values computed by the FSM2D algorithm. +// dem: A NumPy array representing the digital elevation model. +// threshold_slopes: A NumPy array representing the slope thresholds for excess topography calculation. +// cellsize: The size of each cell in the DEM. +// nrows: Number of rows in the input DEM. +// ncols: Number of columns in the input DEM. + +void wrap_excesstopography_fsm2d(py::array_t excess, py::array_t dem, py::array_t threshold_slopes, float cellsize, ptrdiff_t nrows, ptrdiff_t ncols){ + float *excess_ptr = excess.mutable_data(); + float *dem_ptr = dem.mutable_data(); + float *threshold_slopes_ptr = threshold_slopes.mutable_data(); + + excesstopography_fsm2d(excess_ptr, dem_ptr, threshold_slopes_ptr, cellsize, nrows, ncols); +} + +// wrap_excesstopography_fmm2d: +// Parameters: +// excess: A NumPy array to store the excess topography values computed by the FMM2D algorithm. +// heap: A NumPy array representing the heap used in the FMM2D algorithm. +// back: A NumPy array representing the backtracking information used in the FMM2D algorithm. +// dem: A NumPy array representing the digital elevation model. +// threshold_slopes: A NumPy array representing the slope thresholds for excess topography calculation. +// cellsize: The size of each cell in the DEM. +// nrows: Number of rows in the input DEM. +// ncols: Number of columns in the input DEM. + +void wrap_excesstopography_fmm2d(py::array_t excess, py::array_t heap, py::array_t back, py::array_t dem, py::array_t threshold_slopes, float cellsize, ptrdiff_t nrows, ptrdiff_t ncols){ + float *excess_ptr = excess.mutable_data(); + ptrdiff_t *heap_ptr = heap.mutable_data(); + ptrdiff_t *back_ptr = back.mutable_data(); + float *dem_ptr = dem.mutable_data(); + float *threshold_slopes_ptr = threshold_slopes.mutable_data(); + + excesstopography_fmm2d(excess_ptr, heap_ptr, back_ptr, dem_ptr, threshold_slopes_ptr, cellsize, nrows, ncols); +} + // Make wrap_funcname() function available as grid_funcname() to be used by // by functions in the pytopotoolbox package PYBIND11_MODULE(_grid, m) { m.def("grid_fillsinks", &wrap_fillsinks); m.def("grid_identifyflats", &wrap_identifyflats); + m.def("grid_excesstopography_fsm2d", &wrap_excesstopography_fsm2d); + m.def("grid_excesstopography_fmm2d", &wrap_excesstopography_fmm2d); } diff --git a/src/topotoolbox/grid_object.py b/src/topotoolbox/grid_object.py index e8dac1b..30c3023 100644 --- a/src/topotoolbox/grid_object.py +++ b/src/topotoolbox/grid_object.py @@ -7,8 +7,12 @@ import matplotlib.pyplot as plt # pylint: disable=import-error -from ._grid import grid_fillsinks # type: ignore -from ._grid import grid_identifyflats # type: ignore +from ._grid import ( # type: ignore + grid_fillsinks, + grid_identifyflats, + grid_excesstopography_fsm2d, + grid_excesstopography_fmm2d +) __all__ = ['GridObject'] @@ -38,7 +42,7 @@ def __init__(self) -> None: self.transform = None self.crs = None - def fillsinks(self): + def fillsinks(self) -> 'GridObject': """Fill sinks in the digital elevation model (DEM). Returns @@ -66,10 +70,11 @@ def identifyflats( raw : bool, optional If True, returns the raw output grid as np.ndarray. Defaults to False. - output : list of str, - flat_neighbors = 0 optional + output : list of str, optional List of strings indicating desired output types. Possible values - are 'sills', 'flats'. Defaults to ['sills', 'flats']. + are 'sills', 'flats'. Order of inputs in list are irrelevant, + first entry in output will always be sills. + Defaults to ['sills', 'flats']. Returns ------- @@ -110,7 +115,85 @@ def identifyflats( return tuple(result) - def info(self): + def excesstopography( + self, threshold: "float | int | np.ndarray | GridObject" = 0.2, + method: str = 'fsm2d',) -> 'GridObject': + """ + Compute the two-dimensional excess topography using the specified method. + + Parameters + ---------- + threshold : float, int, GridObject, or np.ndarray, optional + Threshold value or array to determine slope limits, by default 0.2. + If a float or int, the same threshold is applied to the entire DEM. + If a GridObject or np.ndarray, it must match the shape of the DEM. + method : str, optional + Method to compute the excess topography, by default 'fsm2d'. + Options are: + - 'fsm2d': Uses the fast sweeping method. + - 'fmm2d': Uses the fast marching method. + + Returns + ------- + GridObject + A new GridObject with the computed excess topography. + + Raises + ------ + ValueError + If `method` is not one of ['fsm2d', 'fmm2d']. + If `threshold` is an np.ndarray and doesn't match the shape of the DEM. + TypeError + If `threshold` is not a float, int, GridObject, or np.ndarray. + """ + + if method not in ['fsm2d', 'fmm2d']: + err = (f"Invalid method '{method}'. Supported methods are" + + " 'fsm2d' and 'fmm2d'.") + raise ValueError(err) from None + + dem = self.z + + if isinstance(threshold, (float, int)): + threshold_slopes = np.full( + dem.shape, threshold, order='F', dtype=np.float32) + elif isinstance(threshold, GridObject): + threshold_slopes = threshold.z + elif isinstance(threshold, np.ndarray): + threshold_slopes = threshold + else: + err = "Threshold must be a float, int, GridObject, or np.ndarray." + raise TypeError(err) from None + + if not dem.shape == threshold_slopes.shape: + err = "Threshold array must have the same shape as the DEM." + raise ValueError(err) from None + if not threshold_slopes.flags['F_CONTIGUOUS']: + threshold_slopes = np.asfortranarray(threshold) + if not np.issubdtype(threshold_slopes.dtype, np.float32): + threshold_slopes = threshold_slopes.astype(np.float32) + + excess = np.zeros_like(dem) + cellsize = self.cellsize + nrows, ncols = self.shape + + if method == 'fsm2d': + grid_excesstopography_fsm2d( + excess, dem, threshold_slopes, cellsize, nrows, ncols) + + elif method == 'fmm2d': + heap = np.zeros_like(dem, dtype=np.int64) + back = np.zeros_like(dem, dtype=np.int64) + + grid_excesstopography_fmm2d( + excess, heap, back, dem, threshold_slopes, cellsize, nrows, ncols) + + result = copy.copy(self) + result.z = excess + + return result + + def info(self) -> None: """Prints all variables of a GridObject. """ print(f"name: {self.name}") @@ -122,7 +205,7 @@ def info(self): print(f"transform: {self.transform}") print(f"crs: {self.crs}") - def show(self, cmap='terrain'): + def show(self, cmap='terrain') -> None: """ Display the GridObject instance as an image using Matplotlib. diff --git a/src/topotoolbox/utils.py b/src/topotoolbox/utils.py index 1e27be7..d6168bc 100644 --- a/src/topotoolbox/utils.py +++ b/src/topotoolbox/utils.py @@ -141,7 +141,8 @@ def read_tif(path: str) -> GridObject: def gen_random(hillsize: int = 24, rows: int = 128, columns: int = 128, - cellsize: float = 10.0, seed: int = 3) -> 'GridObject': + cellsize: float = 10.0, seed: int = 3, + name: str = 'random grid') -> 'GridObject': """Generate a GridObject instance that is generated with OpenSimplex noise. Parameters @@ -156,6 +157,8 @@ def gen_random(hillsize: int = 24, rows: int = 128, columns: int = 128, Size of each cell in the grid. Defaults to 10.0. seed : int, optional Seed for the terrain generation. Defaults to 3 + name : str, optional + Name for the generated GridObject. Defaults to 'random grid' Raises ------ @@ -191,7 +194,7 @@ def gen_random(hillsize: int = 24, rows: int = 128, columns: int = 128, grid.columns = columns grid.shape = grid.z.shape grid.cellsize = cellsize - + grid.name = name return grid diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..57b35c6 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,3 @@ +# Test + +To run the test locally, use: `pytest` diff --git a/tests/test_grid_object.py b/tests/test_grid_object.py index 255763a..cd1b88d 100644 --- a/tests/test_grid_object.py +++ b/tests/test_grid_object.py @@ -20,6 +20,7 @@ def tall_dem(): def test_fillsinks(square_dem, wide_dem, tall_dem): + # TODO: add more tests for grid in [square_dem, wide_dem, tall_dem]: # since grid is a fixture, it has to be assigned/called first dem = grid @@ -58,6 +59,7 @@ def test_fillsinks(square_dem, wide_dem, tall_dem): def test_identifyflats(square_dem, wide_dem, tall_dem): + # TODO: add more tests for dem in [square_dem, wide_dem, tall_dem]: sills, flats = dem.identifyflats() @@ -83,3 +85,7 @@ def test_identifyflats(square_dem, wide_dem, tall_dem): if flats[i_neighbor, j_neighbor] < flats[i, j]: assert flats[i, j] == 1.0 + + def test_excesstopography(square_dem): + # TODO: add more tests + assert square_dem.excesstopography(threshold='0.1') == TypeError()