├── AUTHORS.txt ├── docs ├── contributing.rst ├── _static │ ├── ftypes.png │ ├── pyflwdir.png │ ├── pyflwdir_icon.png │ ├── theme-deltares.css │ └── deltares-blue.svg ├── changelog.rst ├── reference.rst ├── api │ ├── elevation.rst │ ├── gis_utils.rst │ ├── conversion.rst │ ├── region_utils.rst │ └── flwdirraster.rst ├── index.rst ├── clean.py ├── Makefile ├── installation.rst ├── make.bat ├── quickstart.rst └── conf.py ├── examples ├── rhine_d8.tif ├── rhine_elv0.tif ├── utils.py ├── elevation_indices.ipynb ├── tracing.ipynb ├── flwdir.ipynb ├── streams.ipynb ├── from_dem.ipynb ├── upscaling.ipynb └── basins.ipynb ├── .gitattributes ├── MANIFEST.in ├── .vscode ├── settings.json └── launch.json ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yaml │ ├── documentation_improvement.yaml │ └── bug_report.yaml ├── dependabot.yml ├── workflows │ ├── linting.yml │ ├── test_cov.yml │ ├── tests.yml │ ├── docs.yml │ └── publish.yml └── pull_request_template.md ├── environment.yml ├── .zenodo.json ├── .editorconfig ├── pyflwdir ├── __init__.py ├── core_conversion.py ├── rivers.py ├── core_ldd.py ├── core_nextxy.py ├── arithmetics.py ├── core_d8.py ├── regions.py ├── basins.py └── streams.py ├── tests ├── test_arithmetics.py ├── test_flwdir.py ├── data │ └── flwdir.asc ├── test_upscale.py ├── test_subgrid.py ├── test_core_xx.py ├── conftest.py ├── test_core.py ├── test_dem.py ├── test_gis_utils.py └── test_streams_basins.py ├── .pre-commit-config.yaml ├── LICENSE ├── CODE_OF_CONDUCT.txt ├── .gitignore ├── README.rst ├── pyproject.toml ├── make_env.py ├── CONTRIBUTING.rst └── CHANGELOG.rst /AUTHORS.txt: -------------------------------------------------------------------------------- 1 | Authors 2 | ------- 3 | 4 | * Dirk Eilander 5 | * Willem van Verseveld 6 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | .. include:: ../CONTRIBUTING.rst 5 | -------------------------------------------------------------------------------- /examples/rhine_d8.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Deltares/pyflwdir/main/examples/rhine_d8.tif -------------------------------------------------------------------------------- /docs/_static/ftypes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Deltares/pyflwdir/main/docs/_static/ftypes.png -------------------------------------------------------------------------------- /docs/_static/pyflwdir.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Deltares/pyflwdir/main/docs/_static/pyflwdir.png -------------------------------------------------------------------------------- /examples/rhine_elv0.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Deltares/pyflwdir/main/examples/rhine_elv0.tif -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # SCM syntax highlighting 2 | pixi.lock linguist-language=YAML linguist-generated=true 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | recursive-exclude * *.py[co] 4 | graft pyflwdir 5 | -------------------------------------------------------------------------------- /docs/_static/pyflwdir_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Deltares/pyflwdir/main/docs/_static/pyflwdir_icon.png -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | 3 | .. toctree:: 4 | :maxdepth: 2 5 | :hidden: 6 | 7 | Contributing to PyFlwDir 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": [ 3 | "tests" 4 | ], 5 | "python.testing.unittestEnabled": false, 6 | "python.testing.pytestEnabled": true 7 | } 8 | -------------------------------------------------------------------------------- /docs/reference.rst: -------------------------------------------------------------------------------- 1 | Reference 2 | ========= 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | api/flwdirraster 8 | api/elevation 9 | api/gis_utils 10 | api/region_utils 11 | api/conversion 12 | -------------------------------------------------------------------------------- /docs/api/elevation.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: pyflwdir 2 | 3 | Elevation raster methods 4 | ------------------------ 5 | 6 | .. autosummary:: 7 | :toctree: ../_generated 8 | 9 | dem.fill_depressions 10 | dem.slope 11 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | .. toctree:: 4 | :maxdepth: 2 5 | :hidden: 6 | 7 | Installation 8 | User Guide 9 | API reference 10 | Developments 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | blank_issues_enabled: false 4 | contact_links: 5 | - name: Ask a question 6 | url: https://github.com/Deltares/pyflwdir/discussions 7 | about: Ask questions and discuss with other community members 8 | -------------------------------------------------------------------------------- /docs/api/gis_utils.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: pyflwdir 2 | 3 | GIS raster utility methods 4 | -------------------------- 5 | 6 | .. autosummary:: 7 | :toctree: ../_generated 8 | 9 | gis_utils.spread2d 10 | gis_utils.get_edge 11 | gis_utils.reggrid_area 12 | -------------------------------------------------------------------------------- /docs/api/conversion.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: pyflwdir 2 | 3 | Conversion Methods 4 | ^^^^^^^^^^^^^^^^^^ 5 | 6 | The following methods allow for conversion between different flow direction conventions: 7 | 8 | .. autosummary:: 9 | :toctree: ../_generated 10 | 11 | d8_to_ldd 12 | ldd_to_d8 13 | -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: pyflwdir 2 | 3 | channels: 4 | - conda-forge 5 | 6 | dependencies: 7 | - affine 8 | - cartopy>=0.20 9 | - descartes 10 | - geopandas>0.8 11 | - jupyter 12 | - matplotlib 13 | - numba>=0.54,<1.0 14 | - numpy 15 | - pandoc 16 | - python~=3.12.0 17 | - rasterio 18 | - scipy 19 | -------------------------------------------------------------------------------- /docs/api/region_utils.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: pyflwdir 2 | 3 | Region utility methods 4 | ---------------------- 5 | 6 | .. autosummary:: 7 | :toctree: ../_generated 8 | 9 | regions.region_bounds 10 | regions.region_slices 11 | regions.region_sum 12 | regions.region_area 13 | regions.region_dissolve 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | # there is no conda support for dependabot so this is the closest analog 3 | # since the conda deps are also built from pyproject.toml it should work well enough 4 | updates: 5 | - package-ecosystem: "pip" 6 | directory: "/" # Location of package manifests 7 | schedule: 8 | interval: "weekly" 9 | -------------------------------------------------------------------------------- /.github/workflows/linting.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | 8 | jobs: 9 | pre-commit: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-python@v5 14 | with: 15 | python-version: 3.11 16 | - uses: pre-commit/action@v3.0.0 17 | -------------------------------------------------------------------------------- /.zenodo.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "pyFlwDir", 3 | "description": "Fast methods to work with hydro- and topography data in pure Python.", 4 | "upload_type": "software", 5 | "creators": [ 6 | { 7 | "affiliation": "Deltares", 8 | "name": "Eilander, Dirk", 9 | "orcid": "0000-0002-0951-8418" 10 | } 11 | ], 12 | "access_right": "open", 13 | "license": "MIT" 14 | } 15 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Issue addressed 2 | Fixes # 3 | 4 | ## Explanation 5 | Explain how you addressed the bug/feature request, what choices you made and why. 6 | 7 | ## Checklist 8 | - [ ] Updated tests or added new tests 9 | - [ ] Branch is up to date with `main` 10 | - [ ] Updated documentation if needed 11 | - [ ] Updated CHANGELOG.rst if needed 12 | 13 | ## Additional Notes (optional) 14 | Add any additional notes or information that may be helpful. 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | charset = utf-8 12 | 13 | # 4 space indentation 14 | [*.{py,java,r,R}] 15 | indent_style = space 16 | indent_size = 4 17 | 18 | # 2 space indentation 19 | [*.{js,json,y{a,}ml,html,cwl}] 20 | indent_style = space 21 | indent_size = 2 22 | 23 | [*.{md,Rmd,rst}] 24 | trim_trailing_whitespace = false 25 | indent_style = space 26 | indent_size = 2 27 | -------------------------------------------------------------------------------- /docs/clean.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from pathlib import Path 4 | 5 | 6 | def remove_dir_content(path: str) -> None: 7 | for root, dirs, files in os.walk(path): 8 | for f in files: 9 | os.unlink(os.path.join(root, f)) 10 | for d in dirs: 11 | shutil.rmtree(os.path.join(root, d)) 12 | if os.path.isdir(path): 13 | shutil.rmtree(path) 14 | 15 | 16 | if __name__ == "__main__": 17 | here = os.path.dirname(__file__) 18 | remove_dir_content(Path(here, "_build")) 19 | remove_dir_content(Path(here, "_generated")) 20 | remove_dir_content(Path(here, "_examples")) 21 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = pyflwdir 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | 8 | { 9 | "name": "Python Debugger: Current File with Arguments", 10 | "type": "debugpy", 11 | "request": "launch", 12 | "program": "${file}", 13 | "console": "integratedTerminal", 14 | "args": [ 15 | "${command:pickArgs}" 16 | ], 17 | "env": {"NUMBA_DISABLE_JIT": "1"}, 18 | 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /pyflwdir/__init__.py: -------------------------------------------------------------------------------- 1 | """Fast methods to work with hydro- and topography data in pure Python.""" 2 | 3 | # version number without 'v' at start 4 | __version__ = "0.5.10.dev" 5 | 6 | # submodules 7 | from . import gis_utils, regions 8 | 9 | # public functions 10 | from .core_conversion import d8_to_ldd, ldd_to_d8 11 | from .core_nextxy import read_nextxy 12 | from .dem import fill_depressions, slope 13 | from .flwdir import Flwdir, from_dataframe 14 | from .pyflwdir import FlwdirRaster, from_array, from_dem 15 | 16 | __all__ = [ 17 | "Flwdir", 18 | "FlwdirRaster", 19 | "from_array", 20 | "from_dataframe", 21 | "from_dem", 22 | "read_nextxy", 23 | "gis_utils", 24 | "regions", 25 | "slope", 26 | "fill_depressions", 27 | "d8_to_ldd", 28 | "ldd_to_d8", 29 | ] 30 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Install from conda 5 | ------------------ 6 | 7 | .. code-block:: console 8 | 9 | $ conda install pyflwdir -c conda-forge 10 | 11 | 12 | Install from pip 13 | ---------------- 14 | 15 | .. code-block:: console 16 | 17 | $ pip install pyflwdir 18 | 19 | 20 | Install full environment for quickstart and examples 21 | ----------------------------------------------------- 22 | 23 | In order to run the examples in the examples folder some additional packages to read 24 | and write raster and vector data, as well as to plot these data are required. 25 | We recommend using `rasterio `__ raster data and 26 | `geopandas `__ for vector data. 27 | A complete environment can be installed from the environment.yml file using: 28 | 29 | .. code-block:: console 30 | 31 | $ conda env create -f environment.yml 32 | -------------------------------------------------------------------------------- /tests/test_arithmetics.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Tests for the pyflwdir module.""" 3 | 4 | import numpy as np 5 | 6 | from pyflwdir import arithmetics 7 | 8 | 9 | def test_stats(): 10 | nodata = -9999.0 11 | data = np.random.random(10) 12 | weights = np.random.random(10) 13 | assert np.isclose( 14 | np.average(data, weights=weights), arithmetics._average(data, weights, nodata) 15 | ) 16 | assert np.isclose(np.mean(data), arithmetics._mean(data, nodata)) 17 | data[-1] = nodata 18 | assert np.isclose( 19 | np.average(data[:-1], weights=weights[:-1]), 20 | arithmetics._average(data, weights, nodata), 21 | ) 22 | assert np.isclose(np.average(data[:-1]), arithmetics._mean(data, nodata)) 23 | data = np.random.randint(0, 10, 10) 24 | assert np.isclose( 25 | np.average(data, weights=weights), arithmetics._average(data, weights, nodata) 26 | ) 27 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=pyflwdir 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/_static/theme-deltares.css: -------------------------------------------------------------------------------- 1 | /* Override the default color set in the original theme */ 2 | 3 | html[data-theme="light"] { 4 | /* NOTE: added after pydata v0.9 */ 5 | --pst-color-primary: #080c80 !important; 6 | 7 | /* hyperlinks */ 8 | --pst-color-link: rgb(13, 56, 224); 9 | 10 | /* panels */ 11 | --pst-color-preformatted-border: #080c80 !important; 12 | --pst-color-panel-background: #f0f0f075; 13 | 14 | /* navbar */ 15 | /* NOTE: does not work since v0.9 */ 16 | --pst-color-navbar-link: rgb(237, 237, 255); 17 | --pst-color-navbar-link-hover: #fff; 18 | --pst-color-navbar-link-active: #fff; 19 | 20 | 21 | /* sphinx design */ 22 | /* NOTE: does not work since v0.9 */ 23 | --sd-color-card-border-hover: #080c80; 24 | --sd-color-tabs-label-active: #080c80; 25 | --sd-color-tabs-label-hover: #080c80; 26 | --sd-color-tabs-underline-active: #080c80; 27 | } 28 | 29 | /* enlarge deltares & github icon size; only works with local/url svg files; not with fa icons */ 30 | img.icon-link-image { 31 | height: 2.5em !important; 32 | } 33 | -------------------------------------------------------------------------------- /pyflwdir/core_conversion.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Methods to convert between different flwdir types""" 3 | 4 | from numba import njit 5 | import numpy as np 6 | from . import core_d8, core_ldd 7 | 8 | __all__ = ["d8_to_ldd", "ldd_to_d8"] 9 | 10 | 11 | def d8_to_ldd(flwdir): 12 | """Return ldd based on d8 array.""" 13 | # create conversion dict 14 | remap = {k: v for (k, v) in zip(core_d8._ds.flatten(), core_ldd._ds.flatten())} 15 | # add addional land pit code to pcr pit 16 | remap.update({core_d8._pv[1]: core_ldd._pv, core_d8._mv: core_ldd._mv}) 17 | # remap values 18 | return np.vectorize(lambda x: remap.get(x, core_ldd._mv))(flwdir) 19 | 20 | 21 | def ldd_to_d8(flwdir): 22 | """Return d8 based on ldd array.""" 23 | # create conversion dict 24 | remap = {k: v for (k, v) in zip(core_ldd._ds.flatten(), core_d8._ds.flatten())} 25 | # add addional land pit code to pcr pit 26 | remap.update({core_ldd._pv: core_d8._pv[0], core_ldd._mv: core_d8._mv}) 27 | # remap values 28 | return np.vectorize(lambda x: remap.get(x, core_d8._mv))(flwdir) 29 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v4.5.0 5 | hooks: 6 | - id: trailing-whitespace 7 | - id: end-of-file-fixer 8 | - id: check-yaml 9 | - id: check-added-large-files 10 | - id: check-ast 11 | - id: debug-statements 12 | - id: mixed-line-ending 13 | # make sure notebooks are stripped of output before committing 14 | - repo: https://github.com/kynan/nbstripout 15 | rev: 0.6.1 16 | hooks: 17 | - id: nbstripout 18 | - repo: https://github.com/psf/black-pre-commit-mirror 19 | rev: 23.12.0 20 | hooks: 21 | - id: black-jupyter 22 | description: Black, with Jupyter Notebook support 23 | # - repo: https://github.com/astral-sh/ruff-pre-commit 24 | # rev: v0.1.2 25 | # hooks: 26 | # - id: ruff 27 | # args: [--fix, --exit-non-zero-on-fix] 28 | # - id: ruff-format 29 | # - repo: https://github.com/python-jsonschema/check-jsonschema 30 | # rev: 0.24.0 31 | # hooks: 32 | # - id: check-github-workflows 33 | # - id: check-github-actions 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Deltares 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | description: Suggest an idea/enhancement for pyFlwDir 4 | labels: [Enhancement, Needs refinement] 5 | 6 | body: 7 | - type: dropdown 8 | id: checks 9 | attributes: 10 | description: What kind of feature request is this? 11 | label: Kind of request 12 | options: 13 | - Adding new functionality 14 | - Changing existing functionality 15 | - Removing existing functionality 16 | - type: textarea 17 | id: description 18 | attributes: 19 | description: > 20 | Please provide a clear and concise description of the feature you're requesting 21 | label: Enhancement Description 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: use-case 26 | attributes: 27 | description: > 28 | Please describe a situation in which this feature would be useful to you, with code or cli examples if possible 29 | label: Use case 30 | - type: textarea 31 | id: context 32 | attributes: 33 | description: > 34 | Please add any other context about the enhancement here 35 | label: Additional Context 36 | -------------------------------------------------------------------------------- /.github/workflows/test_cov.yml: -------------------------------------------------------------------------------- 1 | name: Tests Coverage 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - tests/* 8 | - pyflwdir/* 9 | - pyproject.toml 10 | pull_request: 11 | branches: [main] 12 | paths: 13 | - tests/* 14 | - pyflwdir/* 15 | - pyproject.toml 16 | 17 | 18 | jobs: 19 | build: 20 | defaults: 21 | run: 22 | shell: bash -l {0} 23 | strategy: 24 | fail-fast: false 25 | runs-on: ubuntu-latest 26 | timeout-minutes: 30 27 | concurrency: 28 | group: ${{ github.workflow }}-${{ matrix.python-version }}-${{ github.ref }} 29 | cancel-in-progress: true 30 | steps: 31 | 32 | - uses: actions/checkout@v4 33 | 34 | - uses: prefix-dev/setup-pixi@v0.8.2 35 | with: 36 | pixi-version: v0.41.1 37 | environments: test-py312 38 | locked: false 39 | cache: true 40 | cache-write: ${{ github.event_name == 'push' && github.ref_name == 'main' }} 41 | 42 | # run test 43 | - name: Test 44 | run: pixi run -e test-py312 test-cov-xml 45 | 46 | # upload coverage 47 | - uses: codecov/codecov-action@v3 48 | -------------------------------------------------------------------------------- /tests/test_flwdir.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from pyflwdir.flwdir import Flwdir, get_loc_idx 5 | 6 | 7 | @pytest.fixture 8 | def data(): 9 | idx = np.array( 10 | [13924, 15144, 10043, 432, 7684, 6379, 6401, 3650, 2725, 95, 147, 7777] 11 | ) 12 | # first idx_ds "15442" is not found in idx and interpreted as pit 13 | idx_ds = np.array( 14 | [15442, 13924, 13924, 10043, 10043, 7684, 7684, 6401, 6401, 2725, 2725, 7777] 15 | ) 16 | # lin indices 17 | idxs_ds = np.array([0, 0, 0, 2, 2, 4, 4, 6, 6, 8, 8, 11]) 18 | # rank 19 | rank = np.array([0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 0]) 20 | return idx, idx_ds, idxs_ds, rank 21 | 22 | 23 | def test_from_dataframe(data): 24 | # unpack test data 25 | idx, idx_ds, idxs_ds, rank = data 26 | 27 | # test Flwdir (as in from_dataframe) 28 | idxs_ds0 = get_loc_idx(idx, idx_ds) 29 | assert np.all(idxs_ds0 == idxs_ds) 30 | flwdir = Flwdir(idxs_ds=idxs_ds) 31 | assert np.all(flwdir.rank == rank) 32 | assert flwdir._mv == -1 33 | 34 | # test with uint64 35 | idxs_ds0 = get_loc_idx(idx.astype(np.uint64), idx_ds.astype(np.uint64)) 36 | flwdir = Flwdir(idxs_ds=idxs_ds0) 37 | assert np.all(flwdir.rank == rank) 38 | assert flwdir._mv == 18446744073709551615 39 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - tests/* 8 | - pyflwdir/* 9 | - pyproject.toml 10 | - pixi.lock 11 | pull_request: 12 | branches: [main] 13 | paths: 14 | - tests/* 15 | - pyflwdir/* 16 | - pyproject.toml 17 | - pixi.lock 18 | 19 | jobs: 20 | build: 21 | defaults: 22 | run: 23 | shell: bash -l {0} 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | os: [ubuntu-latest] 28 | python-version: ['39', '310','311', '312', '313'] 29 | runs-on: ubuntu-latest 30 | timeout-minutes: 30 31 | concurrency: 32 | group: ${{ github.workflow }}-${{ matrix.python-version }}-${{ github.ref }} 33 | cancel-in-progress: true 34 | steps: 35 | 36 | - uses: actions/checkout@v4 37 | 38 | - uses: prefix-dev/setup-pixi@v0.8.2 39 | with: 40 | pixi-version: v0.41.1 41 | environments: test-py${{ matrix.python-version }} 42 | locked: false 43 | cache: true 44 | cache-write: ${{ github.event_name == 'push' && github.ref_name == 'main' }} 45 | 46 | # run test 47 | - name: Test 48 | run: pixi run -e test-py${{ matrix.python-version }} test 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation_improvement.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation Improvement 3 | description: Report wrong or missing documentation 4 | labels: [Documentation, Needs refinement, Examples] 5 | 6 | body: 7 | - type: checkboxes 8 | attributes: 9 | label: pyFlwDir version checks 10 | options: 11 | - label: > 12 | I have checked that the issue still exists on the latest versions of the docs on `main` [here](https://github.com/Deltares/pyflwdir) 13 | required: true 14 | - type: dropdown 15 | id: kind 16 | attributes: 17 | description: What kind of documentation issue is this? 18 | label: Kind of issue 19 | options: 20 | - Docs are wrong 21 | - Docs are unclear 22 | - Docs are missing 23 | validations: 24 | required: true 25 | - type: textarea 26 | id: location 27 | attributes: 28 | description: > 29 | If the docs are wrong or unclear please provide the URL of the documentation in question 30 | label: Location of the documentation 31 | - type: textarea 32 | id: problem 33 | attributes: 34 | description: > 35 | Please provide a description of the documentation problem 36 | label: Documentation problem 37 | validations: 38 | required: true 39 | - type: textarea 40 | id: suggested-fix 41 | attributes: 42 | description: > 43 | Please explain your suggested fix and why it's better than the existing documentation 44 | label: Suggested fix for documentation 45 | -------------------------------------------------------------------------------- /docs/_static/deltares-blue.svg: -------------------------------------------------------------------------------- 1 | Artboard 1 2 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quickstart 2 | ========== 3 | 4 | The most common workflow to derive flow direction from digital elevation data and 5 | subsequent delineate basins or vectorize a stream network can be done in just a few 6 | lines of code. 7 | 8 | To read elevation data from a geotiff raster file *elevation.tif* do: 9 | 10 | .. code-block:: python 11 | 12 | import rasterio 13 | with rasterio.open("elevation.tif", "r") as src: 14 | elevtn = src.read(1) 15 | nodata = src.nodata 16 | transform = src.transform 17 | crs = src.crs 18 | 19 | 20 | Derive a FlwdirRaster object from this data: 21 | 22 | .. code-block:: python 23 | 24 | import pyflwdir 25 | flw = pyflwdir.from_dem( 26 | data=elevtn, 27 | nodata=src.nodata, 28 | transform=transform, 29 | latlon=crs.is_geographic, 30 | ) 31 | 32 | Delineate basins and retrieve a raster with unique IDs per basin: 33 | Tip: This raster can directly be written to geotiff and/or vectorized to save as 34 | vector file with `rasterio `__ 35 | 36 | .. code-block:: python 37 | 38 | basins = flw.basins() 39 | 40 | Vectorize the stream network and save to a geojson file: 41 | 42 | .. code-block:: python 43 | 44 | import geopandas as gpd 45 | feat = flw.streams() 46 | gdf = gpd.GeoDataFrame.from_features(feats, crs=crs) 47 | gdf.to_file('streams.geojson', driver='GeoJSON') 48 | 49 | 50 | .. toctree:: 51 | :maxdepth: 2 52 | :hidden: 53 | 54 | Flow direction data <_examples/flwdir> 55 | Flow directions from elevation data <_examples/from_dem> 56 | Deliniation of (sub)basins <_examples/basins> 57 | Stream order <_examples/streams> 58 | Tracing flow directions <_examples/tracing> 59 | Elevation indices <_examples/elevation_indices> 60 | Flow direction upscaling <_examples/upscaling> 61 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | description: Report incorrect behavior in the pyFlwDir library 4 | labels: [Bug, Needs refinement] 5 | 6 | body: 7 | - type: checkboxes 8 | id: checks 9 | attributes: 10 | label: pyFlwDir version checks 11 | options: 12 | - label: I have checked that this issue has not already been reported. 13 | required: true 14 | - label: I have checked that this bug exists on the latest version of pyFlwDir. 15 | required: true 16 | - type: textarea 17 | id: example 18 | attributes: 19 | description: > 20 | Please provide a minimal, copy-pastable example or a link to a public repository that reproduces the behavior. If providing a copy pastable example, 21 | you may assume your in a clean up to date version of pyFlwDir with a python enviroment active. In the case of a repository, ensure the repository 22 | has a README.md which includes intructions to reproduce the behaviour. 23 | label: Reproducible Example 24 | validations: 25 | required: true 26 | - type: textarea 27 | id: current-behaviour 28 | attributes: 29 | description: > 30 | Please provide a description of the incorrect behaviour shown in the reproducible example 31 | label: Current behaviour 32 | validations: 33 | required: true 34 | - type: textarea 35 | id: expected-behaviour 36 | attributes: 37 | description: > 38 | Please provide a description of what you think the behaviour should be 39 | label: Desired behaviour 40 | validations: 41 | required: true 42 | - type: textarea 43 | id: additional 44 | attributes: 45 | description: > 46 | Please add any other context about the bug here 47 | label: Additional context 48 | validations: 49 | required: false 50 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Build Documentation 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [main] 7 | paths: 8 | - tests/* 9 | - pyflwdir/* 10 | - docs/* 11 | - examples/* 12 | - pyproject.toml 13 | pull_request: 14 | branches: [main] 15 | paths: 16 | - tests/* 17 | - pyflwdir/* 18 | - docs/* 19 | - examples/* 20 | - pyproject.toml 21 | 22 | jobs: 23 | # Build docs on Linux 24 | test-docs: 25 | env: 26 | DOC_VERSION: dev 27 | PYDEVD_DISABLE_FILE_VALIDATION: 1 28 | timeout-minutes: 30 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: checkout code 32 | uses: actions/checkout@v4 33 | 34 | - uses: prefix-dev/setup-pixi@v0.8.2 35 | with: 36 | pixi-version: v0.41.1 37 | environments: default 38 | locked: false 39 | cache: true 40 | cache-write: ${{ github.event_name == 'push' && github.ref_name == 'main' }} 41 | 42 | # if we're not publishing we don't have to write them, so we might as well 43 | # save ourself a bunch of IO time 44 | - name: Build dummy docs 45 | if: ${{ github.event_name == 'pull_request' }} 46 | run: | 47 | pixi run docs-dummy 48 | 49 | - name: Build html docs 50 | if: ${{ github.event_name != 'pull_request' }} 51 | run: | 52 | pixi run docs-html 53 | echo "DOC_VERSION=$(pixi run version)" >> $GITHUB_ENV 54 | 55 | - name: Upload to GitHub Pages 56 | if: ${{ github.event_name != 'pull_request'}} 57 | uses: peaceiris/actions-gh-pages@v3.8.0 58 | with: 59 | github_token: ${{ secrets.GITHUB_TOKEN }} 60 | publish_dir: ./docs/_build/html 61 | exclude_assets: '.buildinfo,_sources/*,_examples/*.ipynb' 62 | destination_dir: ./${{ env.DOC_VERSION }} 63 | keep_files: false 64 | full_commit_message: Deploy ${{ env.DOC_VERSION }} to GitHub Pages 65 | -------------------------------------------------------------------------------- /tests/data/flwdir.asc: -------------------------------------------------------------------------------- 1 | 247 247 247 247 247 247 247 247 247 247 247 247 247 247 247 247 247 247 247 1 2 1 1 128 64 2 | 247 247 247 247 247 247 247 247 247 247 247 247 247 247 247 247 1 2 4 1 1 1 128 1 1 3 | 247 247 247 247 247 247 247 247 247 247 247 247 247 247 247 1 1 2 4 16 1 64 16 128 16 4 | 247 247 247 247 247 247 247 247 247 247 247 247 247 247 2 2 1 4 16 1 64 1 32 64 16 5 | 247 247 247 247 247 247 247 247 247 247 247 247 1 1 1 2 128 4 128 4 4 4 1 64 16 6 | 247 247 247 247 247 4 4 8 16 2 4 2 1 1 128 1 1 128 1 128 128 128 64 64 32 7 | 247 247 247 4 1 1 1 4 16 4 1 1 1 1 128 64 32 8 8 64 1 64 128 16 16 8 | 247 247 1 128 16 1 64 4 64 128 64 1 1 128 2 1 32 64 16 64 128 1 64 64 64 9 | 247 247 64 64 64 32 128 4 16 4 4 1 1 2 1 64 1 32 4 32 128 16 64 1 1 10 | 247 1 64 32 16 2 1 1 1 1 1 2 4 1 64 64 1 2 4 128 64 64 1 1 64 11 | 128 64 2 2 4 4 1 64 16 64 16 1 1 128 8 16 16 1 1 64 64 128 64 64 64 12 | 1 1 1 1 1 128 64 64 32 2 4 4 128 64 16 32 16 64 16 1 1 128 64 128 64 13 | 247 247 64 64 64 32 16 16 16 64 1 1 1 64 16 32 32 16 4 16 16 128 2 1 1 14 | 247 247 128 128 128 128 16 16 4 8 64 32 128 64 32 4 32 64 16 16 16 128 1 1 1 15 | 2 4 4 1 1 1 64 32 16 16 16 64 1 1 1 32 4 32 1 2 16 1 1 1 64 16 | 64 2 1 2 4 2 4 1 1 1 1 1 64 2 1 64 4 8 8 4 1 1 2 1 1 17 | 2 4 8 4 2 2 4 8 128 1 128 128 16 1 128 1 4 8 4 16 128 2 16 1 128 18 | 64 1 1 1 1 1 1 1 2 16 2 1 128 64 16 16 8 16 32 4 4 8 16 1 128 19 | 1 1 1 1 2 4 4 2 1 1 1 128 1 1 64 4 8 8 16 1 8 64 32 16 64 20 | 1 2 2 64 1 128 1 1 64 64 16 4 1 1 1 32 16 16 64 16 4 16 4 64 32 21 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.txt: -------------------------------------------------------------------------------- 1 | 2 | Contributor Code of Conduct 3 | --------------------------- 4 | 5 | As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 6 | 7 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. 8 | 9 | Examples of unacceptable behavior by participants include: 10 | 11 | * The use of sexualized language or imagery 12 | * Personal attacks 13 | * Trolling or insulting/derogatory comments 14 | * Public or private harassment 15 | * Publishing other's private information, such as physical or electronic addresses, without explicit permission 16 | * Other unethical or unprofessional conduct. 17 | 18 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team. 19 | 20 | This code of conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. 21 | 22 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 23 | 24 | This Code of Conduct is adapted from the `Contributor Covenant`_, version 1.2.0, available at http://contributor-covenant.org/version/1/2/0/ 25 | 26 | .. _Contributor Covenant: http://contributor-covenant.org 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # file based on github/gitignore 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | data/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | figs/ 20 | scripts/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | .pytest_cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | .hypothesis/ 54 | tests/data/*.tif 55 | tests/data/*.gpkg 56 | tests/data/*.xml 57 | tests/data/*.qml 58 | tests/data/*.qgz 59 | tests/*.ipynb 60 | 61 | # Translations 62 | *.mo 63 | *.pot 64 | 65 | # Django stuff: 66 | *.log 67 | .static_storage/ 68 | .media/ 69 | local_settings.py 70 | 71 | # Flask stuff: 72 | instance/ 73 | .webassets-cache 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # pyenv 82 | .python-version 83 | 84 | # celery beat schedule file 85 | celerybeat-schedule 86 | 87 | # SageMath parsed files 88 | *.sage.py 89 | 90 | # Environments 91 | .env 92 | .venv 93 | env/ 94 | venv/ 95 | ENV/ 96 | env.bak/ 97 | venv.bak/ 98 | 99 | # Spyder project settings 100 | .spyderproject 101 | .spyproject 102 | 103 | # PyCharm 104 | .idea 105 | 106 | # Rope project settings 107 | .ropeproject 108 | 109 | # notebooks 110 | examples/.ipynb_checkpoints 111 | examples/tmp* 112 | examples/*.png 113 | examples/*.tif 114 | examples/*.gpkg 115 | examples/*.xml 116 | 117 | # Sphinx documentation 118 | docs/_build/ 119 | docs/_generated/ 120 | docs/_examples/ 121 | 122 | # mypy 123 | .mypy_cache/ 124 | 125 | # temp 126 | _pyflwdir 127 | scripts 128 | 129 | pyflwdir*.yml 130 | 131 | # pixi environments 132 | .pixi 133 | *.egg-info 134 | 135 | 136 | # sandbox 137 | sandbox/ 138 | -------------------------------------------------------------------------------- /tests/test_upscale.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Tests for the pyflwdir.upscael module.""" 3 | 4 | import pytest 5 | import numpy as np 6 | 7 | # local 8 | from pyflwdir import upscale, core, streams, basins 9 | 10 | # # large test data 11 | # from pyflwdir import core_d8 12 | # flwdir = np.fromfile(r"./data/d8.bin", dtype=np.uint8).reshape((678, 776)) 13 | # tests = [("dmm", 1073), ("eam", 406), ("com", 138), ("com2", 54)] 14 | # idxs_ds, idxs_pit, _ = core_d8.from_array(flwdir) 15 | # rank, n = core.rank(idxs_ds) 16 | # seq = np.argsort(rank)[-n:] 17 | # cellsize = 10 18 | 19 | # cellsize = 20 20 | tests = [ 21 | (20, "dmm", 33), 22 | (20, "eam", 4), 23 | (20, "eam_plus", 2), 24 | (40, "ihu", 0), 25 | (20, "ihu", 1), 26 | (10, "ihu", 4), 27 | (5, "ihu", 7), 28 | ] 29 | 30 | 31 | # configure tests with different upscale methods 32 | @pytest.mark.parametrize("cellsize, name, nflwerr", tests) 33 | def test_upscale(cellsize, name, nflwerr, flwdir_large, flwdir_large_idxs): 34 | mv = np.uint32(core._mv) 35 | flwdir = flwdir_large 36 | idxs_ds, idxs_pit = flwdir_large_idxs 37 | # caculate upstream area and basin 38 | rank, n = core.rank(idxs_ds, mv=np.uint32(mv)) 39 | seq = np.argsort(rank)[-n:] 40 | upa = streams.upstream_area(idxs_ds, seq, flwdir.shape[1], dtype=np.int32) 41 | ids = np.arange(1, idxs_pit.size + 1, dtype=int) 42 | bas = basins.basins(idxs_ds, idxs_pit, seq, ids) 43 | # upscale 44 | fupscale = getattr(upscale, name) 45 | idxs_ds1, idxs_out, shape1 = fupscale(idxs_ds, upa, flwdir.shape, cellsize, mv=mv) 46 | assert np.multiply(*shape1) == idxs_ds1.size 47 | assert idxs_ds.dtype == idxs_ds1.dtype 48 | assert core.loop_indices(idxs_ds1, mv=mv).size == 0 49 | pit_idxs = core.pit_indices(idxs_ds1) 50 | assert np.unique(idxs_out[pit_idxs]).size == pit_idxs.size 51 | pit_bas = bas[idxs_out[pit_idxs]] 52 | assert np.unique(pit_bas).size == pit_bas.size 53 | # check number of disconnected cells for each method 54 | flwerr_idxs = upscale.upscale_error(idxs_out, idxs_ds1, idxs_ds, mv=mv)[1] 55 | assert flwerr_idxs.size == nflwerr 56 | 57 | 58 | # TODO: extend tests 59 | def test_map(flwdir_large, flwdir_large_idxs): 60 | mv = np.uint32(core._mv) 61 | upscale.map_celledge(flwdir_large_idxs[0], flwdir_large.shape, 20, mv=mv) 62 | upscale.map_effare(flwdir_large_idxs[0], flwdir_large.shape, 20, mv=mv) 63 | -------------------------------------------------------------------------------- /tests/test_subgrid.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Tests for the unitcatchments.py submodule""" 3 | 4 | import numpy as np 5 | import pytest 6 | 7 | from pyflwdir import core, streams, subgrid 8 | 9 | 10 | @pytest.mark.parametrize("method, cellsize", [("eam_plus", 5), ("", 1), ("dmm", 4)]) 11 | def test_subgridch(method, cellsize, test_data0, flwdir0): 12 | idxs_ds, _, seq, rank, mv = [p.copy() for p in test_data0] 13 | ncol, shape = flwdir0.shape[1], flwdir0.shape 14 | upa = streams.upstream_area(idxs_ds, seq, ncol, dtype=np.int32) 15 | idxs_us_main = core.main_upstream(idxs_ds, upa, mv=mv) 16 | elv = rank 17 | 18 | if cellsize == 1: 19 | idxs_out = np.arange(idxs_ds.size) 20 | idxs_out[idxs_ds == mv] = mv 21 | else: 22 | idxs_out, _ = subgrid.outlets( 23 | idxs_ds, upa, cellsize, shape, method=method, mv=mv 24 | ) 25 | umap, uare = subgrid.ucat_area( 26 | idxs_out, idxs_ds, seq, area=np.ones(idxs_ds.size, dtype=np.int32), mv=mv 27 | ) 28 | # upstream 29 | rivlen = subgrid.segment_length(idxs_out, idxs_us_main, distnc=rank.ravel(), mv=mv) 30 | rivslp = subgrid.fixed_length_slope( 31 | idxs_out, idxs_ds, idxs_us_main, elv, rank.ravel(), mv=mv 32 | ) 33 | rivwth = subgrid.segment_average( 34 | idxs_out, idxs_us_main, np.ones(elv.size), np.ones(elv.size), mv=mv 35 | ) 36 | if cellsize == 1: 37 | assert np.all(uare[umap != 0] == cellsize) 38 | assert np.all(rivlen[upa == 1] == 0) # headwater cells 39 | assert np.all(rivlen[upa > 1] >= 1) # downstream cells 40 | assert np.all(np.isclose(rivslp[rivlen > 0], 1 / rivlen[rivlen > 0])) 41 | assert np.all(rivwth[idxs_out != mv] >= 0) # downstream cells 42 | assert np.all(rivslp[idxs_out != mv] >= 0) 43 | assert np.all(rivlen[idxs_out != mv] >= 0) 44 | assert umap.max() - 1 == np.where(idxs_out != mv)[0][-1] 45 | assert np.all(uare[idxs_out != mv] >= 1) 46 | # downstream 47 | rivlen1 = subgrid.segment_length(idxs_out, idxs_ds, distnc=rank.ravel(), mv=mv) 48 | pits = idxs_ds[idxs_out[idxs_out != mv]] == idxs_out[idxs_out != mv] 49 | assert np.all(rivlen1[idxs_out != mv][pits] == 0) 50 | assert np.all(rivlen1[idxs_out != mv] >= 0) 51 | # mask 52 | rivlen2 = subgrid.segment_length(idxs_out, idxs_us_main, distnc=rank.ravel(), mv=mv) 53 | rivlen3 = subgrid.segment_length( 54 | idxs_out, idxs_us_main, distnc=rank.ravel(), mask=upa >= 5, mv=mv 55 | ) 56 | assert np.all(rivlen2 >= rivlen3) 57 | -------------------------------------------------------------------------------- /examples/utils.py: -------------------------------------------------------------------------------- 1 | import matplotlib 2 | import matplotlib.pyplot as plt 3 | from matplotlib import cm, colors 4 | import cartopy.crs as ccrs 5 | import descartes 6 | import numpy as np 7 | import os 8 | import rasterio 9 | from rasterio import features 10 | import geopandas as gpd 11 | 12 | np.random.seed(seed=101) 13 | matplotlib.rcParams["savefig.bbox"] = "tight" 14 | matplotlib.rcParams["savefig.dpi"] = 256 15 | plt.style.use("seaborn-v0_8-whitegrid") 16 | 17 | # read example elevation data and derive background hillslope 18 | fn = os.path.join(os.path.dirname(__file__), "rhine_elv0.tif") 19 | with rasterio.open(fn, "r") as src: 20 | elevtn = src.read(1) 21 | extent = np.array(src.bounds)[[0, 2, 1, 3]] 22 | crs = src.crs 23 | ls = matplotlib.colors.LightSource(azdeg=115, altdeg=45) 24 | hs = ls.hillshade(np.ma.masked_equal(elevtn, -9999), vert_exag=1e3) 25 | 26 | 27 | # convenience method for plotting 28 | def quickplot( 29 | gdfs=[], raster=None, hillshade=True, extent=extent, hs=hs, title="", filename="" 30 | ): 31 | fig = plt.figure(figsize=(8, 15)) 32 | ax = fig.add_subplot(projection=ccrs.PlateCarree()) 33 | # plot hillshade background 34 | if hillshade: 35 | ax.imshow( 36 | hs, 37 | origin="upper", 38 | extent=extent, 39 | cmap="Greys", 40 | alpha=0.3, 41 | zorder=0, 42 | ) 43 | # plot geopandas GeoDataFrame 44 | for gdf, kwargs in gdfs: 45 | gdf.plot(ax=ax, **kwargs) 46 | if raster is not None: 47 | data, nodata, kwargs = raster 48 | ax.imshow( 49 | np.ma.masked_equal(data, nodata), 50 | origin="upper", 51 | extent=extent, 52 | **kwargs, 53 | ) 54 | ax.set_aspect("equal") 55 | ax.set_title(title, fontsize="large") 56 | ax.text( 57 | 0.01, 0.01, "created with pyflwdir", transform=ax.transAxes, fontsize="large" 58 | ) 59 | if filename: 60 | plt.savefig(f"{filename}.png") 61 | return ax 62 | 63 | 64 | # convenience method for vectorizing a raster 65 | def vectorize(data, nodata, transform, crs=crs, name="value"): 66 | feats_gen = features.shapes( 67 | data, 68 | mask=data != nodata, 69 | transform=transform, 70 | connectivity=8, 71 | ) 72 | feats = [ 73 | {"geometry": geom, "properties": {name: val}} for geom, val in list(feats_gen) 74 | ] 75 | 76 | # parse to geopandas for plotting / writing to file 77 | gdf = gpd.GeoDataFrame.from_features(feats, crs=crs) 78 | gdf[name] = gdf[name].astype(data.dtype) 79 | return gdf 80 | -------------------------------------------------------------------------------- /tests/test_core_xx.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Tests for the pyflwdir.core_xx.py and core_conversion submodules.""" 3 | 4 | import pytest 5 | import numpy as np 6 | 7 | from pyflwdir import core_d8, core_nextxy, core_ldd 8 | from pyflwdir.core_conversion import ldd_to_d8, d8_to_ldd 9 | 10 | 11 | @pytest.mark.parametrize("fd", [core_nextxy, core_d8, core_ldd]) 12 | def test_core(fd): 13 | """test core_x.py submodules based on _us definitions""" 14 | # test isvalid 15 | assert fd.isvalid(fd._us) 16 | assert not fd.isvalid(fd._us * 3) 17 | # test ispit 18 | assert np.all(fd.ispit(fd._pv)) 19 | # test isnodata 20 | assert np.all(fd.isnodata(fd._mv)) 21 | # test from_array (and drdc) 22 | idxs_ds, idx_pits, n = fd.from_array(fd._us) 23 | assert n == 9 24 | assert np.all(idxs_ds == 4) 25 | assert np.all(idx_pits == 4) and idx_pits.size == 1 26 | # test to_array 27 | assert np.all(fd.to_array(idxs_ds, (3, 3)) == fd._us) 28 | 29 | 30 | @pytest.mark.parametrize("fd", [core_d8, core_ldd]) 31 | def test_usds(fd): 32 | """assert D8 local upstream/ downstream operations""" 33 | _us_flat = fd._us.flatten() 34 | _ds_flat = fd._ds.flatten() 35 | shape = fd._us.shape 36 | # test upstream 37 | for idx0 in range(9): 38 | flwdir_flat = np.zeros(9, dtype=np.uint8) 39 | flwdir_flat[idx0] = np.uint8(1) 40 | flwdir_flat *= _us_flat 41 | if idx0 != 4: 42 | assert np.all(fd._upstream_idx(4, flwdir_flat, shape) == idx0) 43 | else: 44 | assert fd._upstream_idx(4, flwdir_flat, shape).size == 0 45 | # test downstream 46 | for idx0 in range(9): 47 | if idx0 != 4: 48 | assert fd._downstream_idx(idx0, _ds_flat, shape) == -1 49 | else: 50 | assert fd._downstream_idx(idx0, _ds_flat, shape) == 4 51 | assert fd._downstream_idx(idx0, _us_flat, shape) == 4 52 | 53 | 54 | @pytest.mark.parametrize("fd", [core_nextxy, core_d8, core_ldd]) 55 | def test_identical(fd, test_data): 56 | """test if all core_xx.py return identical results""" 57 | for parsed, flwdir in test_data: 58 | idxs_ds0, idxs_pit0, _, _, mv = parsed 59 | flwdir0 = fd.to_array(idxs_ds0, flwdir.shape, mv=mv) 60 | idxs_ds, idxs_pit, _ = fd.from_array(flwdir0, dtype=idxs_ds0.dtype) 61 | assert np.all(idxs_ds0 == idxs_ds) 62 | assert np.all(idxs_pit0 == idxs_pit) 63 | 64 | 65 | def test_ftype_conversion(): 66 | """test conversion between d8 and ldd formats""" 67 | flwdir = np.random.choice(core_ldd._all, (10, 10)) 68 | assert np.all(d8_to_ldd(ldd_to_d8(flwdir)) == flwdir) 69 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and Upload pyflwdir to PyPI 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | push: 8 | tags: 9 | - 'v*' 10 | workflow_dispatch: 11 | 12 | 13 | jobs: 14 | build-artifacts: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | with: 19 | fetch-depth: 0 20 | 21 | - uses: actions/setup-python@v4 22 | name: Install Python 23 | with: 24 | python-version: 3.9 25 | 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | python -m pip install flit wheel twine 30 | 31 | - name: Build tarball and wheels 32 | run: | 33 | git clean -xdf 34 | git restore -SW . 35 | flit build 36 | 37 | - name: Check built artifacts 38 | run: | 39 | python -m twine check dist/* 40 | pwd 41 | 42 | - uses: actions/upload-artifact@v4 43 | with: 44 | name: releases 45 | path: dist 46 | 47 | test-built-dist: 48 | needs: build-artifacts 49 | runs-on: ubuntu-latest 50 | defaults: 51 | run: 52 | shell: bash -l {0} 53 | steps: 54 | - uses: actions/checkout@v2 55 | - uses: actions/download-artifact@v4 56 | with: 57 | name: releases 58 | path: dist 59 | - name: List contents of built dist 60 | run: | 61 | ls -ltrh 62 | ls -ltrh dist 63 | 64 | - uses: actions/setup-python@v4 65 | name: Install Python 66 | with: 67 | python-version: 3.9 68 | 69 | - name: Verify the built dist/wheel is valid 70 | run: | 71 | python -m pip install --upgrade pip 72 | python -m pip install dist/pyflwdir*.whl 73 | python -c 'from pyflwdir import __version__ as v; print(v)' 74 | 75 | upload-to-test-pypi: 76 | needs: test-built-dist 77 | if: github.event_name == 'push' 78 | runs-on: ubuntu-latest 79 | steps: 80 | - uses: actions/download-artifact@v4 81 | with: 82 | name: releases 83 | path: dist 84 | - name: Publish package to TestPyPI 85 | uses: pypa/gh-action-pypi-publish@v1.5.1 86 | with: 87 | user: __token__ 88 | password: ${{ secrets.PYPI_TEST_TOKEN }} 89 | repository_url: https://test.pypi.org/legacy/ 90 | verbose: true 91 | skip_existing: true 92 | 93 | upload-to-pypi: 94 | needs: test-built-dist 95 | if: github.event_name == 'release' 96 | runs-on: ubuntu-latest 97 | steps: 98 | - uses: actions/download-artifact@v4 99 | with: 100 | name: releases 101 | path: dist 102 | - name: Publish package to PyPI 103 | uses: pypa/gh-action-pypi-publish@v1.5.1 104 | with: 105 | user: __token__ 106 | password: ${{ secrets.PYPI_TOKEN }} 107 | verbose: true 108 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ############################################################################# 2 | PyFlwDir: Fast methods to work with hydro- and topography data in pure Python 3 | ############################################################################# 4 | 5 | .. image:: https://codecov.io/gh/Deltares/PyFlwDir/branch/main/graph/badge.svg?token=N4VMHJJAV3 6 | :target: https://codecov.io/gh/Deltares/PyFlwDir 7 | 8 | .. image:: https://img.shields.io/badge/docs-latest-brightgreen.svg 9 | :target: https://deltares.github.io/pyflwdir/latest 10 | :alt: Latest docs 11 | 12 | .. image:: https://mybinder.org/badge_logo.svg 13 | :alt: Binder 14 | :target: https://mybinder.org/v2/gh/Deltares/pyflwdir/main?urlpath=lab/tree/examples 15 | 16 | .. image:: https://badge.fury.io/py/PyFlwDir.svg 17 | :target: https://pypi.org/project/PyFlwDir/ 18 | :alt: Latest PyPI version 19 | 20 | .. image:: https://anaconda.org/conda-forge/PyFlwDir/badges/version.svg 21 | :target: https://anaconda.org/conda-forge/PyFlwDir 22 | 23 | .. image:: https://zenodo.org/badge/409871473.svg 24 | :target: https://zenodo.org/badge/latestdoi/409871473 25 | 26 | .. image:: https://img.shields.io/github/license/Deltares/pyflwdir?style=flat 27 | :alt: License 28 | :target: https://github.com/Deltares/pyflwdir/blob/main/LICENSE 29 | 30 | 31 | 32 | Intro 33 | ----- 34 | 35 | PyFlwDir contains a series of methods to work with gridded DEM and flow direction 36 | datasets, which are key to many workflows in many earth sciences. 37 | PyFlwDir supports several flow direction data conventions and can easily be extended to include more. 38 | The package contains some unique methods such as Iterative Hydrography Upscaling (IHU) 39 | method to upscale flow directions from high resolution data to coarser model resolution. 40 | 41 | PyFlwDir is in pure python and powered by `numba `_ to keep it fast. 42 | 43 | 44 | Featured methods 45 | ---------------- 46 | 47 | - flow directions from elevation data using a steepest gradient algorithm 48 | - strahler stream order 49 | - flow direction upscaling 50 | - (sub)basin delineation 51 | - pfafstetter subbasins delineation 52 | - classic stream order 53 | - height above nearest drainage (HAND) 54 | - geomorphic floodplain delineation 55 | - up- and downstream tracing and arithmetics 56 | - hydrologically adjusting elevation 57 | - upstream accumulation 58 | - vectorizing streams 59 | - many more! 60 | 61 | .. image:: https://raw.githubusercontent.com/Deltares/pyflwdir/main/docs/_static/pyflwdir.png 62 | :width: 100% 63 | 64 | 65 | Installation 66 | ------------ 67 | 68 | See `installation guide `_ 69 | 70 | Quickstart 71 | ---------- 72 | 73 | See `user guide `_ 74 | 75 | 76 | Reference API 77 | ------------- 78 | 79 | See `reference API `_ 80 | 81 | 82 | Development and Testing 83 | ----------------------- 84 | 85 | Welcome to the PyFlwDir project. All contributions, bug reports, bug fixes, documentation improvements, enhancements, and ideas are welcome. 86 | See `Contributing to PyFlwDir `__ for how we work. 87 | -------------------------------------------------------------------------------- /pyflwdir/rivers.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from numba import njit 3 | from scipy.integrate import solve_ivp 4 | 5 | import logging 6 | 7 | logger = logging.Logger(__name__) 8 | 9 | 10 | @njit 11 | def classify_estuary( 12 | idxs_ds: np.ndarray, 13 | seq: np.ndarray, 14 | idxs_pit: np.ndarray, 15 | rivdst: np.ndarray, 16 | rivwth: np.ndarray, 17 | elevtn: np.ndarray, 18 | max_elevtn: float = 0, 19 | min_convergence: float = 1e-2, 20 | ) -> np.ndarray: 21 | """Classifies estuaries based on width convergence. 22 | 23 | Parameters 24 | ---------- 25 | rivdst, rivwth, elevtn : np.ndarray 26 | Distance to river outlet [m], river width [m], elevation [m+REF] 27 | max_elevtn : float, optional 28 | Maximum elevation for estuary outlet, by default 0 m+REF 29 | min_convergence : float, optional 30 | River width convergence threshold, by default 1e-2 m/m 31 | 32 | Returns 33 | ------- 34 | np.ndarray of int8 35 | Estuary classification: >= 1 where estuary; 2 at upstream end of estaury. 36 | """ 37 | estuary = np.zeros(idxs_ds.size, np.int8) 38 | idxs0 = idxs_pit[elevtn[idxs_pit] <= max_elevtn] 39 | estuary[idxs0] = 1 40 | for idx in seq: # down- to upstream 41 | idx_ds = idxs_ds[idx] 42 | if estuary[idx_ds] == 0 or idx == idx_ds: 43 | continue 44 | dx = rivdst[idx] - rivdst[idx_ds] 45 | dw = rivwth[idx_ds] - rivwth[idx] 46 | if (rivdst[idx_ds] == 0 and dw <= 0) or (dx > 0 and dw / dx > min_convergence): 47 | estuary[idx] = 1 48 | else: 49 | estuary[idx_ds] = 2 # most upstream estuary link 50 | return estuary 51 | 52 | 53 | def rivdph_gvf( 54 | idxs_ds, 55 | seq, 56 | zs, 57 | rivdph, 58 | qbankfull, 59 | rivdst, 60 | rivwth, 61 | manning, 62 | min_rivslp=1e-5, 63 | min_rivdph=1, 64 | eps=1e-1, 65 | n_iter=2, 66 | logger=logger, 67 | ): 68 | # gradually varying flow solver for directed flw graph 69 | # NOTE: experimental!! 70 | def _gvf(x, h, n, q, s0, w, g=9.81, eps=eps): 71 | h = max(h, eps) 72 | sf = lambda h: n**2 * (q / (w * h)) ** 2 * ((w * h) / (2 * h + w)) ** (-4 / 3) 73 | fr = lambda h: q / (w * np.sqrt(g * h)) 74 | dhdx = (s0 - sf(h)) / (1 - fr(h) ** 2) 75 | return -dhdx 76 | 77 | rivdph_out = rivdph.copy() 78 | # initial bed levels 79 | zb = zs - rivdph 80 | for _ in range(n_iter): 81 | for idx in seq: # from down- to upstream 82 | idx_ds = idxs_ds[idx] 83 | if qbankfull[idx] <= 0 or rivwth[idx] <= 0 or idx == idx_ds: # pit 84 | continue 85 | dz = zb[idx] - zb[idx_ds] 86 | dx = rivdst[idx] - rivdst[idx_ds] 87 | # FIXME force a positive slp for stable solutions 88 | slp = max(min_rivslp, dz / dx) 89 | # print(np.round(dz/dx,8), np.round(slp,8)) 90 | h0 = rivdph_out[idx_ds] 91 | args = (manning[idx], qbankfull[idx], slp, rivwth[idx]) 92 | # solve riv depth for single node with RK45 numerical integration 93 | sol = solve_ivp(_gvf, [0, dx], [h0], method="RK45", args=args) 94 | h1 = sol.y[-1][-1] 95 | if abs((h1 - h0) / dx) > 1 or h1 < 0 or not sol.success: 96 | logger.warning(sol.message) 97 | else: 98 | rivdph_out[idx] = max(min_rivdph, h1) 99 | # update bed levels 100 | zb = zs - rivdph_out 101 | return rivdph_out 102 | -------------------------------------------------------------------------------- /docs/api/flwdirraster.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: pyflwdir 2 | 3 | FlwdirRaster 4 | ------------ 5 | 6 | .. autosummary:: 7 | :toctree: ../_generated 8 | 9 | FlwdirRaster 10 | 11 | 12 | Input/Output 13 | ^^^^^^^^^^^^ 14 | 15 | .. autosummary:: 16 | :toctree: ../_generated 17 | 18 | from_array 19 | from_dem 20 | FlwdirRaster.to_array 21 | FlwdirRaster.load 22 | FlwdirRaster.dump 23 | 24 | 25 | Flow direction attributes 26 | ^^^^^^^^^^^^^^^^^^^^^^^^^ 27 | 28 | The following attributes describe the flow direction and are at the core to the object. 29 | 30 | .. autosummary:: 31 | :toctree: ../_generated 32 | 33 | FlwdirRaster.idxs_ds 34 | FlwdirRaster.idxs_us_main 35 | FlwdirRaster.idxs_seq 36 | FlwdirRaster.idxs_pit 37 | FlwdirRaster.ncells 38 | FlwdirRaster.rank 39 | FlwdirRaster.isvalid 40 | FlwdirRaster.mask 41 | FlwdirRaster.area 42 | FlwdirRaster.distnc 43 | FlwdirRaster.n_upstream 44 | 45 | 46 | Flow direction methods 47 | ^^^^^^^^^^^^^^^^^^^^^^ 48 | 49 | .. autosummary:: 50 | :toctree: ../_generated 51 | 52 | FlwdirRaster.order_cells 53 | FlwdirRaster.main_upstream 54 | FlwdirRaster.add_pits 55 | FlwdirRaster.repair_loops 56 | FlwdirRaster.vectorize 57 | 58 | 59 | Raster & geospatial attributes and methods 60 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 61 | 62 | The FlwdirRaster object contains :py:attr:`FlwdirRaster.shape`, :py:attr:`FlwdirRaster.transform` 63 | and :py:attr:`FlwdirRaster.latlon` attributes describing its geospatial location. The first 64 | attribute is required at initializiation, while the others can be set later. 65 | 66 | .. autosummary:: 67 | :toctree: ../_generated 68 | 69 | FlwdirRaster.set_transform 70 | FlwdirRaster.index 71 | FlwdirRaster.xy 72 | FlwdirRaster.bounds 73 | FlwdirRaster.extent 74 | 75 | 76 | Streams and flow paths 77 | ^^^^^^^^^^^^^^^^^^^^^^ 78 | 79 | .. autosummary:: 80 | :toctree: ../_generated 81 | 82 | FlwdirRaster.stream_order 83 | FlwdirRaster.path 84 | FlwdirRaster.snap 85 | FlwdirRaster.outflow_idxs 86 | FlwdirRaster.inflow_idxs 87 | FlwdirRaster.stream_distance 88 | FlwdirRaster.streams 89 | FlwdirRaster.geofeatures 90 | 91 | 92 | (Sub)basins 93 | ^^^^^^^^^^^ 94 | 95 | .. autosummary:: 96 | :toctree: ../_generated 97 | 98 | FlwdirRaster.basins 99 | FlwdirRaster.subbasins_streamorder 100 | FlwdirRaster.subbasins_pfafstetter 101 | FlwdirRaster.subbasins_area 102 | FlwdirRaster.basin_outlets 103 | FlwdirRaster.basin_bounds 104 | 105 | 106 | Up- and downstream values 107 | ^^^^^^^^^^^^^^^^^^^^^^^^^ 108 | 109 | .. autosummary:: 110 | :toctree: ../_generated 111 | 112 | FlwdirRaster.downstream 113 | FlwdirRaster.upstream_sum 114 | 115 | 116 | Up- and downstream arithmetics 117 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 118 | 119 | .. autosummary:: 120 | :toctree: ../_generated 121 | 122 | FlwdirRaster.accuflux 123 | FlwdirRaster.upstream_area 124 | FlwdirRaster.moving_average 125 | FlwdirRaster.moving_median 126 | FlwdirRaster.smooth_rivlen 127 | FlwdirRaster.fillnodata 128 | 129 | 130 | Upscale and subgrid methods 131 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 132 | 133 | .. autosummary:: 134 | :toctree: ../_generated 135 | 136 | FlwdirRaster.upscale 137 | FlwdirRaster.upscale_error 138 | FlwdirRaster.subgrid_rivlen 139 | FlwdirRaster.subgrid_rivslp 140 | FlwdirRaster.subgrid_rivavg 141 | FlwdirRaster.subgrid_rivmed 142 | FlwdirRaster.ucat_area 143 | FlwdirRaster.ucat_outlets 144 | 145 | 146 | Elevation 147 | ^^^^^^^^^ 148 | 149 | .. autosummary:: 150 | :toctree: ../_generated 151 | 152 | FlwdirRaster.dem_adjust 153 | FlwdirRaster.dem_dig_d4 154 | FlwdirRaster.hand 155 | FlwdirRaster.floodplains 156 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.4,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "pyflwdir" 7 | authors = [{name = "Dirk Eilander", email = "dirk.eilander@deltares.nl"}] 8 | readme = "README.rst" 9 | license = {file = "LICENSE"} 10 | classifiers = ["License :: OSI Approved :: MIT License"] 11 | dynamic = ["version", "description"] 12 | dependencies = [ 13 | "affine", 14 | "numba>=0.54,<1.0", 15 | "numpy", 16 | "scipy", 17 | ] 18 | requires-python = ">=3.9" 19 | 20 | [project.urls] 21 | Home = "https://github.com/Deltares/pyflwdir" 22 | Documentation = "https://deltares.github.io/pyflwdir" 23 | 24 | [project.optional-dependencies] 25 | test = [ 26 | "black[jupyter]", 27 | "pre-commit", 28 | "pytest>=2.7.3", 29 | "pytest-cov", 30 | ] 31 | doc = [ 32 | "nbsphinx", 33 | "pydata-sphinx-theme", 34 | "sphinx", 35 | "sphinx_design", 36 | ] 37 | examples = [ 38 | "cartopy>=0.20", 39 | "descartes", 40 | "geopandas>0.8", 41 | "jupyter", 42 | "matplotlib", 43 | "rasterio", 44 | "pandoc", 45 | ] 46 | 47 | full = ["pyflwdir[test, doc, examples]"] 48 | 49 | [tool.black] 50 | line-length = 88 51 | target-version = ['py39'] 52 | 53 | [tool.flit.sdist] 54 | include = ["pyflwdir"] 55 | exclude = ["docs", "notebooks", "envs", "tests", "binder", ".github"] 56 | 57 | [tool.pytest.ini_options] 58 | testpaths = ["tests"] 59 | 60 | [tool.make_env] 61 | channels = ["conda-forge"] 62 | deps_not_in_conda = [ 63 | "sphinx_design", 64 | "black[jupyter]", 65 | ] 66 | 67 | [tool.pixi.project] 68 | channels = ["conda-forge"] 69 | platforms = ["linux-64", "win-64"] 70 | 71 | [tool.pixi.feature.py311.dependencies] 72 | python = "3.11.*" 73 | 74 | [tool.pixi.feature.py310.dependencies] 75 | python = "3.10.*" 76 | 77 | [tool.pixi.feature.py39.dependencies] 78 | python = "3.9.*" 79 | 80 | [tool.pixi.feature.py312.dependencies] 81 | python = "3.12.*" 82 | 83 | [tool.pixi.feature.py313.dependencies] 84 | python = "3.13.*" 85 | 86 | [tool.pixi.feature.doc.dependencies] 87 | pandoc = "*" 88 | 89 | [tool.pixi.pypi-dependencies] 90 | pyflwdir = { path = ".", editable = true } 91 | 92 | [tool.pixi.environments] 93 | default = { features = ["py311", "full", "test", "doc", "examples"], solve-group = "default" } 94 | test-py39 = { features = ["py39", "test"], solve-group = "py39" } 95 | test-py310 = { features = ["py310", "test"], solve-group = "py310" } 96 | test-py311 = { features = ["test"], solve-group = "default" } 97 | test-py312 = { features = ["py312", "test"], solve-group = "py312" } 98 | test-py313 = { features = ["py313", "test"], solve-group = "py313" } 99 | 100 | [tool.pixi.tasks] 101 | install-pre-commit = "pre-commit install" 102 | 103 | # linting 104 | lint = { cmd = "pre-commit run --all-files", depends-on = ["install-pre-commit"] } 105 | 106 | # docs 107 | docs-dummy = {cmd = ["sphinx-build", "./docs", "./docs/_build", "-b", "dummy", "-W"], env = {PYDEVD_DISABLE_FILE_VALIDATION = "1"}} 108 | docs-skip-examples = {cmd = ["sphinx-build", "./docs", "./docs/_build", "-b", "html", "-W"], env = {SKIP_DOC_EXAMPLES = "1", PYDEVD_DISABLE_FILE_VALIDATION = "1"}} 109 | docs-html = {cmd = ["sphinx-build", "-M", "html", "./docs", "./docs/_build", "-W"]} 110 | docs-clean = {cmd = ["rm", "-rf", "./docs/_build", "./docs/_generated"] } 111 | docs-html-clean = { depends-on = ["docs-clean", "docs-html"] } 112 | 113 | # tests 114 | test = { cmd = ["pytest", "tests", "-v", "--cov-report=term-missing"] } 115 | test-cov-xml = { cmd = "python -m pytest --verbose --cov=pyflwdir --cov-report xml", env = {NUMBA_DISABLE_JIT = "1"} } 116 | test-cov = { cmd = "python -m pytest --verbose --cov=pyflwdir --cov-report term-missing", env = {NUMBA_DISABLE_JIT = "1"} } 117 | 118 | 119 | # version 120 | version = { cmd = ["python", "-c", "from pyflwdir import __version__ as v; print('dev' if 'dev' in v else 'v'+v)"] } 121 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import numpy as np 4 | import pytest 5 | 6 | # uncomment for debugging tests 7 | os.environ["NUMBA_DISABLE_JIT"] = "1" 8 | 9 | from pyflwdir import core, core_d8, core_nextxy # noqa: E402 10 | from pyflwdir.pyflwdir import FlwdirRaster, from_dem # noqa: E402 11 | 12 | 13 | @pytest.fixture(scope="session") 14 | def testdir(): 15 | return os.path.dirname(__file__) 16 | 17 | 18 | @pytest.fixture(scope="session") 19 | def flwdir0(testdir): 20 | return np.loadtxt(os.path.join(testdir, "data", "flwdir.asc"), dtype=np.uint8) 21 | 22 | 23 | @pytest.fixture(scope="session") 24 | def flwdir0_idxs(flwdir0): 25 | idxs_ds0, idxs_pit0, _ = core_d8.from_array(flwdir0, dtype=np.uint32) 26 | return idxs_ds0, idxs_pit0 27 | 28 | 29 | @pytest.fixture(scope="session") 30 | def flwdir0_rank(flwdir0_idxs): 31 | idxs_ds0, _ = flwdir0_idxs 32 | rank0, n0 = core.rank(idxs_ds0, mv=np.uint32(core._mv)) 33 | seq0 = np.argsort(rank0)[-n0:] 34 | return rank0, n0, seq0 35 | 36 | 37 | @pytest.fixture(scope="session") 38 | def test_data0(flwdir0_idxs, flwdir0_rank): 39 | rank0, _, seq0 = flwdir0_rank 40 | idxs_ds0, idxs_pit0 = flwdir0_idxs 41 | return idxs_ds0, idxs_pit0, seq0, rank0, np.uint32(core._mv) 42 | 43 | 44 | @pytest.fixture(scope="session") 45 | def nextxy0(flwdir0, flwdir0_idxs): 46 | return core_nextxy.to_array(flwdir0_idxs[0], flwdir0.shape) 47 | 48 | 49 | @pytest.fixture(scope="session") 50 | def flw0(flwdir0, flwdir0_idxs): 51 | idxs_ds0, idxs_pit0 = flwdir0_idxs 52 | return FlwdirRaster( 53 | idxs_ds0.copy(), flwdir0.shape, "d8", idxs_pit=idxs_pit0.copy(), cache=False 54 | ) 55 | 56 | 57 | @pytest.fixture(scope="session") 58 | def flwdir1(): 59 | np.random.seed(2345) 60 | return from_dem(np.random.rand(15, 10)).to_array("d8") 61 | 62 | 63 | @pytest.fixture(scope="session") 64 | def flwdir1_idxs(flwdir1): 65 | idxs_ds1, idxs_pit1, _ = core_d8.from_array(flwdir1, dtype=np.uint32) 66 | return idxs_ds1, idxs_pit1 67 | 68 | 69 | @pytest.fixture(scope="session") 70 | def flwdir1_rank(flwdir1_idxs): 71 | idxs_ds1, _ = flwdir1_idxs 72 | rank1, n1 = core.rank(idxs_ds1, mv=np.uint32(core._mv)) 73 | seq1 = np.argsort(rank1)[-n1:] 74 | return rank1, n1, seq1 75 | 76 | 77 | @pytest.fixture(scope="session") 78 | def test_data1(flwdir1_idxs, flwdir1_rank): 79 | rank1, _, seq1 = flwdir1_rank 80 | idxs_ds1, idxs_pit1 = flwdir1_idxs 81 | return idxs_ds1, idxs_pit1, seq1, rank1, np.uint32(core._mv) 82 | 83 | 84 | @pytest.fixture(scope="session") 85 | def flwdir2(): 86 | np.random.seed(2345) 87 | return from_dem(np.random.rand(15, 10)).to_array("d8") 88 | 89 | 90 | @pytest.fixture(scope="session") 91 | def flwdir2_idxs(flwdir2): 92 | idxs_ds2, idxs_pit2, _ = core_d8.from_array(flwdir2, dtype=np.uint64) 93 | return idxs_ds2, idxs_pit2 94 | 95 | 96 | @pytest.fixture(scope="session") 97 | def flwdir2_rank(flwdir2_idxs): 98 | idxs_ds2, _ = flwdir2_idxs 99 | rank2, n2 = core.rank(idxs_ds2, mv=np.uint64(core._mv)) 100 | seq2 = np.argsort(rank2)[-n2:] 101 | return rank2, n2, seq2 102 | 103 | 104 | @pytest.fixture(scope="session") 105 | def test_data2(flwdir2_idxs, flwdir2_rank): 106 | rank2, _, seq2 = flwdir2_rank 107 | idxs_ds2, idxs_pit2 = flwdir2_idxs 108 | return idxs_ds2, idxs_pit2, seq2, rank2, np.uint64(core._mv) 109 | 110 | 111 | @pytest.fixture(scope="session") 112 | def test_data(test_data0, flwdir0, test_data1, flwdir1, test_data2, flwdir2): 113 | return [(test_data0, flwdir0), (test_data1, flwdir1), (test_data2, flwdir2)] 114 | 115 | 116 | @pytest.fixture(scope="session") 117 | def flwdir_large(testdir): 118 | return np.loadtxt(os.path.join(testdir, "data", "flwdir1.asc"), dtype=np.uint8) 119 | 120 | 121 | @pytest.fixture(scope="session") 122 | def flwdir_large_idxs(flwdir_large): 123 | idxs_ds0, idxs_pit0, _ = core_d8.from_array(flwdir_large, dtype=np.uint32) 124 | return idxs_ds0, idxs_pit0 125 | -------------------------------------------------------------------------------- /make_env.py: -------------------------------------------------------------------------------- 1 | """A simple script to generate enviroment.yml files from pyproject.toml.""" 2 | 3 | import argparse 4 | import re 5 | from sys import version_info 6 | from typing import List 7 | 8 | if version_info.minor >= 11: 9 | from tomllib import load 10 | else: 11 | from tomli import load 12 | 13 | 14 | # our quick and dirty implementation of recursive depedencies 15 | def _parse_profile(profile_str: str, opt_deps: dict, project_name: str) -> List[str]: 16 | if profile_str is None or profile_str == "": 17 | return [] 18 | 19 | pat = re.compile(r"\s*" + project_name + r"\[(.*)\]\s*") 20 | parsed = [] 21 | queue = [f"{project_name}[{x.strip()}]" for x in profile_str.split(",")] 22 | while len(queue) > 0: 23 | dep = queue.pop(0) 24 | if dep == "": 25 | continue 26 | m = pat.match(dep) 27 | if m: 28 | # if we match the patern, all list elts have to be dependenciy groups 29 | dep_groups = [d.strip() for d in m.groups(0)[0].split(",")] 30 | unknown_dep_groups = set(dep_groups) - set(opt_deps.keys()) 31 | if len(unknown_dep_groups) > 0: 32 | raise RuntimeError(f"unknown dependency group(s): {unknown_dep_groups}") 33 | queue.extend(dep_groups) 34 | continue 35 | 36 | if dep in opt_deps: 37 | queue.extend([x.strip() for x in opt_deps[dep]]) 38 | else: 39 | parsed.append(dep) 40 | 41 | return parsed 42 | 43 | 44 | parser = argparse.ArgumentParser() 45 | 46 | parser.add_argument("profile", default="examples", nargs="?") 47 | parser.add_argument("--output", "-o", default="environment.yml") 48 | parser.add_argument("--channels", "-c", default=None) 49 | parser.add_argument("--name", "-n", default=None) 50 | parser.add_argument( 51 | "--py-version", "-p", default="3.12", choices=["3.9", "3.10", "3.11", "3.12"] 52 | ) 53 | parser.add_argument("-r", "--release", action="store_true") 54 | args = parser.parse_args() 55 | 56 | # 57 | with open("pyproject.toml", "rb") as f: 58 | toml = load(f) 59 | deps = toml["project"]["dependencies"] 60 | opt_deps = toml["project"]["optional-dependencies"] 61 | project_name = toml["project"]["name"] 62 | # specific conda_install settings 63 | install_config = toml["tool"].get("make_env", {}) 64 | deps_not_in_conda = install_config.get("deps_not_in_conda", []) 65 | channels = install_config.get("channels", ["conda-forge"]) 66 | if args.channels is not None: 67 | channels.extend(args.channels.split(",")) 68 | channels = list(set(channels)) 69 | 70 | # parse environment name 71 | name = args.name 72 | if name is None: 73 | name = project_name 74 | print(f"Environment name: {name}") 75 | 76 | # parse dependencies groups and flavours 77 | # "min" equals no optional dependencies 78 | if args.release: 79 | deps_to_install = ["hydromt"] 80 | else: 81 | deps_to_install = deps.copy() 82 | if args.profile not in ["", "min"]: 83 | extra_deps = _parse_profile(args.profile, opt_deps, project_name) 84 | deps_to_install.extend(extra_deps) 85 | 86 | conda_deps = [] 87 | pip_deps = [] 88 | for dep in deps_to_install: 89 | if dep in deps_not_in_conda: 90 | pip_deps.append(dep) 91 | else: 92 | conda_deps.append(dep) 93 | if args.py_version is not None: 94 | conda_deps.append(f"python~={args.py_version}.0") 95 | 96 | # add pip as a conda dependency if we have pip deps 97 | if len(pip_deps) > 0: 98 | conda_deps.append("pip") 99 | 100 | # the list(set()) is to remove duplicates 101 | conda_deps_to_install_string = "\n- ".join(sorted(list(set(conda_deps)))) 102 | channels_string = "\n- ".join(set(channels)) 103 | 104 | # create environment.yml 105 | env_spec = f"""name: {name} 106 | 107 | channels: 108 | - {channels_string} 109 | 110 | dependencies: 111 | - {conda_deps_to_install_string} 112 | """ 113 | if len(pip_deps) > 0: 114 | pip_deps_to_install_string = "\n - ".join(sorted(list(set(pip_deps)))) 115 | env_spec += f"""- pip: 116 | - {pip_deps_to_install_string} 117 | """ 118 | 119 | with open(args.output, "w") as out: 120 | out.write(env_spec) 121 | -------------------------------------------------------------------------------- /pyflwdir/core_ldd.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Description of LDD flow direction type and methods to convert to/from general 3 | nextidx.""" 4 | 5 | from numba import njit, vectorize 6 | import numpy as np 7 | from . import core, core_d8 8 | 9 | __all__ = [] 10 | 11 | # LDD type 12 | _ftype = "ldd" 13 | _ds = np.array([[7, 8, 9], [4, 5, 6], [1, 2, 3]], dtype=np.uint8) 14 | _us = np.array([[3, 2, 1], [6, 5, 4], [9, 8, 7]], dtype=np.uint8) 15 | _mv = np.uint8(255) 16 | _pv = np.uint8(5) 17 | _all = np.array([7, 8, 9, 4, 5, 6, 1, 2, 3, 255], dtype=np.uint8) 18 | 19 | 20 | from numba import njit 21 | import numpy as np 22 | 23 | 24 | @njit("Tuple((int8, int8))(uint8)") 25 | def drdc(dd): 26 | """convert ldd value to delta row/col""" 27 | dr, dc = np.int8(0), np.int8(0) 28 | if dd >= np.uint8(4): # W / PIT / E / NW / N / NE 29 | if dd >= np.uint8(7): # NW / N / NE 30 | dr = np.int8(-1) 31 | dc = np.int8(dd) - np.int8(8) 32 | else: # W / PIT / E 33 | dr = np.int8(0) 34 | dc = np.int8(dd) - np.int8(5) 35 | else: # SW / S / SE 36 | dr = np.int8(1) 37 | dc = np.int8(dd) - np.int8(2) 38 | return dr, dc 39 | 40 | 41 | @njit 42 | def from_array(flwdir, _mv=_mv, dtype=np.intp): 43 | """convert 2D LDD data to 1D next downstream indices""" 44 | nrow, ncol = flwdir.shape 45 | flwdir_flat = flwdir.ravel() 46 | # get downsteam indices 47 | pits_lst = [] 48 | idxs_ds = np.full(flwdir.size, core._mv, dtype=dtype) 49 | n = 0 50 | for idx0 in range(flwdir.size): 51 | if flwdir_flat[idx0] == _mv: 52 | continue 53 | dr, dc = drdc(flwdir_flat[idx0]) 54 | r_ds = int(idx0 // ncol + dr) 55 | c_ds = int(idx0 % ncol + dc) 56 | pit = dr == 0 and dc == 0 57 | outside = r_ds >= nrow or c_ds >= ncol or r_ds < 0 or c_ds < 0 58 | idx_ds = c_ds + r_ds * ncol 59 | # pit or outside or ds cell has mv 60 | if pit or outside or flwdir_flat[idx_ds] == _mv: 61 | pits_lst.append(idx0) 62 | idxs_ds[idx0] = idx0 63 | else: 64 | idxs_ds[idx0] = idx_ds 65 | n += 1 66 | return idxs_ds, np.array(pits_lst, dtype=dtype), n 67 | 68 | 69 | @njit 70 | def _downstream_idx(idx0, flwdir_flat, shape, mv=core._mv): 71 | """Returns linear index of the donwstream neighbor; idx0 if at pit""" 72 | nrow, ncol = shape 73 | r0 = idx0 // ncol 74 | c0 = idx0 % ncol 75 | dr, dc = drdc(flwdir_flat[idx0]) 76 | r_ds, c_ds = r0 + dr, c0 + dc 77 | if r_ds >= 0 and r_ds < nrow and c_ds >= 0 and c_ds < ncol: # check bounds 78 | idx_ds = c_ds + r_ds * ncol 79 | else: 80 | idx_ds = mv 81 | return idx_ds 82 | 83 | 84 | # general 85 | @njit 86 | def to_array(idxs_ds, shape, mv=core._mv): 87 | """convert downstream linear indices to dense D8 raster""" 88 | ncol = shape[1] 89 | flwdir = np.full(idxs_ds.size, _mv, dtype=np.uint8) 90 | for idx0 in range(idxs_ds.size): 91 | idx_ds = idxs_ds[idx0] 92 | if idx_ds == mv: 93 | continue 94 | dr: np.int32 = np.int32(idx_ds // ncol) - np.int32(idx0 // ncol) 95 | dc: np.int32 = np.int32(idx_ds % ncol) - np.int32(idx0 % ncol) 96 | if dr >= -1 and dr <= 1 and dc >= -1 and dc <= 1: 97 | dd = _ds[dr + 1, dc + 1] 98 | else: 99 | raise ValueError("Invalid data downstream index outside 8 neighbors.") 100 | flwdir[idx0] = dd 101 | return flwdir.reshape(shape) 102 | 103 | 104 | def isvalid(flwdir, _all=_all): 105 | """True if 2D LDD raster is valid""" 106 | return core_d8.isvalid(flwdir, _all) 107 | 108 | 109 | @njit 110 | def ispit(dd, _pv=_pv): 111 | """True if LDD pit""" 112 | return dd == _pv 113 | 114 | 115 | @njit 116 | def isnodata(dd, _mv=_mv): 117 | """True if LDD nodata""" 118 | return core_d8.isnodata(dd, _mv) 119 | 120 | 121 | @njit 122 | def _upstream_idx(idx0, flwdir_flat, shape, _us=_us, dtype=np.intp): 123 | """Returns a numpy array (int64) with linear indices of upstream neighbors""" 124 | return core_d8._upstream_idx(idx0, flwdir_flat, shape, _us, dtype=dtype) 125 | -------------------------------------------------------------------------------- /pyflwdir/core_nextxy.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Description of NEXTXY flow direction type and methods to convert to/from general 3 | nextidx. This type is mainly used for the CaMa-Flood model. Note that X (column) and Y 4 | (row) coordinates are one-based.""" 5 | 6 | from pathlib import Path 7 | from typing import List, Union 8 | from numba import njit 9 | import numpy as np 10 | from . import core, gis_utils 11 | 12 | __all__ = ["read_nextxy"] 13 | 14 | # NEXTXY type 15 | _ftype = "nextxy" 16 | _mv = np.int32(-9999) 17 | # -10 is inland termination, -9 river outlet at ocean 18 | _pv = np.array([-9, -10], dtype=np.int32) 19 | # NOTE: data below for consistency with LDD / D8 types and testing 20 | _us = np.ones((2, 3, 3), dtype=np.int32) * 2 21 | _us[:, 1, 1] = _pv[0] 22 | 23 | 24 | def from_array(flwdir, dtype=np.intp): 25 | if not ( 26 | (isinstance(flwdir, tuple) and len(flwdir) == 2) 27 | or ( 28 | isinstance(flwdir, np.ndarray) and flwdir.ndim == 3 and flwdir.shape[0] == 2 29 | ) 30 | ): 31 | raise TypeError("NEXTXY flwdir data not understood") 32 | nextx, nexty = flwdir # convert [2,:,:] OR ([:,:], [:,:]) to [:,:], [:,:] 33 | return _from_array(nextx, nexty, dtype=dtype) 34 | 35 | 36 | def to_array(idxs_ds, shape, mv=core._mv): 37 | nextx, nexty = _to_array(idxs_ds, shape, mv=mv) 38 | return np.stack([nextx, nexty]) 39 | 40 | 41 | @njit 42 | def _from_array(nextx, nexty, _mv=_mv, dtype=np.intp): 43 | size = nextx.size 44 | nrow, ncol = nextx.shape[0], nextx.shape[-1] 45 | nextx_flat = nextx.ravel() 46 | nexty_flat = nexty.ravel() 47 | # allocate output arrays 48 | pits_lst = [] 49 | idxs_ds = np.full(nextx.size, core._mv, dtype=dtype) 50 | n = 0 51 | for idx0 in range(nextx.size): 52 | if nextx_flat[idx0] == _mv: 53 | continue 54 | c1 = nextx_flat[idx0] 55 | r1 = nexty_flat[idx0] 56 | pit = ispit(c1) or ispit(r1) 57 | # convert from one- to zero-based index 58 | r_ds, c_ds = np.intp(r1 - 1), np.intp(c1 - 1) 59 | outside = r_ds >= nrow or c_ds >= ncol or r_ds < 0 or c_ds < 0 60 | idx_ds = c_ds + r_ds * ncol 61 | # pit or outside or ds cell is mv 62 | if pit or outside or nextx_flat[idx_ds] == _mv: 63 | pits_lst.append(idx0) 64 | idxs_ds[idx0] = idx0 65 | else: 66 | idxs_ds[idx0] = idx_ds 67 | n += 1 68 | return idxs_ds, np.array(pits_lst, dtype=dtype), n 69 | 70 | 71 | @njit 72 | def _to_array(idxs_ds, shape, mv=core._mv): 73 | """convert 1D index to 3D NEXTXY raster""" 74 | ncol = shape[1] 75 | nextx = np.full(idxs_ds.size, _mv, dtype=np.int32) 76 | nexty = np.full(idxs_ds.size, _mv, dtype=np.int32) 77 | for idx0 in range(idxs_ds.size): 78 | idx_ds = idxs_ds[idx0] 79 | if idx_ds == mv: 80 | continue 81 | elif idx0 == idx_ds: # pit 82 | nextx[idx0] = _pv[0] 83 | nexty[idx0] = _pv[0] 84 | else: 85 | # convert idx_ds to one-based row / col indices 86 | nextx[idx0] = idx_ds % ncol + 1 87 | nexty[idx0] = idx_ds // ncol + 1 88 | return nextx.reshape(shape), nexty.reshape(shape) 89 | 90 | 91 | def isvalid(flwdir): 92 | """True if NEXTXY raster is valid""" 93 | isfmt1 = isinstance(flwdir, tuple) and len(flwdir) == 2 94 | isfmt2 = ( 95 | isinstance(flwdir, np.ndarray) and flwdir.ndim == 3 and flwdir.shape[0] == 2 96 | ) 97 | if not (isfmt1 or isfmt2): 98 | return False 99 | nextx, nexty = flwdir # should work for [2,:,:] and ([:,:], [:,:]) 100 | mask = np.logical_or(isnodata(nextx), ispit(nextx)) 101 | return ( 102 | nexty.dtype == "int32" 103 | and nextx.dtype == "int32" 104 | and np.all(nexty.shape == nextx.shape) 105 | and np.all(nextx[~mask] >= 0) 106 | and np.all(nextx[mask] == nexty[mask]) 107 | ) 108 | 109 | 110 | @njit 111 | def ispit(dd, _pv=_pv): 112 | """True if NEXTXY pit""" 113 | return np.logical_or(dd == _pv[0], dd == _pv[1]) 114 | 115 | 116 | @njit 117 | def isnodata(dd): 118 | """True if NEXTXY nodata""" 119 | return dd == _mv 120 | 121 | 122 | def read_nextxy(fn: Union[str, Path], nrow: int, ncol: int, bbox: List) -> np.ndarray: 123 | """Read nextxy data from binary file. 124 | 125 | Parameters 126 | ---------- 127 | fn : str, Path 128 | Path to nextxy.bin file 129 | nrow, ncol : int 130 | Number or rows and columns in nextxy file. 131 | bbox: list of float 132 | domain bounding box [xmin, ymin, xmax, ymax] 133 | 134 | Returns 135 | ------- 136 | np.ndarray 137 | Nextxy data 138 | transform: Affine 139 | Coefficients mapping pixel coordinates to coordinate reference system. 140 | """ 141 | data = np.fromfile(fn, "i4").reshape(2, nrow, ncol) 142 | assert len(bbox) == 4, "Bounding box should contain 4 coordinates." 143 | transform = gis_utils.transform_from_bounds(*bbox, ncol, nrow) 144 | return data, transform 145 | -------------------------------------------------------------------------------- /pyflwdir/arithmetics.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """""" 3 | 4 | from numba import njit 5 | import numpy as np 6 | 7 | # import flow direction definition 8 | from . import core 9 | 10 | _mv = core._mv 11 | __all__ = [] 12 | 13 | 14 | # NOTE np.average (with) weights is not yet supoorted by numpy 15 | # all functions are faster than numpy. 16 | @njit 17 | def _average(data, weights, nodata): 18 | """Weighted arithmetic mean""" 19 | v = 0.0 20 | w = 0.0 21 | nan = np.isnan(nodata) 22 | for i in range(data.size): 23 | v0 = data[i] 24 | if (not nan and v0 == nodata) or (nan and np.isnan(v0)): 25 | continue 26 | w0 = weights[i] 27 | v += w0 * v0 28 | w += w0 29 | return v / w if w != 0 else nodata 30 | 31 | 32 | @njit 33 | def _mean(data, nodata): 34 | """Arithmetic mean""" 35 | v = 0.0 36 | w = 0.0 37 | nan = np.isnan(nodata) 38 | for v0 in data: 39 | if (not nan and v0 == nodata) or (nan and np.isnan(v0)): 40 | continue 41 | v += v0 42 | w += 1.0 43 | return v / w if w != 0 else nodata 44 | 45 | 46 | @njit 47 | def lstsq(x: np.ndarray, y: np.ndarray): 48 | """Simple ordinary Least Squares regression.""" 49 | n = x.size 50 | x_sum = 0.0 51 | y_sum = 0.0 52 | x_sq_sum = 0.0 53 | x_y_sum = 0.0 54 | 55 | for i in range(n): 56 | x_sum += x[i] 57 | y_sum += y[i] 58 | x_sq_sum += x[i] ** 2 59 | x_y_sum += x[i] * y[i] 60 | 61 | slope = (n * x_y_sum - x_sum * y_sum) / (n * x_sq_sum - x_sum**2) 62 | intercept = (y_sum - slope * x_sum) / n 63 | 64 | return slope, intercept 65 | 66 | 67 | @njit 68 | def moving_average( 69 | data, weights, n, idxs_ds, idxs_us_main, strord=None, nodata=-9999.0, mv=_mv 70 | ): 71 | """Take the moving weighted average over the flow direction network. 72 | 73 | Parameters 74 | ---------- 75 | data : 1D array 76 | values to be averaged 77 | weights : 1D array 78 | weights 79 | n : int 80 | number of up/downstream neighbors to include 81 | idxs_ds, idxs_us_main : array of int 82 | indices of downstream, main upstream cells 83 | strord: 1D array 84 | stream order, when set limit window to cells of same or smaller stream order. 85 | nodata : float, optional 86 | Nodata value which is ignored when calculating the average, by default -9999.0 87 | 88 | Returns 89 | ------- 90 | 1D array 91 | averaged data 92 | """ 93 | # loop over values and avarage 94 | data_out = np.full(data.size, nodata, dtype=data.dtype) 95 | for idx0 in range(data.size): 96 | if data[idx0] == nodata: 97 | continue 98 | idxs = core._window(idx0, n, idxs_ds, idxs_us_main, strord=strord, mv=mv) 99 | idxs = idxs[idxs != mv] 100 | if idxs.size > 0: 101 | w = np.ones(idxs.size) if weights is None else weights[idxs] 102 | data_out[idx0] = _average(data[idxs], w, nodata) 103 | return data_out 104 | 105 | 106 | @njit 107 | def moving_median(data, n, idxs_ds, idxs_us_main, strord=None, nodata=-9999.0, mv=_mv): 108 | """Take the moving median over the flow direction network. 109 | 110 | Parameters 111 | ---------- 112 | data : 1D (sparse) array 113 | values 114 | weights : 1D (sparse) array 115 | weights 116 | n : int 117 | number of up/downstream neighbors to include 118 | idxs_ds, idxs_us_main : array of int 119 | indices of downstream, main upstream cells 120 | strord: 1D array 121 | stream order, when set limit window to cells of same or smaller stream order. 122 | nodata : float, optional 123 | Nodata value which is ignored when calculating the median, by default -9999.0 124 | 125 | Returns 126 | ------- 127 | 1D array 128 | median data 129 | """ 130 | # loop over values and avarage 131 | data_out = np.full(data.size, nodata, dtype=data.dtype) 132 | nan = np.isnan(nodata) 133 | for idx0 in range(data.size): 134 | if data[idx0] == nodata: 135 | continue 136 | idxs = core._window(idx0, n, idxs_ds, idxs_us_main, strord=strord, mv=mv) 137 | idxs = idxs[idxs != mv] 138 | if idxs.size > 0: 139 | a = data[idxs] 140 | if not nan: 141 | a = np.where(a == nodata, np.nan, a).astype(a.dtype) 142 | data_out[idx0] = np.nanmedian(a) 143 | return data_out 144 | 145 | 146 | @njit 147 | def upstream_sum(idxs_ds, data, nodata=-9999.0, mv=_mv): 148 | """Returns sum of first upstream values 149 | 150 | Parameters 151 | ---------- 152 | idxs_ds : 1D-array of uint32 153 | sparse indices of downstream cells 154 | 155 | Returns 156 | ------- 157 | 2D-array of uint32 158 | indices of upstream cells 159 | """ 160 | # 2D arrays of upstream index 161 | arr_sum = np.full(data.size, 0, dtype=data.dtype) 162 | for idx0 in range(data.size): 163 | idx_ds = idxs_ds[idx0] 164 | if idx_ds != mv and idx_ds != idx0: 165 | if data[idx0] == nodata or data[idx_ds] == nodata: 166 | arr_sum[idx0] = nodata 167 | else: 168 | arr_sum[idx_ds] += data[idx0] 169 | return arr_sum 170 | -------------------------------------------------------------------------------- /pyflwdir/core_d8.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Description of D8 flow direction type and methods to convert to/from general 3 | nextidx.""" 4 | 5 | from typing import Tuple 6 | import numpy as np 7 | from numba import njit 8 | 9 | from . import core 10 | 11 | __all__ = [] 12 | 13 | # D8 type 14 | _ftype = "d8" 15 | _ds = np.array([[32, 64, 128], [16, 0, 1], [8, 4, 2]], dtype=np.uint8) 16 | _us = np.array([[2, 4, 8], [1, 0, 16], [128, 64, 32]], dtype=np.uint8) 17 | _mv = np.uint8(247) 18 | _pv = np.array([0, 255], dtype=np.uint8) 19 | _all = np.array([32, 64, 128, 16, 0, 1, 8, 4, 2, 247, 255], dtype=np.uint8) 20 | 21 | 22 | @njit("Tuple((int8, int8))(uint8)") 23 | def drdc(dd): 24 | """convert d8 value to delta row/col""" 25 | dr, dc = np.int8(0), np.int8(0) 26 | if dd <= np.uint8(8): # PIT / E / SW / S / SE 27 | if dd >= np.uint8(2): # SW / S / SE 28 | dr = np.int8(1) 29 | dc = np.int8(2 - np.log2(dd)) 30 | else: # PIT / E 31 | dr = np.int8(0) 32 | dc = np.int8(dd) 33 | elif dd <= np.uint8(128): # W / NW / N / NE 34 | if dd == np.uint8(16): # W 35 | dr, dc = np.int8(0), np.int8(-1) 36 | else: # NW / N / NE 37 | dr = np.int8(-1) 38 | dc = np.int8(np.log2(dd) - 6) 39 | return dr, dc 40 | 41 | 42 | @njit 43 | def from_array(flwdir, _mv=_mv, dtype=np.intp): 44 | """convert 2D D8 data to 1D next downstream indices""" 45 | nrow, ncol = flwdir.shape 46 | flwdir_flat = flwdir.ravel() 47 | # get downsteam indices 48 | pits_lst = [] 49 | idxs_ds = np.full(flwdir.size, core._mv, dtype=dtype) 50 | n = 0 51 | for idx0 in range(flwdir.size): 52 | if flwdir_flat[idx0] == _mv: 53 | continue 54 | dr, dc = drdc(flwdir_flat[idx0]) 55 | r_ds = int(idx0 // ncol) + int(dr) 56 | c_ds = int(idx0 % ncol) + int(dc) 57 | pit = dr == 0 and dc == 0 58 | outside = r_ds >= nrow or c_ds >= ncol or r_ds < 0 or c_ds < 0 59 | idx_ds = c_ds + r_ds * ncol 60 | # pit or outside or ds cell has mv 61 | if pit or outside or flwdir_flat[idx_ds] == _mv: 62 | pits_lst.append(idx0) 63 | idxs_ds[idx0] = idx0 64 | else: 65 | idxs_ds[idx0] = idx_ds 66 | n += 1 67 | return idxs_ds, np.array(pits_lst, dtype=dtype), n 68 | 69 | 70 | @njit 71 | def _downstream_idx(idx0, flwdir_flat, shape, mv=core._mv): 72 | """Returns linear index of the donwstream neighbor; idx0 if at pit""" 73 | nrow, ncol = shape 74 | r0 = idx0 // ncol 75 | c0 = idx0 % ncol 76 | dr, dc = drdc(flwdir_flat[idx0]) 77 | r_ds, c_ds = r0 + dr, c0 + dc 78 | if r_ds >= 0 and r_ds < nrow and c_ds >= 0 and c_ds < ncol: # check bounds 79 | idx_ds = c_ds + r_ds * ncol 80 | else: 81 | idx_ds = mv 82 | return idx_ds 83 | 84 | 85 | # general 86 | @njit 87 | def to_array(idxs_ds: np.ndarray[np.uint64], shape: Tuple[int, int], mv=core._mv): 88 | """convert downstream linear indices to dense D8 raster""" 89 | ncol = shape[1] 90 | flwdir = np.full(idxs_ds.size, _mv, dtype=np.uint8) 91 | for idx0 in range(idxs_ds.size): 92 | idx_ds = idxs_ds[idx0] 93 | if idx_ds == mv: 94 | continue 95 | dr: np.int32 = np.int32(idx_ds // ncol) - np.int32(idx0 // ncol) 96 | dc: np.int32 = np.int32(idx_ds % ncol) - np.int32(idx0 % ncol) 97 | if dr >= -1 and dr <= 1 and dc >= -1 and dc <= 1: 98 | dd: np.uint8 = _ds[dr + 1, dc + 1] 99 | else: 100 | raise ValueError("Invalid data downstream index outside 8 neighbors.") 101 | flwdir[idx0] = dd 102 | return flwdir.reshape(shape) 103 | 104 | 105 | def isvalid(flwdir: np.uint8, _all: np.ndarray[np.uint8] = _all) -> bool: 106 | """True if 2D D8 raster is valid""" 107 | return ( 108 | isinstance(flwdir, np.ndarray) 109 | and flwdir.dtype == "uint8" 110 | and flwdir.ndim == 2 111 | and check_values(flwdir, _all) 112 | ) 113 | 114 | 115 | @njit 116 | def check_values(flwdir, _all): 117 | check = True 118 | for dd in flwdir.ravel(): 119 | if np.all(_all != dd): 120 | check = False 121 | break 122 | return check 123 | 124 | 125 | @njit 126 | def ispit(dd, _pv=_pv): 127 | """True if D8 pit""" 128 | return np.any(dd == _pv) 129 | 130 | 131 | @njit 132 | def isnodata(dd, _mv=_mv): 133 | """True if D8 nodata""" 134 | return dd == _mv 135 | 136 | 137 | @njit 138 | def _upstream_idx(idx0, flwdir_flat, shape, _us=_us, dtype=np.intp): 139 | """Returns a numpy array (int64) with linear indices of upstream neighbors""" 140 | nrow, ncol = shape 141 | # assume c-style row-major 142 | r = idx0 // ncol 143 | c = idx0 % ncol 144 | idxs_lst = list() 145 | for dr in range(-1, 2): 146 | for dc in range(-1, 2): 147 | if dr == 0 and dc == 0: # skip pit -> return empty array 148 | continue 149 | r_us, c_us = r + dr, c + dc 150 | if r_us >= 0 and r_us < nrow and c_us >= 0 and c_us < ncol: # check bounds 151 | idx = r_us * ncol + c_us 152 | if flwdir_flat[idx] == _us[dr + 1, dc + 1]: 153 | idxs_lst.append(idx) 154 | return np.array(idxs_lst, dtype=dtype) 155 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Tests for the pyflwdir.core.py submodule.""" 3 | 4 | import pytest 5 | import numpy as np 6 | 7 | from pyflwdir import core, streams 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "test_data, flwdir", [("test_data0", "flwdir0"), ("test_data0", "flwdir0")] 12 | ) 13 | def test_downstream(test_data, flwdir, request): 14 | test_data = request.getfixturevalue(test_data) 15 | flwdir = request.getfixturevalue(flwdir) 16 | idxs_ds, idxs_pit, seq, rank, mv = [p.copy() for p in test_data] 17 | n, ncol = np.sum(idxs_ds != mv), flwdir.shape[1] 18 | # rank 19 | assert np.sum(rank == 0) == idxs_pit.size 20 | if np.any(rank > 0): 21 | idxs_mask = np.where(rank > 0)[0] # valid and no pits 22 | assert np.all(rank[idxs_mask] == rank[idxs_ds[idxs_mask]] + 1) 23 | # pit indices 24 | idxs_pit1 = np.sort(core.pit_indices(idxs_ds)) 25 | assert np.all(idxs_pit1 == np.sort(idxs_pit)) 26 | # loop indices 27 | idxs_loop = core.loop_indices(idxs_ds, mv=mv) 28 | assert seq.size == n - idxs_loop.size 29 | # local upstream indices 30 | if np.any(rank >= 2): 31 | rmax = np.max(rank) 32 | idxs = np.where(rank == rmax)[0] 33 | # path 34 | paths, dists = core.path(idxs, idxs_ds, mv=mv) 35 | assert np.all([p.size for p in paths] == rmax + 1) 36 | assert np.all(dists == rmax) 37 | # snap 38 | idxs1, dists1 = core.snap(idxs, idxs_ds, ncol, real_length=True, mv=mv) 39 | assert np.all([idxs_ds[idx] == idx for idx in idxs1]) 40 | assert np.all(dists1 >= rmax) 41 | idxs2, dists2 = core.snap(idxs, idxs_ds, real_length=False, max_length=2, mv=mv) 42 | assert np.all(dists2 == 2) 43 | assert np.all(rank[idxs2] == rmax - 2) 44 | idxs2, dists2 = core.snap(idxs, idxs_ds, mask=rank <= rmax - 2, mv=mv) 45 | assert np.all(dists2 == 2) 46 | assert np.all(rank[idxs2] == rmax - 2) 47 | # window 48 | idx0 = np.where(rank == 2)[0][0] 49 | path = core._trace(idx0, idxs_ds, mv=mv)[0] 50 | wdw = core._window(idx0, 2, idxs_ds, idxs_ds, mv=mv) 51 | assert np.all(path == wdw[2:]) and np.all(path[::-1] == wdw[:-2]) 52 | ## 53 | rank1 = core.fillnodata_downstream(idxs_ds, seq, rank, nodata=0) 54 | idxs1 = idxs_ds[np.where(rank == 1)[0]] 55 | idxs1, n_up = np.unique(idxs1, return_counts=True) 56 | assert np.all(rank1[idxs1] == 1) 57 | rank2 = core.fillnodata_downstream(idxs_ds, seq, rank, nodata=0, how="min") 58 | assert np.all(rank2 == rank1) 59 | rank3 = core.fillnodata_downstream(idxs_ds, seq, rank, nodata=0, how="sum") 60 | assert np.all(rank3[idxs1] == n_up) 61 | 62 | 63 | @pytest.mark.parametrize( 64 | "test_data, flwdir", [("test_data0", "flwdir0"), ("test_data0", "flwdir0")] 65 | ) 66 | def test_upstream(test_data, flwdir, request): 67 | test_data = request.getfixturevalue(test_data) 68 | flwdir = request.getfixturevalue(flwdir) 69 | idxs_ds, idxs_pit, seq, rank, mv = [p.copy() for p in test_data] 70 | idxs_ds[rank == -1] = mv 71 | n, ncol = np.sum(idxs_ds != mv), flwdir.shape[1] 72 | upa = streams.upstream_area(idxs_ds, seq, ncol, dtype=np.int32) 73 | # count 74 | n_up = core.upstream_count(idxs_ds, mv=mv) 75 | assert np.sum(n_up[n_up != -9]) == n - idxs_pit.size 76 | # upstream matrix 77 | idxs_us = core.upstream_matrix(idxs_ds, mv=mv) 78 | assert np.sum(idxs_us != mv) == seq.size - idxs_pit.size 79 | # ordered 80 | seq2 = core.idxs_seq(idxs_ds, idxs_pit, mv=mv) 81 | assert np.all(np.diff(rank.flat[seq2]) >= 0) 82 | # headwater 83 | idxs_headwater = core.headwater_indices(idxs_ds, mv=mv) 84 | assert np.all(n_up[idxs_headwater] == 0) 85 | if np.any(n_up > 0): 86 | # local upstream indices 87 | idx0 = np.where(upa == np.max(upa))[0][0] 88 | idxs_us0 = np.sort(core._upstream_d8_idx(idx0, idxs_ds, flwdir.shape)) 89 | idxs_us1 = np.sort(idxs_us[idx0, : n_up[idx0]]) 90 | assert np.all(idxs_us1 == idxs_us0) 91 | # main upstream 92 | idxs_us_main = core.main_upstream(idxs_ds, upa, mv=mv) 93 | assert np.any(idxs_us0 == idxs_us_main[idx0]) 94 | idxs = np.where(idxs_us_main != mv)[0] 95 | assert np.all(idxs_ds[idxs_us_main[idxs]] == idxs) 96 | assert idxs.size == np.sum(n_up[upa > 0] >= 1) 97 | # window 98 | path = core._trace(idx0, idxs_us_main, ncol, mv=mv)[0] 99 | wdw = core._window(idx0, 1, idxs_us_main, idxs_us_main, mv=mv) 100 | assert np.all(path[:2] == wdw[1:]) and np.all(path[:2][::-1] == wdw[:-1]) 101 | # # tributary 102 | # idxs_us_trib = core.main_tributary(idxs_ds, idxs_us_main, upa, mv=mv) 103 | # idxs = np.where(idxs_us_trib != mv)[0] 104 | # assert idxs.size == np.sum(n_up[upa > 0] > 1) 105 | # if idxs.size > 0: 106 | # assert np.all(idxs_ds[idxs_us_main[idxs]] == idxs) 107 | # # tributaries 108 | # idxs_trib = core._tributaries(idx0, idxs_us_main, idxs_us_trib, upa, mv=mv) 109 | # assert np.all([np.any(idx == idxs_us_trib[path]) for idx in idxs_trib]) 110 | # if idxs_trib.size > 1: 111 | # idxs_trib1 = core._tributaries( 112 | # idx0, idxs_us_main, idxs_us_trib, upa, n=1, mv=mv 113 | # ) 114 | # assert np.max(upa[idxs_trib]) == upa[idxs_trib1] 115 | -------------------------------------------------------------------------------- /examples/elevation_indices.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Elevation indices" 8 | ] 9 | }, 10 | { 11 | "cell_type": "markdown", 12 | "metadata": {}, 13 | "source": [ 14 | "Here we assume that flow directions are known. We read the flow direction raster data, including meta-data, using [rasterio](https://rasterio.readthedocs.io/en/latest/) and parse it to a pyflwdir `FlwDirRaster` object, see earlier examples for more background." 15 | ] 16 | }, 17 | { 18 | "cell_type": "code", 19 | "execution_count": null, 20 | "metadata": {}, 21 | "outputs": [], 22 | "source": [ 23 | "# import pyflwdir, some dependencies and convenience methods\n", 24 | "import numpy as np\n", 25 | "import rasterio\n", 26 | "import pyflwdir\n", 27 | "\n", 28 | "# local convenience methods (see utils.py script in notebooks folder)\n", 29 | "from utils import quickplot, plt # data specific quick plot method\n", 30 | "\n", 31 | "# read and parse flow direciton data\n", 32 | "with rasterio.open(\"rhine_d8.tif\", \"r\") as src:\n", 33 | " flwdir = src.read(1)\n", 34 | " crs = src.crs\n", 35 | " extent = np.array(src.bounds)[[0, 2, 1, 3]]\n", 36 | " flw = pyflwdir.from_array(\n", 37 | " flwdir,\n", 38 | " ftype=\"d8\",\n", 39 | " transform=src.transform,\n", 40 | " latlon=crs.is_geographic,\n", 41 | " cache=True,\n", 42 | " )\n", 43 | "# read elevation data\n", 44 | "with rasterio.open(\"rhine_elv0.tif\", \"r\") as src:\n", 45 | " elevtn = src.read(1)" 46 | ] 47 | }, 48 | { 49 | "cell_type": "markdown", 50 | "metadata": {}, 51 | "source": [ 52 | "## height above nearest drain (HAND)" 53 | ] 54 | }, 55 | { 56 | "cell_type": "markdown", 57 | "metadata": {}, 58 | "source": [ 59 | "The [hand()](https://deltares.github.io/pyflwdir/latest/_generated/pyflwdir.FlwdirRaster.hand.html) method uses drainage-normalized topography and flowpaths to delineate the relative vertical distances (drop) to the nearest river (drain) as a proxy for the potential extent of flooding ([Nobre et al. 2016](https://doi.org/10.1002/hyp.10581)). The pyflwdir implementation requires stream mask `drain` and elevation raster `elevtn`. The stream mask is typically determined based on a threshold on [upstream_area()](https://deltares.github.io/pyflwdir/latest/_generated/pyflwdir.FlwdirRaster.upstream_area.html) or [stream_order()](https://deltares.github.io/pyflwdir/latest/_generated/pyflwdir.FlwdirRaster.stream_order.html), but can also be set from rasterizing a vector stream file." 60 | ] 61 | }, 62 | { 63 | "cell_type": "code", 64 | "execution_count": null, 65 | "metadata": {}, 66 | "outputs": [], 67 | "source": [ 68 | "# first we derive the upstream area map\n", 69 | "uparea = flw.upstream_area(\"km2\")" 70 | ] 71 | }, 72 | { 73 | "cell_type": "code", 74 | "execution_count": null, 75 | "metadata": {}, 76 | "outputs": [], 77 | "source": [ 78 | "# HAND based on streams defined by a minimal upstream area of 1000 km2\n", 79 | "hand = flw.hand(drain=uparea > 1000, elevtn=elevtn)\n", 80 | "# plot\n", 81 | "ax = quickplot(title=\"Height above nearest drain (HAND)\")\n", 82 | "im = ax.imshow(\n", 83 | " np.ma.masked_equal(hand, -9999),\n", 84 | " extent=extent,\n", 85 | " cmap=\"gist_earth_r\",\n", 86 | " alpha=0.5,\n", 87 | " vmin=0,\n", 88 | " vmax=150,\n", 89 | ")\n", 90 | "fig = plt.gcf()\n", 91 | "cax = fig.add_axes([0.82, 0.37, 0.02, 0.12])\n", 92 | "fig.colorbar(im, cax=cax, orientation=\"vertical\")\n", 93 | "cax.set_ylabel(\"HAND [m]\")\n", 94 | "plt.savefig(\"hand.png\")" 95 | ] 96 | }, 97 | { 98 | "cell_type": "markdown", 99 | "metadata": {}, 100 | "source": [ 101 | "## Floodplains" 102 | ] 103 | }, 104 | { 105 | "cell_type": "markdown", 106 | "metadata": {}, 107 | "source": [ 108 | "The [floodplains()](https://deltares.github.io/pyflwdir/latest/_generated/pyflwdir.FlwdirRaster.floodplains.html) method delineates geomorphic floodplain boundaries based on a power-law relation between upstream area and a maximum HAND contour as developed by [Nardi et al (2019)](http://www.doi.org/10.1038/sdata.2018.309). Here, streams are defined based on a minimum upstream area threshold `upa_min` and floodplains on the scaling parameter `b` of the power-law relationship." 109 | ] 110 | }, 111 | { 112 | "cell_type": "code", 113 | "execution_count": null, 114 | "metadata": {}, 115 | "outputs": [], 116 | "source": [ 117 | "floodplains = flw.floodplains(elevtn=elevtn, uparea=uparea, upa_min=1000)\n", 118 | "# plot\n", 119 | "floodmap = (floodplains, -1, dict(cmap=\"Blues\", alpha=0.5, vmin=0))\n", 120 | "ax = quickplot(\n", 121 | " raster=floodmap, title=\"Geomorphic floodplains\", filename=\"flw_floodplain\"\n", 122 | ")" 123 | ] 124 | } 125 | ], 126 | "metadata": { 127 | "kernelspec": { 128 | "display_name": "Python 3.10.5 ('hydromt-dev')", 129 | "language": "python", 130 | "name": "python3" 131 | }, 132 | "language_info": { 133 | "codemirror_mode": { 134 | "name": "ipython", 135 | "version": 3 136 | }, 137 | "file_extension": ".py", 138 | "mimetype": "text/x-python", 139 | "name": "python", 140 | "nbconvert_exporter": "python", 141 | "pygments_lexer": "ipython3", 142 | "version": "3.10.5" 143 | }, 144 | "vscode": { 145 | "interpreter": { 146 | "hash": "3808d5b5b54949c7a0a707a38b0a689040fa9c90ab139a050e41373880719ab1" 147 | } 148 | } 149 | }, 150 | "nbformat": 4, 151 | "nbformat_minor": 4 152 | } 153 | -------------------------------------------------------------------------------- /examples/tracing.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Tracing flow directions" 8 | ] 9 | }, 10 | { 11 | "cell_type": "markdown", 12 | "metadata": {}, 13 | "source": [ 14 | "Here we assume that flow directions are known. We read the flow direction raster data, including meta-data, using [rasterio](https://rasterio.readthedocs.io/en/latest/) and parse it to a pyflwdir `FlwDirRaster` object, see earlier examples for more background." 15 | ] 16 | }, 17 | { 18 | "cell_type": "code", 19 | "execution_count": null, 20 | "metadata": {}, 21 | "outputs": [], 22 | "source": [ 23 | "# import pyflwdir, some dependencies and convenience methods\n", 24 | "import geopandas as gpd\n", 25 | "import numpy as np\n", 26 | "import rasterio\n", 27 | "import pyflwdir\n", 28 | "\n", 29 | "# local convenience methods (see utils.py script in notebooks folder)\n", 30 | "from utils import quickplot # data specific quick plot method\n", 31 | "\n", 32 | "# read and parse data\n", 33 | "with rasterio.open(\"rhine_d8.tif\", \"r\") as src:\n", 34 | " flwdir = src.read(1)\n", 35 | " crs = src.crs\n", 36 | " flw = pyflwdir.from_array(\n", 37 | " flwdir,\n", 38 | " ftype=\"d8\",\n", 39 | " transform=src.transform,\n", 40 | " latlon=crs.is_geographic,\n", 41 | " cache=True,\n", 42 | " )" 43 | ] 44 | }, 45 | { 46 | "cell_type": "markdown", 47 | "metadata": {}, 48 | "source": [ 49 | "## Flow paths" 50 | ] 51 | }, 52 | { 53 | "cell_type": "markdown", 54 | "metadata": {}, 55 | "source": [ 56 | "To trace flow paths downstream from a point, for instance to trace polutants from a \n", 57 | "point source, we can use the [path()](https://deltares.github.io/pyflwdir/latest/_generated/pyflwdir.FlwdirRaster.path.html) method. Here \n", 58 | "we trace three point sources along a maximum distance of 400 km. " 59 | ] 60 | }, 61 | { 62 | "cell_type": "code", 63 | "execution_count": null, 64 | "metadata": {}, 65 | "outputs": [], 66 | "source": [ 67 | "# flow paths return the list of linear indices\n", 68 | "xy = ([8.92, 5.55, 8.50], [50.28, 49.80, 47.3])\n", 69 | "flowpaths, dists = flw.path(xy=xy, max_length=400e3, unit=\"m\")\n", 70 | "# note that all distances are nearly at the threshold\n", 71 | "dists / 1e3" 72 | ] 73 | }, 74 | { 75 | "cell_type": "code", 76 | "execution_count": null, 77 | "metadata": {}, 78 | "outputs": [], 79 | "source": [ 80 | "# derive streams for visualization\n", 81 | "streams_feat = flw.streams(min_sto=6)\n", 82 | "gdf_streams = gpd.GeoDataFrame.from_features(streams_feat, crs=crs)\n", 83 | "streams = (gdf_streams, dict(color=\"grey\"))" 84 | ] 85 | }, 86 | { 87 | "cell_type": "code", 88 | "execution_count": null, 89 | "metadata": {}, 90 | "outputs": [], 91 | "source": [ 92 | "# which we than use to vectorize to geofeatures\n", 93 | "feats = flw.geofeatures(flowpaths)\n", 94 | "gdf_paths = gpd.GeoDataFrame.from_features(feats, crs=crs).reset_index()\n", 95 | "gdf_pnts = gpd.GeoDataFrame(geometry=gpd.points_from_xy(*xy)).reset_index()\n", 96 | "# and plot\n", 97 | "pnt = (gdf_pnts, dict(column=\"index\", cmap=\"tab10\", s=60, marker=\"<\", zorder=4))\n", 98 | "fp = (gdf_paths, dict(column=\"index\", cmap=\"tab10\", linewidth=2))\n", 99 | "title = \"Flow path from source points (<) with max. distance of 400 km)\"\n", 100 | "ax = quickplot([streams, fp, pnt], title=title, filename=\"flw_path\")" 101 | ] 102 | }, 103 | { 104 | "cell_type": "markdown", 105 | "metadata": {}, 106 | "source": [ 107 | "## Snap points to stream" 108 | ] 109 | }, 110 | { 111 | "cell_type": "markdown", 112 | "metadata": {}, 113 | "source": [ 114 | "With the [snap()](https://deltares.github.io/pyflwdir/latest/_generated/pyflwdir.FlwdirRaster.snap.html) method we can find the nearest downstream stream for any given point and calculate\n", 115 | "the distance to this point." 116 | ] 117 | }, 118 | { 119 | "cell_type": "code", 120 | "execution_count": null, 121 | "metadata": {}, 122 | "outputs": [], 123 | "source": [ 124 | "# find nearest stream order 8 stream\n", 125 | "idxs1, dists = flw.snap(xy=xy, mask=flw.stream_order() >= 8, unit=\"m\")\n", 126 | "# convert index to coordinates and Point Geo\n", 127 | "xy1 = flw.xy(idxs1)\n", 128 | "gdf_pnts1 = gpd.GeoDataFrame(geometry=gpd.points_from_xy(*xy1), crs=crs).reset_index()\n", 129 | "# print end locations\n", 130 | "print([f\"({x:.3f}, {y:.3f})\" for x, y in zip(*xy1)])" 131 | ] 132 | }, 133 | { 134 | "cell_type": "code", 135 | "execution_count": null, 136 | "metadata": {}, 137 | "outputs": [], 138 | "source": [ 139 | "# plot\n", 140 | "pnt1 = (gdf_pnts1, dict(column=\"index\", cmap=\"tab10\", s=60, marker=\"o\", zorder=4))\n", 141 | "streams = (gdf_streams, dict(color=\"grey\"))\n", 142 | "title = \"Snap points (<) to nearest stream order 8 stream (o).\"\n", 143 | "ax = quickplot([streams, pnt1, pnt], title=title, filename=\"flw_snap\")" 144 | ] 145 | } 146 | ], 147 | "metadata": { 148 | "kernelspec": { 149 | "display_name": "Python 3.10.5 ('hydromt-dev')", 150 | "language": "python", 151 | "name": "python3" 152 | }, 153 | "language_info": { 154 | "codemirror_mode": { 155 | "name": "ipython", 156 | "version": 3 157 | }, 158 | "file_extension": ".py", 159 | "mimetype": "text/x-python", 160 | "name": "python", 161 | "nbconvert_exporter": "python", 162 | "pygments_lexer": "ipython3", 163 | "version": "3.10.5" 164 | }, 165 | "vscode": { 166 | "interpreter": { 167 | "hash": "3808d5b5b54949c7a0a707a38b0a689040fa9c90ab139a050e41373880719ab1" 168 | } 169 | } 170 | }, 171 | "nbformat": 4, 172 | "nbformat_minor": 4 173 | } 174 | -------------------------------------------------------------------------------- /tests/test_dem.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Tests for the pyflwdir.dem module.""" 3 | 4 | import numpy as np 5 | import pytest 6 | 7 | from pyflwdir import dem, subgrid 8 | 9 | 10 | @pytest.mark.parametrize("dtype", [np.float32, np.int32]) 11 | def test_from_dem(dtype): 12 | # example from Wang & Lui (2015) 13 | a = np.array( 14 | [ 15 | [15, 15, 14, 15, 12, 6, 12], 16 | [14, 13, 10, 12, 15, 17, 15], 17 | [15, 15, 9, 11, 8, 15, 15], 18 | [16, 17, 8, 16, 15, 7, 5], 19 | [19, 18, 19, 18, 17, 15, 14], 20 | ], 21 | dtype=dtype, 22 | ) 23 | # NOTE: compared to paper same a_filled, but difference 24 | # in flowdir because row instead of col first .. 25 | # and priority of non-boundary cells with same elevation 26 | d8 = np.array( 27 | [ 28 | [2, 2, 4, 8, 1, 0, 16], 29 | [1, 1, 2, 2, 128, 64, 32], 30 | [128, 128, 1, 1, 2, 2, 4], 31 | [128, 128, 128, 128, 1, 1, 0], 32 | [64, 128, 64, 32, 128, 128, 64], 33 | ], 34 | dtype=np.uint8, 35 | ) 36 | # test default 37 | a2 = a.copy() 38 | a2[1:4, 2] = 11 # filled depression 39 | a_filled, _d8 = dem.fill_depressions(a) 40 | assert np.all(a_filled == a2) 41 | assert np.all(d8 == _d8) 42 | # test single outlet 43 | a2[0, 5] = 12 44 | a_filled = dem.fill_depressions(a, outlets="min")[0] 45 | assert np.all(a2 == a_filled) 46 | # test with nodata values 47 | a2 = a.copy() 48 | a2[3, 5:] = -9999 49 | _d8 = dem.fill_depressions(a2)[1] 50 | assert np.all(_d8[3, 5:] == 247) 51 | assert _d8[2, 4] == 0 52 | # test max depth 53 | _a = dem.fill_depressions(a, max_depth=2)[0] 54 | assert np.all(a == _a) 55 | # test with 4-connectivity 56 | a2 = np.array( 57 | [ 58 | [15, 15, 14, 15, 12, 6, 12], 59 | [14, 14, 14, 14, 15, 17, 15], 60 | [15, 15, 14, 14, 14, 15, 15], 61 | [16, 17, 14, 16, 15, 7, 5], 62 | [19, 18, 19, 18, 17, 15, 14], 63 | ], 64 | dtype=np.float32, 65 | ) 66 | a_filled, _d8 = dem.fill_depressions(a, connectivity=4) 67 | assert np.all(a_filled == a2) 68 | assert np.all(np.isin(np.unique(_d8), [0, 1, 4, 16, 64])) 69 | 70 | 71 | def test_dem_adjust(): 72 | # option 1 dig 73 | dem0 = np.array([8, 7, 6, 5, 5, 6, 5, 4]) 74 | dem1 = np.array([8, 7, 6, 5, 5, 5, 5, 4]) 75 | assert np.all(dem._adjust_elevation(dem0) == dem1) 76 | # option 2 fill 77 | dem0 = np.array([8, 7, 6, 5, 6, 6, 5, 4]) 78 | dem1 = np.array([8, 7, 6, 6, 6, 6, 5, 4]) 79 | assert np.all(dem._adjust_elevation(dem0) == dem1) 80 | # option 3 dig and fill 81 | dem0 = np.array([8, 7, 6, 5, 6, 7, 5, 4]) 82 | dem1 = np.array([8, 7, 6, 6, 6, 6, 5, 4]) 83 | assert np.all(dem._adjust_elevation(dem0) == dem1) 84 | dem0 = np.array([84, 19, 5, 26, 34, 4]) 85 | dem1 = np.array([84, 26, 26, 26, 26, 4]) 86 | assert np.all(dem._adjust_elevation(dem0) == dem1) 87 | dem0 = np.array([46, 26, 5, 20, 23, 21, 5]) 88 | dem1 = np.array([46, 26, 21, 21, 21, 21, 5]) 89 | assert np.all(dem._adjust_elevation(dem0) == dem1) 90 | # with large z on last position 91 | dem0 = np.array([8, 7, 6, 6, 6, 6, 5, 7]) 92 | dem1 = np.array([8, 7, 7, 7, 7, 7, 7, 7]) 93 | assert np.all(dem._adjust_elevation(dem0) == dem1) 94 | # pit at first value 95 | dem0 = np.array([5, 41, 15]) 96 | dem1 = np.array([15, 15, 15]) 97 | assert np.all(dem._adjust_elevation(dem0) == dem1) 98 | # multiple pits 99 | dem0 = np.array([60, 13, 54, 37, 49, 27, 22, 19, 42, 33, 40, 36, 7, 32, 8, 8, 2, 1]) 100 | dem1 = np.array([60, 54, 54, 37, 37, 27, 22, 19, 19, 19, 19, 19, 8, 8, 8, 8, 2, 1]) 101 | assert np.all(dem._adjust_elevation(dem0) == dem1) 102 | 103 | 104 | # TODO: extend test 105 | def test_slope(): 106 | elv = np.ones((4, 4)) 107 | nodata = -9999 108 | assert np.all(dem.slope(elv, nodata) == 0) 109 | elv.flat[0] == -9999 110 | assert np.all(dem.slope(elv, nodata).flat[1:] == 0) 111 | elv = np.random.random((4, 4)) 112 | assert np.all(dem.slope(elv, nodata).flat[1:] >= 0) 113 | elv = np.random.random((1, 1)) 114 | assert np.all(dem.slope(elv, nodata) == 0) 115 | 116 | 117 | def test_hand_fldpln(test_data0, flwdir0): 118 | idxs_ds, _, seq, rank, _ = test_data0 119 | elevtn = rank # dz along flow path is 1 120 | drain = rank == 0 # only outlets are 121 | # hand == elevtn 122 | hand = dem.height_above_nearest_drain(idxs_ds, seq, drain, elevtn) 123 | assert np.all(hand == elevtn) 124 | # subgrid floodplain volume 125 | idxs_out = np.where(drain.ravel())[0] 126 | area = np.ones(idxs_ds.size, dtype=np.int32) 127 | depths = np.linspace(1, hand.max(), 5) 128 | drain_map, fldpln_vol = subgrid.ucat_volume( 129 | idxs_out, idxs_ds, seq, hand, area, depths=depths 130 | ) 131 | assert fldpln_vol.shape == (*depths.shape, drain_map.max()) 132 | assert np.all(np.diff(fldpln_vol, axis=0) > 0) 133 | # max h = 1 134 | uparea = np.where(drain, 1, 0) 135 | upa_min = 1 136 | fldpln = dem.floodplains(idxs_ds, seq, elevtn, uparea, upa_min=upa_min, b=0) 137 | assert np.all(fldpln[elevtn > 1] == 0) 138 | assert np.all(fldpln[elevtn <= 1] != 0) 139 | # max h = uparea 140 | fldpln = dem.floodplains(idxs_ds, seq, elevtn, uparea, upa_min=upa_min, b=1) 141 | hmax = uparea[drain].max() 142 | hmin = uparea[drain].min() 143 | assert np.all(fldpln[elevtn > hmax] == 0) 144 | assert np.all(fldpln[elevtn < hmin] != 0) 145 | # hand == 1 for non-drain cells 146 | elevtn = np.ones(flwdir0.size) 147 | elevtn[drain] = 0 148 | hand = dem.height_above_nearest_drain(idxs_ds, seq, drain, elevtn) 149 | assert np.all(hand[rank > 0] == 1) 150 | # fldpln == 1 for all cells 151 | fldpln = dem.floodplains(idxs_ds, seq, elevtn, uparea, upa_min=upa_min, b=1) 152 | assert np.all(fldpln[rank >= 0] == 1) 153 | -------------------------------------------------------------------------------- /examples/flwdir.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Flow direction data" 8 | ] 9 | }, 10 | { 11 | "cell_type": "markdown", 12 | "metadata": {}, 13 | "source": [ 14 | "The `FlwdirRaster` object is at the core of the pyflwdir package.\n", 15 | "It contains gridded flow direction data, parsed to an actionable common format\n", 16 | "which describes the linear index of the next dowsntream cell.\n", 17 | "\n", 18 | "Currently we support two local flow direction (D8) data types according to the arcgis **D8** and pcraster **LDD** conventions (see figure), and one global flow direction type according to the CaMa-Flood **NEXTXY** convention. Local flow direction data types describe the next downstream cell based on a relative direction from a cell towards one of its neighboring cells, while global flow direction types describe the next downstream cell based on its row and column indices. " 19 | ] 20 | }, 21 | { 22 | "cell_type": "markdown", 23 | "metadata": {}, 24 | "source": [ 25 | "\n", 26 | "" 27 | ] 28 | }, 29 | { 30 | "cell_type": "markdown", 31 | "metadata": {}, 32 | "source": [ 33 | "We read the flow direction raster data, including meta-data, using [rasterio](https://rasterio.readthedocs.io/en/latest/)" 34 | ] 35 | }, 36 | { 37 | "cell_type": "code", 38 | "execution_count": null, 39 | "metadata": {}, 40 | "outputs": [], 41 | "source": [ 42 | "import rasterio\n", 43 | "\n", 44 | "with rasterio.open(\"rhine_d8.tif\", \"r\") as src:\n", 45 | " flwdir = src.read(1)\n", 46 | " transform = src.transform\n", 47 | " crs = src.crs\n", 48 | " latlon = crs.to_epsg() == 4326" 49 | ] 50 | }, 51 | { 52 | "cell_type": "markdown", 53 | "metadata": {}, 54 | "source": [ 55 | "Next, we parse this data to a `FlwdirRaster` object, the core object \n", 56 | "to work with flow direction data. In this step the D8 data is parsed to an actionable format." 57 | ] 58 | }, 59 | { 60 | "cell_type": "markdown", 61 | "metadata": {}, 62 | "source": [ 63 | "
\n", 64 | "\n", 65 | "NOTE: that for most methods a first call might be a bit slow as the numba code is compiled just in time, a second call of the same methods (also with different arguments) will be much faster!\n", 66 | " \n", 67 | "
\n" 68 | ] 69 | }, 70 | { 71 | "cell_type": "code", 72 | "execution_count": null, 73 | "metadata": {}, 74 | "outputs": [], 75 | "source": [ 76 | "import pyflwdir\n", 77 | "\n", 78 | "flw = pyflwdir.from_array(\n", 79 | " flwdir, ftype=\"d8\", transform=transform, latlon=latlon, cache=True\n", 80 | ")" 81 | ] 82 | }, 83 | { 84 | "cell_type": "code", 85 | "execution_count": null, 86 | "metadata": {}, 87 | "outputs": [], 88 | "source": [ 89 | "# When printing the FlwdirRaster instance we see its attributes.\n", 90 | "print(flw)" 91 | ] 92 | }, 93 | { 94 | "cell_type": "markdown", 95 | "metadata": {}, 96 | "source": [ 97 | "We can than make use of the many methods of the `FlwdirRaster` object, see \n", 98 | "[FlwdirRaster API](https://deltares.github.io/pyflwdir/latest/_generated/pyflwdir.FlwdirRaster.html)." 99 | ] 100 | }, 101 | { 102 | "cell_type": "markdown", 103 | "metadata": {}, 104 | "source": [ 105 | "To visualize the flow directions we derive the stream network as vector using the [streams()](https://deltares.github.io/pyflwdir/latest/_generated/pyflwdir.FlwdirRaster.streams.html) method.\n", 106 | "Each line element respresnets a stream segment with a minimal Strahler stream order of `min_sto`, as computed by [stream_order()](https://deltares.github.io/pyflwdir/latest/_generated/pyflwdir.FlwdirRaster.stream_order.html).\n", 107 | "The line elements (geo features) are parsed to a [GeoDataFrame](https://geopandas.org/data_structures.html#geodataframe) object for visualization." 108 | ] 109 | }, 110 | { 111 | "cell_type": "code", 112 | "execution_count": null, 113 | "metadata": {}, 114 | "outputs": [], 115 | "source": [ 116 | "import geopandas as gpd\n", 117 | "\n", 118 | "feats = flw.streams(min_sto=4)\n", 119 | "gdf = gpd.GeoDataFrame.from_features(feats, crs=crs)\n", 120 | "gdf.head()" 121 | ] 122 | }, 123 | { 124 | "cell_type": "code", 125 | "execution_count": null, 126 | "metadata": {}, 127 | "outputs": [], 128 | "source": [ 129 | "# plot\n", 130 | "import numpy as np\n", 131 | "\n", 132 | "# local convenience methods (see utils.py script in notebooks folder)\n", 133 | "from utils import quickplot, colors, cm # data specific quick plot method\n", 134 | "\n", 135 | "# key-word arguments passed to GeoDataFrame.plot method\n", 136 | "gdf_plot_kwds = dict(\n", 137 | " column=\"strord\", cmap=colors.ListedColormap(cm.Blues(np.linspace(0.4, 1, 7)))\n", 138 | ")\n", 139 | "# plot streams with hillshade from elevation data (see utils.py)\n", 140 | "ax = quickplot(gdfs=[(gdf, gdf_plot_kwds)], title=\"Streams\")" 141 | ] 142 | } 143 | ], 144 | "metadata": { 145 | "kernelspec": { 146 | "display_name": "Python 3.10.5 ('hydromt-dev')", 147 | "language": "python", 148 | "name": "python3" 149 | }, 150 | "language_info": { 151 | "codemirror_mode": { 152 | "name": "ipython", 153 | "version": 3 154 | }, 155 | "file_extension": ".py", 156 | "mimetype": "text/x-python", 157 | "name": "python", 158 | "nbconvert_exporter": "python", 159 | "pygments_lexer": "ipython3", 160 | "version": "3.10.5" 161 | }, 162 | "vscode": { 163 | "interpreter": { 164 | "hash": "3808d5b5b54949c7a0a707a38b0a689040fa9c90ab139a050e41373880719ab1" 165 | } 166 | } 167 | }, 168 | "nbformat": 4, 169 | "nbformat_minor": 4 170 | } 171 | -------------------------------------------------------------------------------- /tests/test_gis_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Tests for the pyflwdir.gis_utils module.""" 3 | 4 | import numpy as np 5 | import pytest 6 | from affine import Affine 7 | 8 | from pyflwdir import gis_utils as gis 9 | 10 | # glob total area 11 | glob_area = 4 * np.pi * gis._R**2 12 | glob_circ = 2 * np.pi * gis._R 13 | 14 | 15 | ## TRANSFORM 16 | # Adapted from https://github.com/mapbox/rasterio/blob/master/tests/test_transform.py 17 | prof = { 18 | "width": 6120, 19 | "height": 4920, 20 | "res": 1 / 1200.0, 21 | "bounds": [-10.5, 51.4, -5.4, 55.5], 22 | "transform": Affine(1 / 1200.0, 0.0, -10.5, 0.0, -1 / 1200.0, 55.5), 23 | } 24 | 25 | 26 | def test_from_origin(): 27 | w, _, _, n = prof["bounds"] 28 | tr = gis.transform_from_origin(w, n, prof["res"], prof["res"]) 29 | assert [round(v, 7) for v in tr] == [round(v, 7) for v in prof["transform"]] 30 | 31 | 32 | def test_from_bounds(): 33 | w, s, e, n = prof["bounds"] 34 | tr = gis.transform_from_bounds(w, s, e, n, prof["width"], prof["height"]) 35 | assert [round(v, 7) for v in tr] == [round(v, 7) for v in prof["transform"]] 36 | 37 | 38 | def test_array_bounds(): 39 | bounds0 = np.asarray(prof["bounds"]) 40 | bounds = gis.array_bounds(prof["height"], prof["width"], prof["transform"]) 41 | assert np.all(bounds0 == np.asarray(bounds).round(7)) 42 | 43 | 44 | def test_xy(): 45 | aff = prof["transform"] 46 | ul_x, ul_y = aff * (0, 0) 47 | xoff = aff.a 48 | yoff = aff.e 49 | assert gis.xy(aff, 0, 0, offset="ul") == (ul_x, ul_y) 50 | assert gis.xy(aff, 0, 0, offset="ur") == (ul_x + xoff, ul_y) 51 | assert gis.xy(aff, 0, 0, offset="ll") == (ul_x, ul_y + yoff) 52 | expected = (ul_x + xoff, ul_y + yoff) 53 | assert gis.xy(aff, 0, 0, offset="lr") == expected 54 | expected = (ul_x + xoff / 2, ul_y + yoff / 2) 55 | assert gis.xy(aff, 0, 0, offset="center") == expected 56 | assert ( 57 | gis.xy(aff, 0, 0, offset="lr") 58 | == gis.xy(aff, 0, 1, offset="ll") 59 | == gis.xy(aff, 1, 1, offset="ul") 60 | == gis.xy(aff, 1, 0, offset="ur") 61 | ) 62 | 63 | 64 | def test_rowcol(): 65 | aff = gis.IDENTITY # N->S changed in version 0.5 66 | left, bottom, right, top = (0, -200, 100, 0) 67 | assert gis.rowcol(aff, left, top) == (top, left) 68 | assert gis.rowcol(aff, right, top) == (top, right) 69 | assert gis.rowcol(aff, right, bottom) == (-bottom, right) 70 | assert gis.rowcol(aff, left, bottom) == (-bottom, left) 71 | 72 | 73 | def test_idxs_to_coords(): 74 | shape = (10, 8) 75 | idxs = np.arange(shape[0] * shape[1]).reshape(shape) 76 | transform = gis.Affine(1.0, 0.0, 0.0, 0.0, 1.0, 0.0) 77 | xs, ys = gis.idxs_to_coords(idxs, transform, shape) 78 | assert np.all(ys == (np.arange(shape[0]) + 0.5)[:, None]) 79 | assert np.all(xs == np.arange(shape[1]) + 0.5) 80 | with pytest.raises(IndexError): 81 | gis.idxs_to_coords(np.array([-1]), transform, shape) 82 | 83 | 84 | def test_coords_to_idxs(): 85 | shape = (10, 8) 86 | idxs0 = np.arange(shape[0] * shape[1]) 87 | transform = gis.Affine(1.0, 0.0, 0.0, 0.0, 1.0, 0.0) 88 | xs, ys = np.meshgrid(np.arange(shape[1]) + 0.5, np.arange(shape[0]) + 0.5) 89 | idxs = gis.coords_to_idxs(xs, ys, transform, shape) 90 | assert np.all(idxs.ravel() == idxs0) 91 | with pytest.raises(IndexError): 92 | gis.coords_to_idxs(ys, xs, transform, shape) 93 | 94 | 95 | def test_affine_to_coords(): 96 | shape = (10, 8) 97 | transform = gis.Affine(1.0, 0.0, 0.0, 0.0, 1.0, 0.0) 98 | xs, ys = gis.affine_to_coords(transform, shape) 99 | assert np.all(ys == np.arange(shape[0]) + 0.5) 100 | assert np.all(xs == np.arange(shape[1]) + 0.5) 101 | 102 | 103 | def test_reggrid_dx(): 104 | # also tests degree_metres_x 105 | # area of glob in 1 degree cells 106 | lats = np.array([0.0]) 107 | lons = np.arange(-179.5, 180) 108 | dx = gis.reggrid_dx(lats, lons) 109 | assert dx.shape == (lats.size, lons.size) 110 | assert dx.sum().round(3) == 40075004.88 111 | 112 | 113 | def test_reggrid_dy(): 114 | # also tests degree_metres_y 115 | # area of glob in 1 degree cells 116 | lats = np.arange(-89.5, 90) 117 | lons = np.array([0.0]) 118 | dy = gis.reggrid_dy(lats, lons) 119 | assert dy.shape == (lats.size, lons.size) 120 | assert dy.sum().round(3) == 20003925.600 121 | 122 | 123 | def test_cellarea(): 124 | # area of whole sphere 125 | assert gis.cellarea(0, 360, 180) == glob_area 126 | # area of 1 degree cell 127 | assert gis.cellarea(0, 1, 1) == 12364154779.389229 128 | 129 | 130 | def test_reggrid_area(): 131 | # area of glob in 1 degree cells 132 | lats = np.arange(-89.5, 90) 133 | lons = np.arange(-179.5, 180) 134 | assert gis.reggrid_area(lats, lons).sum().round() == np.round(glob_area) 135 | 136 | 137 | def test_distance(): 138 | # transform=gis.IDENTITY 139 | assert gis.distance(0, 1, 3) == 1 # horizontal 140 | assert gis.distance(0, 3, 3) == 1 # vertical 141 | assert gis.distance(4, 0, 3) == np.hypot(1, 1) # diagonal 142 | assert gis.distance(0, 4, 3, True) == gis.distance(4, 0, 3, True) 143 | assert gis.distance(0, 1, 3, False) == gis.distance(7, 8, 3, False) 144 | assert gis.distance(0, 1, 3, True) != gis.distance(7, 8, 3, True) 145 | 146 | 147 | def test_edge(): 148 | a = np.ones((5, 5), dtype=bool) 149 | b = a.copy() 150 | b[1:-1, 1:-1] = False 151 | assert np.all(gis.get_edge(a) == b) 152 | a[np.diag_indices(5)] = False 153 | assert np.all(gis.get_edge(a) == a) 154 | b = a.copy() 155 | b[1, 3], b[3, 1] = False, False 156 | d4 = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype=bool) 157 | assert np.all(gis.get_edge(a, structure=d4) == b) 158 | 159 | 160 | def test_spread(): 161 | a = np.zeros((5, 5)) 162 | a[2, 2] = 1 163 | out, src, dst = gis.spread2d(a, nodata=0) 164 | assert np.all(out == 1) 165 | assert np.all(src == 12) 166 | assert np.isclose(np.max(dst), 2 * np.hypot(1, 1)) 167 | a[-1, -1] = 2 168 | out, src, dst = gis.spread2d(a, nodata=0, msk=a != 2, latlon=True) 169 | assert np.all(out[a != 2] == 1) 170 | assert np.all(out.flat[src] == out) 171 | assert dst[-1, -1] == 0 172 | -------------------------------------------------------------------------------- /examples/streams.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Stream order" 8 | ] 9 | }, 10 | { 11 | "cell_type": "markdown", 12 | "metadata": {}, 13 | "source": [ 14 | "Here we assume that flow directions are known. We read the flow direction raster data, including meta-data, using [rasterio](https://rasterio.readthedocs.io/en/latest/) and parse it to a pyflwdir `FlwDirRaster` object, see earlier examples for more background." 15 | ] 16 | }, 17 | { 18 | "cell_type": "code", 19 | "execution_count": null, 20 | "metadata": {}, 21 | "outputs": [], 22 | "source": [ 23 | "# import pyflwdir, some dependencies and convenience methods\n", 24 | "import geopandas as gpd\n", 25 | "import numpy as np\n", 26 | "import rasterio\n", 27 | "import pyflwdir\n", 28 | "\n", 29 | "# local convenience methods (see utils.py script in notebooks folder)\n", 30 | "from utils import quickplot, colors, cm # data specific quick plot method\n", 31 | "\n", 32 | "# read and parse data\n", 33 | "with rasterio.open(\"rhine_d8.tif\", \"r\") as src:\n", 34 | " flwdir = src.read(1)\n", 35 | " crs = src.crs\n", 36 | " flw = pyflwdir.from_array(\n", 37 | " flwdir,\n", 38 | " ftype=\"d8\",\n", 39 | " transform=src.transform,\n", 40 | " latlon=crs.is_geographic,\n", 41 | " cache=True,\n", 42 | " )" 43 | ] 44 | }, 45 | { 46 | "cell_type": "markdown", 47 | "metadata": {}, 48 | "source": [ 49 | "## Strahler stream order" 50 | ] 51 | }, 52 | { 53 | "cell_type": "markdown", 54 | "metadata": {}, 55 | "source": [ 56 | "In the **strahler** \"top down\" stream order map, rivers of the first order are\n", 57 | "the most upstream tributaries or head water cells. If two streams of the same\n", 58 | "order merge, the resulting stream has an order of one higher.\n", 59 | "If two rivers with different stream orders merge, the resulting stream is\n", 60 | "given the maximum of the two order." 61 | ] 62 | }, 63 | { 64 | "cell_type": "code", 65 | "execution_count": null, 66 | "metadata": {}, 67 | "outputs": [], 68 | "source": [ 69 | "# first define streams based on an upstream area threshold, here 100 km2\n", 70 | "stream_mask = flw.upstream_area(\"km2\") > 100" 71 | ] 72 | }, 73 | { 74 | "cell_type": "code", 75 | "execution_count": null, 76 | "metadata": {}, 77 | "outputs": [], 78 | "source": [ 79 | "# calculate the stream orders for these streams\n", 80 | "strahler = flw.stream_order(type=\"strahler\", mask=stream_mask)" 81 | ] 82 | }, 83 | { 84 | "cell_type": "code", 85 | "execution_count": null, 86 | "metadata": {}, 87 | "outputs": [], 88 | "source": [ 89 | "# vectorize stream order for plotting\n", 90 | "feats = flw.streams(stream_mask, strord=strahler)\n", 91 | "gdf = gpd.GeoDataFrame.from_features(feats, crs=crs)\n", 92 | "gdf.to_file(\"rhine_strahler.geojson\", driver=\"GeoJSON\")" 93 | ] 94 | }, 95 | { 96 | "cell_type": "code", 97 | "execution_count": null, 98 | "metadata": {}, 99 | "outputs": [], 100 | "source": [ 101 | "# properties passed to the GeoDataFrame.plot method\n", 102 | "gpd_plot_kwds = dict(\n", 103 | " column=\"strord\",\n", 104 | " cmap=colors.ListedColormap(cm.Blues(np.linspace(0.4, 1, 7))),\n", 105 | " legend=True,\n", 106 | " categorical=True,\n", 107 | " legend_kwds=dict(loc=\"lower right\", title=\"Strahler order [-]\"),\n", 108 | ")\n", 109 | "# plot streams with hillshade from elevation data (see utils.py)\n", 110 | "ax = quickplot(\n", 111 | " gdfs=[(gdf, gpd_plot_kwds)], title=\"Strahler order\", filename=\"flw_strord_strahler\"\n", 112 | ")" 113 | ] 114 | }, 115 | { 116 | "cell_type": "markdown", 117 | "metadata": {}, 118 | "source": [ 119 | "## Classic stream order" 120 | ] 121 | }, 122 | { 123 | "cell_type": "markdown", 124 | "metadata": {}, 125 | "source": [ 126 | "In the **classic** \"bottum up\" stream order map, the main river stem has order 1.\n", 127 | "Each tributary is given a number one greater than that of the\n", 128 | "river or stream into which they discharge." 129 | ] 130 | }, 131 | { 132 | "cell_type": "code", 133 | "execution_count": null, 134 | "metadata": {}, 135 | "outputs": [], 136 | "source": [ 137 | "strord = flw.stream_order(type=\"classic\", mask=stream_mask)" 138 | ] 139 | }, 140 | { 141 | "cell_type": "code", 142 | "execution_count": null, 143 | "metadata": {}, 144 | "outputs": [], 145 | "source": [ 146 | "# vectorize stream order for plotting purposes\n", 147 | "feats1 = flw.streams(stream_mask, strord=strord)\n", 148 | "gdf1 = gpd.GeoDataFrame.from_features(feats1, crs=crs)" 149 | ] 150 | }, 151 | { 152 | "cell_type": "code", 153 | "execution_count": null, 154 | "metadata": {}, 155 | "outputs": [], 156 | "source": [ 157 | "# properties passed to the GeoDataFrame.plot method\n", 158 | "gpd_plot_kwds = dict(\n", 159 | " column=\"strord\",\n", 160 | " cmap=colors.ListedColormap(cm.Greens_r(np.linspace(0, 0.8, 6))),\n", 161 | " legend=True,\n", 162 | " categorical=True,\n", 163 | " legend_kwds=dict(loc=\"lower right\", title=\"Stream order [-]\"),\n", 164 | ")\n", 165 | "# plot streams with hillshade from elevation data (see utils.py)\n", 166 | "ax = quickplot(\n", 167 | " gdfs=[(gdf1, gpd_plot_kwds)],\n", 168 | " title=\"Classic stream order\",\n", 169 | " filename=\"flw_strord_classic\",\n", 170 | ")" 171 | ] 172 | } 173 | ], 174 | "metadata": { 175 | "kernelspec": { 176 | "display_name": "default", 177 | "language": "python", 178 | "name": "python3" 179 | }, 180 | "language_info": { 181 | "codemirror_mode": { 182 | "name": "ipython", 183 | "version": 3 184 | }, 185 | "file_extension": ".py", 186 | "mimetype": "text/x-python", 187 | "name": "python", 188 | "nbconvert_exporter": "python", 189 | "pygments_lexer": "ipython3", 190 | "version": "3.11.11" 191 | } 192 | }, 193 | "nbformat": 4, 194 | "nbformat_minor": 2 195 | } 196 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Welcome to the pyflwdir project. All contributions, bug reports, bug fixes, 2 | documentation improvements, enhancements, and ideas are welcome. Here's how we work. 3 | 4 | Code of Conduct 5 | --------------- 6 | 7 | First of all: the pyflwdir project has a code of conduct. Please read the 8 | CODE_OF_CONDUCT.txt file, it's important to all of us. 9 | 10 | Rights 11 | ------ 12 | 13 | The MIT license (see LICENSE.txt) applies to all contributions. 14 | 15 | Issue Conventions 16 | ----------------- 17 | 18 | The pyflwdir issue tracker is for actionable issues. 19 | 20 | Pyflwdir is a relatively new project and highly active. We have bugs, both 21 | known and unknown. 22 | 23 | Please search existing issues, open and closed, before creating a new one. 24 | 25 | Please provide these details as well as tracebacks and relevant logs. Short scripts and 26 | datasets demonstrating the issue are especially helpful! 27 | 28 | Design Principles 29 | ----------------- 30 | 31 | PyFlwDir contains methods to work with hydro- and topography data in Numpy arrays. 32 | 33 | - I/O from the filesystem to numpy arrays is not part of this package, other packages 34 | such as `xarray `__. or 35 | `rasterio `__. 36 | - To accalerate the code we use `numba `__. 37 | - Flow direction data is parsed to a flattened array of linear indices of the next 38 | downstream cell. Based on this general concept many methods can be applied. 39 | 40 | 41 | Git Conventions 42 | --------------- 43 | 44 | After discussing a new proposal or implementation in the issue tracker, you can start 45 | working on the code. You write your code locally in a new branch PyFlwDir repo or in a 46 | branch of a fork. Once you're done with your first iteration, you commit your code and 47 | push to your PyFlwDir repository. 48 | 49 | To create a new branch after you've downloaded the latest changes in the project: 50 | 51 | .. code-block:: console 52 | 53 | $ git pull 54 | $ git checkout -b 55 | 56 | Develop your new code and keep while keeping track of the status and differences using: 57 | 58 | .. code-block:: console 59 | 60 | $ git status 61 | $ git diff 62 | 63 | Add and commit local changes, use clear commit messages and add the number of the 64 | related issue to that (first) commit message: 65 | 66 | .. code-block:: console 67 | 68 | $ git add 69 | $ git commit -m "this is my commit message. Ref #xxx" 70 | 71 | Regularly push local commits to the repository. For a new branch the remote and name 72 | of branch need to be added. 73 | 74 | .. code-block:: console 75 | 76 | $ git push 77 | 78 | When your changes are ready for review, you can merge them into the main codebase with a 79 | merge request. We recommend creating a merge request as early as possible to give other 80 | developers a heads up and to provide an opportunity for valuable early feedback. You 81 | can create a merge request online or by pushing your branch to a feature-branch. 82 | 83 | Code Conventions 84 | ---------------- 85 | 86 | We use `black `__ for standardized code formatting. 87 | 88 | Tests are mandatory for new features. We use `pytest `__. All tests 89 | should go in the tests directory. 90 | 91 | During Continuous Integration testing, several tools will be run to check your code for 92 | based on pytest, but also stylistic errors. 93 | 94 | Development Environment 95 | ----------------------- 96 | 97 | Developing PyFlwDir requires Python >= 3.6. We prefer developing with the most recent 98 | version of Python. We strongly encourage you to develop in a separate conda environment. 99 | All Python dependencies required to develop PyFlwDir can be found in `environment.yml `__. 100 | 101 | Initial Setup 102 | ^^^^^^^^^^^^^ 103 | 104 | First, clone pyflwdir's ``git`` repo and navigate into the repository: 105 | 106 | .. code-block:: console 107 | 108 | $ git clone git@github.com:Deltares/pyflwdir.git 109 | $ cd pyflwdir 110 | 111 | Install pixi from `pixi.sh `__ to manage the development environment. 112 | To install the package in development mode, use the following command: 113 | 114 | .. code-block:: console 115 | 116 | $ pixi install 117 | $ pixi run install-pre-commit 118 | 119 | This will install the package in development mode and install the required dependencies. 120 | 121 | Running the tests 122 | ^^^^^^^^^^^^^^^^^ 123 | 124 | PyFlwDir's tests live in the tests folder and generally match the main package layout. 125 | Test should be run from the tests folder. 126 | 127 | To run the tests, use the following command: 128 | 129 | .. code-block:: console 130 | 131 | $ pixi run test 132 | 133 | To run the tests with coverage, numba needs to be disabled. 134 | This is done by setting the environment variable NUMBA_DISABLE_JIT to 1. 135 | These arguments are combined in the following command: 136 | 137 | .. code-block:: console 138 | 139 | $ pixi run test-cov 140 | 141 | A single test file: 142 | 143 | .. code-block:: console 144 | 145 | $ pixi run python -m pytest --verbose test_pyflwdir.py 146 | 147 | A single test: 148 | 149 | .. code-block:: console 150 | 151 | $ pixi run python -m pytest --verbose test_pyflwdir.py::test_save 152 | 153 | Running code format checks 154 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 155 | 156 | To automatically reformat your code: 157 | 158 | .. code-block:: console 159 | 160 | $ pixi run lint 161 | 162 | Creating a release 163 | ^^^^^^^^^^^^^^^^^^ 164 | 165 | 1. First create a new release on github under https://github.com/Deltares/pyflwdir/releases. We use semantic versioning and describe the release based on the CHANGELOG. 166 | 2. Make sure to update and clean your local git folder. This removes all files which are not tracked by git. 167 | 168 | .. code-block:: console 169 | 170 | $ git pull 171 | $ git clean -xfd 172 | 173 | 3. Build a wheel for the package and check the resulting files in the dist/ directory. 174 | 175 | .. code-block:: console 176 | 177 | $ flit build 178 | $ python -m twine check dist/* 179 | 180 | 4. Then use twine to upload our wheels to pypi. It will prompt you for your username and password. 181 | 182 | .. code-block:: console 183 | 184 | $ twine upload dist/* 185 | -------------------------------------------------------------------------------- /examples/from_dem.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Flow directions from elevation data" 8 | ] 9 | }, 10 | { 11 | "cell_type": "markdown", 12 | "metadata": {}, 13 | "source": [ 14 | "Flow directions are typically derived from a conditioned (high resolution) Digital Elevation Models (DEMs) using the so-called 'steepest gradient' method. This method selects the lowest direct neighbor of each cell as its donstream flow direction. HydroMT implements the algorithm proposed by [Wang & Liu (2006)](https://doi.org/10.1080/13658810500433453) which is used in this example." 15 | ] 16 | }, 17 | { 18 | "cell_type": "code", 19 | "execution_count": null, 20 | "metadata": {}, 21 | "outputs": [], 22 | "source": [ 23 | "import rasterio\n", 24 | "import numpy as np\n", 25 | "import pyflwdir\n", 26 | "from utils import (\n", 27 | " quickplot,\n", 28 | " colors,\n", 29 | " cm,\n", 30 | " plt,\n", 31 | ") # data specific quick plot convenience method\n", 32 | "\n", 33 | "# read elevation data of the rhine basin using rasterio\n", 34 | "with rasterio.open(\"rhine_elv0.tif\", \"r\") as src:\n", 35 | " elevtn = src.read(1)\n", 36 | " nodata = src.nodata\n", 37 | " transform = src.transform\n", 38 | " crs = src.crs\n", 39 | " extent = np.array(src.bounds)[[0, 2, 1, 3]]\n", 40 | " latlon = src.crs.is_geographic\n", 41 | " prof = src.profile" 42 | ] 43 | }, 44 | { 45 | "cell_type": "code", 46 | "execution_count": null, 47 | "metadata": {}, 48 | "outputs": [], 49 | "source": [ 50 | "ax = quickplot(title=\"Elevation\")\n", 51 | "im = ax.imshow(\n", 52 | " np.ma.masked_equal(elevtn, -9999),\n", 53 | " extent=extent,\n", 54 | " cmap=\"gist_earth_r\",\n", 55 | " alpha=0.5,\n", 56 | " vmin=0,\n", 57 | " vmax=1000,\n", 58 | ")\n", 59 | "fig = plt.gcf()\n", 60 | "cax = fig.add_axes([0.8, 0.37, 0.02, 0.12])\n", 61 | "fig.colorbar(im, cax=cax, orientation=\"vertical\", extend=\"max\")\n", 62 | "cax.set_ylabel(\"elevation [m+EGM96]\")\n", 63 | "# plt.savefig('elevation.png', dpi=225, bbox_axis='tight')" 64 | ] 65 | }, 66 | { 67 | "cell_type": "markdown", 68 | "metadata": {}, 69 | "source": [ 70 | "## Derive flow direction" 71 | ] 72 | }, 73 | { 74 | "cell_type": "markdown", 75 | "metadata": {}, 76 | "source": [ 77 | " \n", 78 | " Here we use the [from_dem()](https://deltares.github.io/pyflwdir/latest/_generated/pyflwdir.from_dem.html) method to retrieve a [FlwDirRaster](https://deltares.github.io/pyflwdir/latest/_generated/pyflwdir.FlwdirRaster.html) object based on the flow directions as derived with steepest gradient algorithm. This method wraps the [dem.fill_depressions()](https://deltares.github.io/pyflwdir/latest/_generated/pyflwdir.dem.fill_depressions.html) method which returns a depression-filled elevation raster and a local flow directions array following the arcgis D8 convention.\n", 79 | " \n", 80 | " The algorithm assumes that outlets occur at the edge of valid elevation cells. Elevation depressions are filled based on its lowest pour point elevation. If the depression depth relative to the pour point is larger than the maximum pour point depth `max_depth` a pit is set at the depression local minimum elevation (not used in this example). Optionally, all flow direction can be forced towards a single outlet at the lowest edge cell by setting the `outlets='min'` keyword. The `trasform` and `latlon` arguments define the geospatial location of the data." 81 | ] 82 | }, 83 | { 84 | "cell_type": "markdown", 85 | "metadata": {}, 86 | "source": [ 87 | "
\n", 88 | "\n", 89 | "NOTE: that for most methods a first call might be a bit slow as the numba code is compiled just in time, a second call of the same methods (also with different arguments) will be much faster!\n", 90 | " \n", 91 | "
" 92 | ] 93 | }, 94 | { 95 | "cell_type": "code", 96 | "execution_count": null, 97 | "metadata": {}, 98 | "outputs": [], 99 | "source": [ 100 | "# returns FlwDirRaster object\n", 101 | "flw = pyflwdir.from_dem(\n", 102 | " data=elevtn,\n", 103 | " nodata=src.nodata,\n", 104 | " transform=transform,\n", 105 | " latlon=latlon,\n", 106 | " outlets=\"min\",\n", 107 | ")" 108 | ] 109 | }, 110 | { 111 | "cell_type": "markdown", 112 | "metadata": {}, 113 | "source": [ 114 | "We visualize the derived flow directions by plotting all streams with a minimum strahler order of 4, see [streams()](https://deltares.github.io/pyflwdir/latest/_generated/pyflwdir.FlwdirRaster.streams.html) method." 115 | ] 116 | }, 117 | { 118 | "cell_type": "code", 119 | "execution_count": null, 120 | "metadata": {}, 121 | "outputs": [], 122 | "source": [ 123 | "import geopandas as gpd\n", 124 | "\n", 125 | "feats = flw.streams(min_sto=4)\n", 126 | "gdf = gpd.GeoDataFrame.from_features(feats, crs=crs)" 127 | ] 128 | }, 129 | { 130 | "cell_type": "code", 131 | "execution_count": null, 132 | "metadata": {}, 133 | "outputs": [], 134 | "source": [ 135 | "# create nice colormap of Blues with less white\n", 136 | "cmap_streams = colors.ListedColormap(cm.Blues(np.linspace(0.4, 1, 7)))\n", 137 | "gdf_plot_kwds = dict(column=\"strord\", cmap=cmap_streams)\n", 138 | "# plot streams with hillshade from elevation data (see utils.py)\n", 139 | "ax = quickplot(\n", 140 | " gdfs=[(gdf, gdf_plot_kwds)],\n", 141 | " title=\"Streams based steepest gradient algorithm\",\n", 142 | " filename=\"flw_streams_steepest_gradient\",\n", 143 | ")" 144 | ] 145 | }, 146 | { 147 | "cell_type": "markdown", 148 | "metadata": {}, 149 | "source": [ 150 | "## Save flow direction raster" 151 | ] 152 | }, 153 | { 154 | "cell_type": "markdown", 155 | "metadata": {}, 156 | "source": [ 157 | "With the [to_array()](https://deltares.github.io/pyflwdir/latest/_generated/pyflwdir.FlwdirRaster.to_array.html) method we can return a flow direction numpy array from the `FlwDirRaster` object in any supported convention. This can be saved to a geospatial raster file using rasterio as shown below." 158 | ] 159 | }, 160 | { 161 | "cell_type": "code", 162 | "execution_count": null, 163 | "metadata": {}, 164 | "outputs": [], 165 | "source": [ 166 | "d8_data = flw.to_array(ftype=\"d8\")\n", 167 | "d8_data" 168 | ] 169 | }, 170 | { 171 | "cell_type": "code", 172 | "execution_count": null, 173 | "metadata": {}, 174 | "outputs": [], 175 | "source": [ 176 | "# update data type and nodata value properties which are different compared to the input elevation grid and write to geotif\n", 177 | "prof.update(dtype=d8_data.dtype, nodata=247)\n", 178 | "with rasterio.open(\"flwdir.tif\", \"w\", **prof) as src:\n", 179 | " src.write(d8_data, 1)" 180 | ] 181 | } 182 | ], 183 | "metadata": { 184 | "kernelspec": { 185 | "display_name": "Python 3.10.5 ('hydromt-dev')", 186 | "language": "python", 187 | "name": "python3" 188 | }, 189 | "language_info": { 190 | "codemirror_mode": { 191 | "name": "ipython", 192 | "version": 3 193 | }, 194 | "file_extension": ".py", 195 | "mimetype": "text/x-python", 196 | "name": "python", 197 | "nbconvert_exporter": "python", 198 | "pygments_lexer": "ipython3", 199 | "version": "3.10.5" 200 | }, 201 | "vscode": { 202 | "interpreter": { 203 | "hash": "3808d5b5b54949c7a0a707a38b0a689040fa9c90ab139a050e41373880719ab1" 204 | } 205 | } 206 | }, 207 | "nbformat": 4, 208 | "nbformat_minor": 2 209 | } 210 | -------------------------------------------------------------------------------- /examples/upscaling.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Flow direction upscaling" 8 | ] 9 | }, 10 | { 11 | "cell_type": "markdown", 12 | "metadata": {}, 13 | "source": [ 14 | "Here we assume that flow directions are known. We read the flow direction raster data, including meta-data, using [rasterio](https://rasterio.readthedocs.io/en/latest/) and parse it to a pyflwdir `FlwDirRaster` object, see earlier examples for more background." 15 | ] 16 | }, 17 | { 18 | "cell_type": "code", 19 | "execution_count": null, 20 | "metadata": {}, 21 | "outputs": [], 22 | "source": [ 23 | "# import pyflwdir, some dependencies and convenience methods\n", 24 | "import geopandas as gpd\n", 25 | "import numpy as np\n", 26 | "import rasterio\n", 27 | "import pyflwdir\n", 28 | "\n", 29 | "# local convenience methods (see utils.py script in notebooks folder)\n", 30 | "from utils import quickplot, colors, cm # data specific quick plot method\n", 31 | "\n", 32 | "# read and parse flow direciton data\n", 33 | "with rasterio.open(\"rhine_d8.tif\", \"r\") as src:\n", 34 | " flwdir = src.read(1)\n", 35 | " crs = src.crs\n", 36 | " extent = np.array(src.bounds)[[0, 2, 1, 3]]\n", 37 | " prof = src.profile\n", 38 | " flw = pyflwdir.from_array(\n", 39 | " flwdir,\n", 40 | " ftype=\"d8\",\n", 41 | " transform=src.transform,\n", 42 | " latlon=crs.is_geographic,\n", 43 | " cache=True,\n", 44 | " )" 45 | ] 46 | }, 47 | { 48 | "cell_type": "code", 49 | "execution_count": null, 50 | "metadata": {}, 51 | "outputs": [], 52 | "source": [ 53 | "# vectorize streams for visualization,\n", 54 | "uparea = flw.upstream_area()\n", 55 | "feats0 = flw.streams(uparea > 100, uparea=uparea)\n", 56 | "# base color and labels on log10 of upstream area\n", 57 | "gdf_stream0 = gpd.GeoDataFrame.from_features(feats0, crs=crs)\n", 58 | "gdf_stream0[\"logupa\"] = np.floor(np.log10(gdf_stream0[\"uparea\"])).astype(int)\n", 59 | "labels = {2: \"1e2-1e3\", 3: \"1e3-1e4\", 4: \"1e4-1e5\", 5: \"1e5-1e6\"}\n", 60 | "gdf_stream0[\"loglabs\"] = [labels[k] for k in gdf_stream0[\"logupa\"]]\n", 61 | "# kew-word arguments for GeoDataFrame.plot method\n", 62 | "gdf_plt_kwds = dict(\n", 63 | " column=\"loglabs\",\n", 64 | " cmap=colors.ListedColormap(cm.Blues(np.linspace(0.5, 1, 7))),\n", 65 | " categorical=True,\n", 66 | " legend=True,\n", 67 | " legend_kwds=dict(title=\"Upstream area [km2]\"),\n", 68 | ")\n", 69 | "title = f\"Orignial flow directions (upstream area > 100 km2)\"\n", 70 | "ax = quickplot(\n", 71 | " gdfs=[(gdf_stream0, gdf_plt_kwds)], title=title, filename=f\"flw_original\"\n", 72 | ")" 73 | ] 74 | }, 75 | { 76 | "cell_type": "markdown", 77 | "metadata": {}, 78 | "source": [ 79 | "## Flow direction upscaling" 80 | ] 81 | }, 82 | { 83 | "cell_type": "markdown", 84 | "metadata": {}, 85 | "source": [ 86 | "Methods to upcale flow directions are required as models often have a coarser resolution than the elevation data used to build them. Instead of deriving flow directions from upscaled elevation data, it is better to directly upscaling the flow direction data itself. The [upscale()](https://deltares.github.io/pyflwdir/latest/_generated/pyflwdir.FlwdirRaster.upscale.html) method implements the recently developed Iterative Hydrography Upscaling (**IHU**) algorithm ([Eilander et al 2020](https://doi.org/10.5194/hess-2020-582)). The method takes high resolution flow directions and upstream area grid to iterativly determine the best stream segment to represent in each upscaled cell. This stream segment is than traced towards the next downstream upscaled cell to determine the upscaled flow directions. Full details can be found in the referenced paper." 87 | ] 88 | }, 89 | { 90 | "cell_type": "code", 91 | "execution_count": null, 92 | "metadata": {}, 93 | "outputs": [], 94 | "source": [ 95 | "# upscale using scale_factor s\n", 96 | "s = 10\n", 97 | "flw1, idxs_out = flw.upscale(scale_factor=s, uparea=uparea, method=\"ihu\")" 98 | ] 99 | }, 100 | { 101 | "cell_type": "markdown", 102 | "metadata": {}, 103 | "source": [ 104 | "Several methods are implemented based on the most downstream high-res pixel of each upscaled cell, the so-called outlet pixels. The location of these pixels can be used to derive the contributing area to each cell using the [ucat_area()](https://deltares.github.io/pyflwdir/latest/_generated/pyflwdir.FlwdirRaster.ucat_area.html) method. " 105 | ] 106 | }, 107 | { 108 | "cell_type": "code", 109 | "execution_count": null, 110 | "metadata": {}, 111 | "outputs": [], 112 | "source": [ 113 | "# get the contributing unit area to each upscaled cell & accumulate\n", 114 | "subareas = flw.ucat_area(idxs_out=idxs_out, unit=\"km2\")[1]\n", 115 | "uparea1 = flw1.accuflux(subareas)" 116 | ] 117 | }, 118 | { 119 | "cell_type": "code", 120 | "execution_count": null, 121 | "metadata": {}, 122 | "outputs": [], 123 | "source": [ 124 | "# assess the quality of the upscaling\n", 125 | "flwerr = flw.upscale_error(flw1, idxs_out)\n", 126 | "percentage_error = np.sum(flwerr == 0) / np.sum(flwerr != 255) * 100\n", 127 | "print(f\"upscaling error in {percentage_error:.2f}% of cells\")" 128 | ] 129 | }, 130 | { 131 | "cell_type": "code", 132 | "execution_count": null, 133 | "metadata": {}, 134 | "outputs": [], 135 | "source": [ 136 | "# vectorize streams for visualization\n", 137 | "feats1 = flw1.streams(uparea1 > 100, uparea=uparea1)\n", 138 | "# base color and labels on log10 of upstream area\n", 139 | "gdf_stream1 = gpd.GeoDataFrame.from_features(feats1, crs=crs)\n", 140 | "gdf_stream1[\"logupa\"] = np.floor(np.log10(gdf_stream1[\"uparea\"])).astype(int)\n", 141 | "gdf_stream1[\"loglabs\"] = [labels[k] for k in gdf_stream1[\"logupa\"]]\n", 142 | "# plot\n", 143 | "title = f\"IHU Upscaled flow directions ({s}x)\"\n", 144 | "ax = quickplot(\n", 145 | " gdfs=[(gdf_stream1, gdf_plt_kwds)], title=title, filename=f\"flw_upscale{s:2d}\"\n", 146 | ")" 147 | ] 148 | }, 149 | { 150 | "cell_type": "markdown", 151 | "metadata": {}, 152 | "source": [ 153 | "## save upscaled flow directions to file" 154 | ] 155 | }, 156 | { 157 | "cell_type": "code", 158 | "execution_count": null, 159 | "metadata": {}, 160 | "outputs": [], 161 | "source": [ 162 | "# update the profile and write the upscaled flow direction to a new raster file\n", 163 | "prof.update(\n", 164 | " width=flw1.shape[1],\n", 165 | " height=flw1.shape[0],\n", 166 | " transform=flw1.transform,\n", 167 | " nodata=247,\n", 168 | ")\n", 169 | "with rasterio.open(f\"rhine_d8_upscale{s}.tif\", \"w\", **prof) as src:\n", 170 | " src.write(flw1.to_array(\"d8\"), 1)" 171 | ] 172 | }, 173 | { 174 | "cell_type": "code", 175 | "execution_count": null, 176 | "metadata": {}, 177 | "outputs": [], 178 | "source": [ 179 | "# save upscaled network to a vector file\n", 180 | "gdf_stream1.to_file(f\"rhine_d8_upscale{s}.gpkg\", driver=\"GPKG\")" 181 | ] 182 | } 183 | ], 184 | "metadata": { 185 | "kernelspec": { 186 | "display_name": "default", 187 | "language": "python", 188 | "name": "python3" 189 | }, 190 | "language_info": { 191 | "codemirror_mode": { 192 | "name": "ipython", 193 | "version": 3 194 | }, 195 | "file_extension": ".py", 196 | "mimetype": "text/x-python", 197 | "name": "python", 198 | "nbconvert_exporter": "python", 199 | "pygments_lexer": "ipython3", 200 | "version": "3.11.13" 201 | } 202 | }, 203 | "nbformat": 4, 204 | "nbformat_minor": 4 205 | } 206 | -------------------------------------------------------------------------------- /pyflwdir/regions.py: -------------------------------------------------------------------------------- 1 | # -- coding: utf-8 -- 2 | """Methods to for regions, i.e. connected areas with same unique ID. 3 | Building on scipy.ndimage measurement methods, see 4 | https://docs.scipy.org/doc/scipy/reference/ndimage.html#measurements 5 | """ 6 | 7 | from scipy import ndimage 8 | import numpy as np 9 | from numba import njit 10 | 11 | from . import gis_utils 12 | 13 | __all__ = ["region_bounds", "region_slices", "region_sum", "region_area"] 14 | 15 | 16 | def region_sum(data, regions): 17 | """Returns the sum of values in `data` for each unique label in `regions`. 18 | 19 | Parameters 20 | ---------- 21 | data: 2D array 22 | input data 23 | regions: 2D array of int 24 | raster with unique IDs for each region, must have the same shape as `data`. 25 | 26 | Returns 27 | ------- 28 | lbs, sum: 1D array 29 | arrays of the unique region IDs, and associated sum of input data 30 | """ 31 | lbs = np.unique(regions[regions > 0]) 32 | return lbs, ndimage.sum(data, regions, index=lbs) 33 | 34 | 35 | def region_area(regions, transform=gis_utils.IDENTITY, latlon=False): 36 | """Returns the area [m2] for each unique label in `regions`. 37 | 38 | Parameters 39 | ---------- 40 | regions: 2D array of int 41 | raster with unique IDs for each region, must have the same shape as `data`. 42 | latlon: bool 43 | True for geographic CRS, False for projected CRS. 44 | If True, the transform units are assumed to be degrees and converted to metric distances. 45 | transform: Affine 46 | Coefficients mapping pixel coordinates to coordinate reference system. 47 | 48 | Returns 49 | ------- 50 | lbs, areas: 1D array 51 | array of the unique region IDs, and associated areas [m2] 52 | """ 53 | area = gis_utils.area_grid(transform=transform, shape=regions.shape, latlon=latlon) 54 | return region_sum(area, regions) 55 | 56 | 57 | def region_slices(regions): 58 | """Returns slices for each unique label in `regions`. 59 | 60 | NOTE: a region must be a connected area with the same ID, 61 | where ID are integer values larger than zero. 62 | 63 | Parameters 64 | ---------- 65 | regions: 2D array of int 66 | raster with unique IDs for each region, must have the same shape as `data`. 67 | 68 | Returns 69 | ------- 70 | lbs: 1D array 71 | array of the unique region IDs 72 | slices: list of tuples 73 | Each tuple contains slices, one for each dimension 74 | """ 75 | if regions.ndim != 2: 76 | raise ValueError('The "regions" array should be two dimensional') 77 | lbs = np.unique(regions[regions > 0]) 78 | if lbs.size == 0: 79 | raise ValueError("No regions found in data") 80 | slices = ndimage.find_objects(regions) 81 | slices = [s for s in slices if s is not None] 82 | return lbs, slices 83 | 84 | 85 | def region_bounds(regions, transform=gis_utils.IDENTITY): 86 | """Returns the bounding box each unique label in `regions`. 87 | 88 | NOTE: a region must be a connected area with the same ID, 89 | where ID are integer values larger than zero. 90 | 91 | Parameters 92 | ---------- 93 | regions: 2D array of int 94 | raster with unique IDs for each region, must have the same shape as `data`. 95 | transform: Affine 96 | Coefficients mapping pixel coordinates to coordinate reference system. 97 | 98 | Returns 99 | ------- 100 | lbs: 1D array 101 | array of the unique region IDs 102 | bboxs: 2D array with shape (lbs.size, 4) 103 | bounding box [xmin, ymin, xmax, ymax] for each label 104 | total_bbox: 1D array 105 | total bounding box of all regions 106 | """ 107 | lbs, slices = region_slices(regions) 108 | xres, yres = transform[0], transform[4] 109 | lons, lats = gis_utils.affine_to_coords(transform, regions.shape) 110 | iy = np.array([0, -1]) 111 | ix = iy.copy() 112 | if yres < 0: 113 | iy = iy[::-1] 114 | if xres < 0: 115 | ix = ix[::-1] 116 | dx = np.abs(xres) / 2 117 | dy = np.abs(yres) / 2 118 | bboxs = [] 119 | for yslice, xslice in slices: 120 | xmin, xmax = lons[xslice][ix] 121 | ymin, ymax = lats[yslice][iy] 122 | bboxs.append([xmin - dx, ymin - dy, xmax + dx, ymax + dy]) 123 | bboxs = np.asarray(bboxs) 124 | total_bbox = np.hstack([bboxs[:, :2].min(axis=0), bboxs[:, 2:].max(axis=0)]) 125 | return lbs, bboxs, total_bbox 126 | 127 | 128 | @njit 129 | def region_outlets(regions, idxs_ds, seq): 130 | """Returns the linear index of the outlet cell in `regions`. 131 | 132 | NOTE: a region must be a connected area with the same ID, 133 | where ID are integer values larger than zero. 134 | 135 | Parameters 136 | ---------- 137 | regions: 2D array of int 138 | raster with unique IDs for each region, must have the same shape as `data`. 139 | idxs_ds : 1D-array of intp 140 | index of next downstream cell 141 | seq : 1D array of int 142 | ordered cell indices from down- to upstream 143 | 144 | Returns 145 | ------- 146 | lbs: 1D array 147 | array of the unique region IDs 148 | idxs_out: 1D array 149 | linear index of outlet cell per region 150 | """ 151 | regions_flat = regions.ravel() 152 | lbs_lst, idxs_lst = [], [] 153 | for idx in seq[::-1]: # up- to downstream 154 | idx_ds = idxs_ds[idx] 155 | lb0 = regions_flat[idx] 156 | # outlet: idx inside region (lb0) and idx_ds outside region or pit 157 | if lb0 > 0 and (idx_ds == idx or regions_flat[idx_ds] != lb0): 158 | idxs_lst.append(idx) 159 | lbs_lst.append(lb0) 160 | lbs = np.array(lbs_lst, dtype=regions.dtype) 161 | idxs_out = np.array(idxs_lst, dtype=idxs_ds.dtype) 162 | sort = np.argsort(lbs) 163 | return lbs[sort], idxs_out[sort] 164 | 165 | 166 | def region_dissolve( 167 | regions, 168 | labels=None, 169 | idxs=None, 170 | transform=gis_utils.IDENTITY, 171 | latlon=False, 172 | **kwargs, 173 | ): 174 | """Dissolve regions into its nearest neighboring regions. 175 | 176 | Regions to be dissolved are provided by either their `labels` or one location 177 | per region expressed with a linear index in `idxs`. These regions are assigned the 178 | label of the nearest neighboring region. If a locations `idxs` are provided the 179 | proximitity to other regions from that location. This can be usefull to e.g. 180 | dissolve basins based on the distance from its outlet. 181 | 182 | Parameters 183 | ---------- 184 | regions: 2D-array of int 185 | raster with unique non-zero positive IDs for each region 186 | labels: 1D-array of int 187 | labels of regions to be dissolved. Must be unique and larger than zero. 188 | idxs: 1D-array of int 189 | linear index of one location per region to be dissolved 190 | latlon: bool 191 | True for geographic CRS, False for projected CRS. 192 | If True, the transform units are assumed to be degrees and converted to metric distances. 193 | transform: Affine 194 | Coefficients mapping pixel coordinates to coordinate reference system. 195 | 196 | 197 | Returns 198 | ------- 199 | basins_out : 2D-array of int 200 | raster with basin IDs 201 | """ 202 | if idxs is not None and labels is None: 203 | labels = regions.flat[idxs] 204 | elif labels is not None and idxs is None: 205 | labels = np.atleast_1d(labels) 206 | else: 207 | raise ValueError('Either "labels" or "idxs" must be provided.') 208 | if np.unique(labels[labels > 0]).size != labels.size: 209 | raise ValueError("Found non-unique or zero-value labels.") 210 | if regions.ndim != 2: 211 | raise ValueError('The "regions" array should be two dimensional') 212 | # set regions to be dissolved to zero (=background value) 213 | # and spread labels of valid regions 214 | regions0 = regions.copy() 215 | regions0[np.isin(regions, labels)] = 0 216 | assert np.any(regions0 != 0) 217 | out, _, dst = gis_utils.spread2d( 218 | regions0, nodata=0, transform=transform, latlon=latlon, **kwargs 219 | ) 220 | if idxs is None: # get idxs based on smallest distance per region 221 | r, c = zip(*ndimage.minimum_position(dst, regions, labels)) 222 | idxs = np.asarray(r) * regions.shape[1] + np.asarray(c) 223 | # read labels of nearest regions at idxs 224 | labels1 = out.flat[idxs] 225 | # relabel regions 226 | d = {old: new for old, new in zip(labels, labels1)} 227 | return np.vectorize(lambda x: d.get(x, x))(regions) 228 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ########### 2 | Change Log 3 | ########### 4 | 5 | in development 6 | ************** 7 | 8 | 0.5.11 (unreleased) 9 | ******************** 10 | * add conversion methods to api refs (#65) 11 | * add section to upscaling example to save output to file (#73) 12 | * use numpy array to create boolean rather than list comprehension, fixing an issue for Mac (#76) 13 | 14 | 0.5.10 (18-02-2025) 15 | ******************** 16 | * Add support for py 3.13 17 | * bugfix in `from_dataframe` method when downstream index is not present in the dataframe 18 | 19 | 0.5.9 (19-12-2024) 20 | ******************** 21 | * Fixed numpy 2.0 compatibility issues 22 | * Fixed bug in strahler stream order method when more than two streams meet. 23 | * Fixed support for interger type DEMs in `from_dem` and `dem.fill_depressions` methods 24 | * Add support for py 3.12 25 | * Add support for pixi 26 | * Add support for large rasters in the flwdir object (Contributed by @robgpita) 27 | 28 | 0.5.8 (06-Oct-2023) 29 | ******************** 30 | 31 | * support py 3.11 and drop support for py 3.8 32 | 33 | 0.5.7 (22-Mar-2023) 34 | ******************** 35 | 36 | New 37 | --- 38 | * add FlwdirRaster.ucat_volume & subgrid.ucat_volume methods 39 | 40 | 41 | 0.5.6 (15-Nov-2022) 42 | ******************** 43 | 44 | New 45 | --- 46 | * `FlwdirRaster.smooth_rivlen` method to smooth river length with a moving window operation over a river network. 47 | 48 | Changed 49 | ------- 50 | * Move to flit and pyproject.toml for installation and publication 51 | * drop support for python 3.7 52 | * update docs to Sphinx pydata style 53 | 54 | Bugfix 55 | ------ 56 | * use np.uint64 as dtype for large arrays 57 | 58 | 0.5.5 (16-Feb-2022) 59 | ******************** 60 | 61 | New 62 | --- 63 | * read_nextxy method to read binary nextxy data 64 | 65 | Bugfix 66 | ------ 67 | * Support -9 (river outlet at ocean) and -10 (inland river pit) pit values for nextxy data 68 | * Fix 'argmin of an empty sequence' error in dem_dig_d4 69 | 70 | Improved 71 | -------- 72 | * improve gvf and manning estimates in river_depth method 73 | 74 | 75 | 0.5.4 (18-Jan-2022) 76 | ******************** 77 | 78 | Improved 79 | --------- 80 | * prioritize non-boundary cells with same elevation over boundary cells in dem.fill_depressions #17 81 | 82 | Bugfix 83 | ------ 84 | * fix dem_adjust method #16 85 | 86 | 87 | 0.5.3 (18-Nov-2021) 88 | ******************** 89 | 90 | Improved 91 | --------- 92 | * add new idxs_pit argument to dem.fill_depressions 93 | 94 | Bugfix 95 | ------ 96 | * min_rivdph argument was not always applied in FlwdirRaster.river_depth 97 | 98 | 99 | 0.5.2 (17-Nov-2021) 100 | ******************* 101 | 102 | New 103 | --- 104 | * Flwdir.river_depth for gradually varying flow (gvf) and manning river depth estimation 105 | * Flwdir.path method to get the indices of flow paths for vector flow directions 106 | 107 | Improved 108 | -------- 109 | * FlwdirRaster.streams method includes a (zero-length) line at pits and adds option for trace direction in combination with segment end cells. 110 | * Moved accuflux method from FlwdirRaster to parent Flwdir class. 111 | * Additional `how` argument in fillnodata to indicate how to combine values at confluences. Min, max, sum and mean are supported. 112 | 113 | 114 | 0.5.1 (3-Oct-2021) 115 | ****************** 116 | 117 | New 118 | --- 119 | * Restore FlwdirRaster.inflow_idxs and FlwdirRaster.outflow_idxs methods 120 | 121 | 0.5 (28-Sept-2021) 122 | ****************** 123 | New 124 | --- 125 | * General Flwdir object for 1D vector based (instead of raster based) flow directions 126 | * flwdir.from_dataframe methods to derive a Flwdir object from a (Geo)DataFrame based on the row index and a column with downstream row indices. 127 | * dem.fill_depressions and pyflwdir.from_dem methods to derive flow directions from DEMs based on Wang & Lui (2015) 128 | * gis_utils.get_edge method to get a boolean mask of valid cells at the interface with nodata cells or the array edge. 129 | * gis_utils.spread2d method to spread valid values on a 2D raster with optional friction and mask rasters 130 | * FlwdirRaster.dem_dig_d4 method to adjust a DEM such that each cell has a 4D neighbor with equal or lower elevation. 131 | * FlwdirRaster.fillnodata method fill nodata gaps by propagating valid values up or downstream. 132 | * region.region_outlets method; which is also wrapped in the new FlwdirRaster.basin_outlets method 133 | * region.region_dissolve method to dissovle regions into their nearest neighboring region 134 | * FlwdirRaster.subbasins_areas method to derive subbasins based on a minimal area threshold 135 | 136 | Improved 137 | -------- 138 | * added type="classis" for bottum-up stream order to FlwdirRaster.stream_order, default is type="strahler" 139 | * return subbasin outlet indices for all FlwdirRaster.subbasin* methods 140 | * improved subgrid slope method with optional lstsq regression based slope 141 | * FlwdirRaster.streams takes an optional `idxs_out` argument to derive stream vectors for unit catchments 142 | * FlwdirRaster.streams takes an optional `max_len` argument to split large segments into multiple smaller ones. 143 | * Using the new Flwdir object as common base of FlwdirRaster to share methods and properties 144 | * gis_utils.IDENTITY transform has North -> South orientation (yres < 0) instead of S->N orientation which is in line with flow direction rasters. 145 | * new `restrict_strord` argument in FlwdirRaster.moving_average and FlwdirRaster.moving_median methods to restrict the moving window to cells with same or larger stream order. 146 | 147 | Bugfix 148 | ------ 149 | * strahler stream_order method gave incorrect results 150 | * basins.subbasins_pfafstetter reimplementation to fix mall functioning when jitted 151 | * FlwdirRaster.streams fix when called with optional `min_sto` argument 152 | 153 | Deprecated 154 | ---------- 155 | * FlwdirRaster.main_tributaries method is deprecated due to mallfunctioning when jitted 156 | * FlwdirRaster.inflow_idxs and FlwdirRaster.outflow_idxs 157 | 158 | 0.4.6 159 | ***** 160 | Improved 161 | -------- 162 | * vectorizing of local flow directions and streams in seperate methods 163 | * fixed subbasins method 164 | * documentation using nbsphinx 165 | 166 | 0.4.5 167 | ***** 168 | New 169 | --- 170 | * subbasin_mask_within_region 171 | * contiguous_area_within_region 172 | 173 | 174 | 0.4.4 175 | ***** 176 | Improved 177 | -------- 178 | * IHU upscaling (HESS preprint) 179 | 180 | 0.4.3 181 | ***** 182 | Improved 183 | -------- 184 | * vectorizing of streams 185 | * pfafstetter method improved 186 | * remove use of pandas and geopandas to limit dependencies 187 | 188 | New 189 | --- 190 | * new subbasins method 191 | * features method in favor vectorize 192 | 193 | 0.4.2 194 | ***** 195 | Improved 196 | -------- 197 | * improved test coverage 198 | * prepared release for pip 199 | 200 | New 201 | --- 202 | 203 | 0.4.1 204 | ***** 205 | Improved 206 | -------- 207 | * code reformatted using black 208 | * improved subgrid river methods 209 | 210 | New 211 | --- 212 | * subgrid_rivlen, subgrid_rivslp methods in favor of ucat_channel (will be deprecated) 213 | 214 | 0.4.0 215 | ***** 216 | Improved 217 | -------- 218 | * improved COM upscaling 219 | 220 | New 221 | --- 222 | 223 | 0.3.0 224 | ***** 225 | Improved 226 | -------- 227 | * simplified data layout based on linear downstream cell indices and a ordered sequence or down- to upstream cell indices. 228 | 229 | New 230 | --- 231 | * hand - height above neares drain based on Nobre et al. (2016) 232 | * floodplains - flood plain delineation based on Nardi et al. (2019) 233 | * snap/path - methods to follow a streamline in up- or downstream direction 234 | 235 | 0.2.0 236 | ***** 237 | 238 | New 239 | --- 240 | * suport for multiple flow direction types 241 | 242 | Improved 243 | -------- 244 | 245 | * upscale - Connecting outlets method is born 246 | 247 | 248 | 0.1.0 249 | ***** 250 | 251 | New 252 | ----- 253 | 254 | * setup_network - Setup all upstream - downstream connections based on the flow direcion map. 255 | * get_pits - Return the indices of the pits/outlets in the flow direction map. 256 | * upstream_area - Returns the upstream area [km] based on the flow direction map. 257 | * stream_order - Returns the Strahler Order map 258 | * delineate_basins - Returns a map with basin ids and corresponding bounding boxes. 259 | * basin_map - Returns a map with (sub)basins based on the up- downstream network. 260 | * ucat_map - Returns the unit-subcatchment and outlets map. 261 | * basin_shape - Returns the vectorized basin boundary. 262 | * stream_shape - Returns a GeoDataFrame with vectorized river segments. 263 | * upscale - Returns upscaled flow direction map using the extended effective area method. 264 | * propagate_downstream - Returns a map with accumulated material from all upstream cells. 265 | * propagate_upstream - Returns a map with accumulated material from all downstream cells. 266 | * adjust_elevation - Returns hydrologically adjusted elevation map. 267 | -------------------------------------------------------------------------------- /tests/test_streams_basins.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Tests for the streams.py and basins.py submodules.""" 3 | 4 | import pytest 5 | import numpy as np 6 | 7 | from pyflwdir import streams, basins, core, gis_utils, regions 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "test_data, flwdir", 12 | [("test_data0", "flwdir0"), ("test_data1", "flwdir1"), ("test_data2", "flwdir2")], 13 | ) 14 | def test_accuflux(test_data, flwdir, request): 15 | flwdir = request.getfixturevalue(flwdir) 16 | test_data = request.getfixturevalue(test_data) 17 | idxs_ds, idxs_pit, seq, rank, mv = [p.copy() for p in test_data] 18 | n, ncol = seq.size, flwdir.shape[1] 19 | # cell count 20 | nodata = -9999 21 | material = np.full(idxs_ds.size, nodata, dtype=np.int32) 22 | material[seq] = 1 23 | acc = streams.accuflux(idxs_ds, seq, material, nodata) 24 | assert acc[idxs_pit].sum() == n 25 | upa = streams.upstream_area(idxs_ds, seq, ncol, dtype=np.int32) 26 | assert upa[idxs_pit].sum() == n 27 | assert np.all(upa == acc) 28 | # latlon is True 29 | lons, lats = gis_utils.affine_to_coords(gis_utils.IDENTITY, flwdir.shape) 30 | area = np.where(rank >= 0, gis_utils.reggrid_area(lats, lons).ravel(), nodata) 31 | acc1 = streams.accuflux(idxs_ds, seq, area, nodata) 32 | upa1 = streams.upstream_area(idxs_ds, seq, ncol, latlon=True) 33 | assert np.all(upa1 == acc1) 34 | 35 | 36 | @pytest.mark.parametrize( 37 | "test_data, flwdir", 38 | [("test_data0", "flwdir0"), ("test_data1", "flwdir1"), ("test_data2", "flwdir2")], 39 | ) 40 | def test_basins(test_data, flwdir, request): 41 | flwdir = request.getfixturevalue(flwdir) 42 | test_data = request.getfixturevalue(test_data) 43 | idxs_ds, idxs_pit, seq, _, _ = [p.copy() for p in test_data] 44 | _, ncol = seq.size, flwdir.shape[1] 45 | upa = streams.upstream_area(idxs_ds, seq, ncol, dtype=np.int32) 46 | # test basins 47 | ids = np.arange(1, idxs_pit.size + 1, dtype=int) 48 | bas = basins.basins(idxs_ds, idxs_pit, seq, ids) 49 | assert np.all(np.array([np.sum(bas == i) for i in ids]) == upa[idxs_pit]) 50 | assert np.all(np.unique(bas[bas != 0]) == ids) # nodata == 0 51 | # test region 52 | bas = bas.reshape(flwdir.shape) 53 | total_bbox = regions.region_bounds(bas)[-1] 54 | assert np.all(total_bbox == np.array([0, -bas.shape[0], bas.shape[1], 0])) 55 | lbs, areas = regions.region_area(bas) 56 | assert areas[0] == np.sum(bas == 1) 57 | areas1 = regions.region_area(bas, latlon=True)[1] 58 | assert areas1.argmax() == areas.argmax() 59 | # test dissolve with labels 60 | lbs0 = lbs[np.argmin(areas)] 61 | bas1 = regions.region_dissolve(bas, labels=lbs0) 62 | assert np.all(~np.isin(bas1, lbs0)) 63 | # test dissovle with linear index 64 | idxs = idxs_pit[np.argsort(upa[idxs_pit])][:2] 65 | lbs0 = bas.flat[idxs] 66 | bas1 = regions.region_dissolve(bas, idxs=idxs) 67 | assert np.all(~np.isin(bas1, lbs0)) 68 | # dissolve errors 69 | with pytest.raises(ValueError, match='Either "labels" or "idxs" must be provided'): 70 | regions.region_dissolve(bas) 71 | with pytest.raises(ValueError, match="Found non-unique or zero-value labels"): 72 | regions.region_dissolve(bas, labels=0) 73 | 74 | 75 | @pytest.mark.parametrize( 76 | "test_data, flwdir", 77 | [("test_data0", "flwdir0"), ("test_data1", "flwdir1"), ("test_data2", "flwdir2")], 78 | ) 79 | def test_subbasins(test_data, flwdir, request): 80 | flwdir = request.getfixturevalue(flwdir) 81 | test_data = request.getfixturevalue(test_data) 82 | idxs_ds, idxs_pit, seq, _, mv = [p.copy() for p in test_data] 83 | _, ncol = seq.size, flwdir.shape[1] 84 | upa = streams.upstream_area(idxs_ds, seq, ncol, dtype=np.int32) 85 | idxs_us_main = core.main_upstream(idxs_ds, upa, mv=mv) 86 | ## pfafstetter for largest basin 87 | idx0 = np.atleast_1d(idxs_pit[np.argsort(upa[idxs_pit])[-1:]]) 88 | pfaf1, idxs_out1 = basins.subbasins_pfafstetter( 89 | idx0, idxs_ds, seq, idxs_us_main, upa, mask=None, depth=1, mv=mv 90 | ) 91 | assert pfaf1[idx0] == 1 92 | lbs, idxs_out = regions.region_outlets(pfaf1.reshape(flwdir.shape), idxs_ds, seq) 93 | idxs_out1 = idxs_out1[np.argsort(pfaf1[idxs_out1])] 94 | assert np.all(idxs_out1 == idxs_out) 95 | assert np.all(lbs == pfaf1[idxs_out1]) 96 | pfaf2, _ = basins.subbasins_pfafstetter( 97 | idx0, idxs_ds, seq, idxs_us_main, upa, mask=None, depth=2, mv=mv 98 | ) 99 | assert pfaf2[idx0] == 11 100 | assert np.all(pfaf2 // 10 == pfaf1) 101 | pfaf_path = pfaf2[core.path(idx0, idxs_us_main, mv=mv)[0][0]] 102 | assert np.all(pfaf_path % 2 == 1) # only interbasin (=odd values) 103 | assert np.all(np.diff(pfaf_path) >= 0) # increasing values upstream 104 | ## area subbasins 105 | subbas, idxs_out1 = basins.subbasins_area( 106 | idxs_ds, seq, idxs_us_main, upa, area_min=5 107 | ) 108 | assert np.all(upa[subbas == 0] == -9999) 109 | pits = idxs_ds[idxs_out1] == idxs_out1 110 | assert np.all(subbas[idxs_out1][~pits] != subbas[idxs_ds[idxs_out1]][~pits]) 111 | lbs0 = subbas[idxs_out1][~pits] 112 | lbs, areas = regions.region_area(subbas.reshape(flwdir.shape)) 113 | # all nonpits must have area_min size 114 | assert np.all(areas[np.isin(lbs, lbs0)] > 5) 115 | 116 | 117 | @pytest.mark.parametrize("test_data", ["test_data0", "test_data1"]) 118 | def test_subbasins_strord(test_data, request): 119 | test_data = request.getfixturevalue(test_data) 120 | idxs_ds, _, seq, _, _ = [p.copy() for p in test_data] 121 | ## streamorder basins 122 | strord = streams.strahler_order(idxs_ds, seq) 123 | maxsto = strord.max() 124 | subbas, idxs_out1 = basins.subbasins_streamorder(idxs_ds, seq, strord, min_sto=-2) 125 | sto_out = strord[idxs_out1] 126 | assert np.all(sto_out >= maxsto - 2) 127 | assert np.all(strord[subbas == 0] < maxsto - 2) 128 | pits = idxs_ds[idxs_out1] == idxs_out1 129 | sto_out1 = strord[idxs_ds[idxs_out1]] 130 | assert np.all(sto_out1[~pits] > sto_out[~pits]) 131 | assert np.all(subbas[idxs_out1][~pits] != subbas[idxs_ds[idxs_out1]][~pits]) 132 | 133 | 134 | @pytest.mark.parametrize( 135 | "test_data, flwdir", 136 | [("test_data0", "flwdir0"), ("test_data1", "flwdir1"), ("test_data2", "flwdir2")], 137 | ) 138 | def test_streams(test_data, flwdir, request): 139 | flwdir = request.getfixturevalue(flwdir) 140 | test_data = request.getfixturevalue(test_data) 141 | idxs_ds, idxs_pit, seq, rank, mv = [p.copy() for p in test_data] 142 | _, ncol = seq.size, flwdir.shape[1] 143 | idxs_ds[rank == -1] = mv 144 | upa = streams.upstream_area(idxs_ds, seq, ncol, dtype=np.int32) 145 | # strahler stream order 146 | sto = streams.strahler_order(idxs_ds, seq) 147 | idxs_headwater = core.headwater_indices(idxs_ds, mv=mv) 148 | assert np.all(sto[idxs_headwater] == 1) 149 | assert np.max(sto[idxs_pit]) == np.max(sto) 150 | assert np.all(sto[idxs_ds == mv] == 0) and np.all(sto[idxs_ds != mv] >= 1) 151 | # strahler stream order with mask 152 | sto1 = streams.strahler_order(idxs_ds, seq, mask=sto > 1) 153 | assert np.all(sto1[sto <= 1] == 0) 154 | # classic stream order 155 | idxs_us_main = core.main_upstream(idxs_ds, upa, mv=mv) 156 | sto0 = streams.stream_order(idxs_ds, seq, idxs_us_main, mask=None, mv=mv) 157 | assert np.all(sto0[idxs_pit] == 1) 158 | assert np.max(sto0[idxs_headwater]) == np.max(sto0) 159 | # stream distance 160 | data = np.zeros(idxs_ds.size, dtype=np.int32) 161 | data[rank > 0] = 1 162 | strlen0 = streams.accuflux_ds(idxs_ds, seq, data, -1) 163 | assert np.all(rank[rank >= 0] == strlen0[rank >= 0]) 164 | strlen = streams.stream_distance(idxs_ds, seq, ncol) 165 | assert np.all(strlen[idxs_pit] == 0) 166 | assert np.max(strlen[idxs_headwater]) == np.max(strlen) 167 | ranks1 = streams.stream_distance(idxs_ds, seq, ncol, real_length=False) 168 | assert np.all(ranks1[rank >= 0] == rank[rank >= 0]) 169 | 170 | 171 | def test_smooth_rivlen(test_data0, flwdir0): 172 | idxs_ds, _, seq, _, mv = test_data0 173 | ncol = flwdir0.shape[1] 174 | upa = streams.upstream_area(idxs_ds, seq, ncol, dtype=np.int32) 175 | idxs_us_main = core.main_upstream(idxs_ds, upa, mv=mv) 176 | rivlen = np.random.rand(idxs_ds.size) 177 | rivlen[upa <= 3] = -9999.0 # river cells with at least 3 upstream cells 178 | min_rivlen = 0.2 179 | rivlen_out = streams.smooth_rivlen( 180 | idxs_ds, 181 | idxs_us_main, 182 | rivlen, 183 | min_rivlen=min_rivlen, 184 | max_window=10, 185 | nodata=-9999.0, 186 | mv=mv, 187 | ) 188 | # NOTE: there could still be cells with rivlen < min_rivlen 189 | assert rivlen_out[rivlen_out < min_rivlen].size < rivlen[rivlen < min_rivlen].size 190 | assert np.isclose(np.sum(rivlen_out[rivlen_out > 0]), np.sum(rivlen[rivlen > 0])) 191 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # pyflwdir documentation build configuration file, created by 4 | # sphinx-quickstart on Wed Jul 24 15:19:00 2019. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | import os 20 | import sys 21 | from distutils.dir_util import copy_tree 22 | import pyflwdir 23 | 24 | here = os.path.dirname(__file__) 25 | sys.path.insert(0, os.path.abspath(os.path.join(here, ".."))) 26 | 27 | 28 | # -- Project information ----------------------------------------------------- 29 | 30 | project = "PyFlwDir" 31 | copyright = "Deltares" 32 | author = "Dirk Eilander" 33 | 34 | # The short version which is displayed 35 | version = pyflwdir.__version__.split("dev")[0] 36 | 37 | # # -- Copy notebooks to include in docs ------- 38 | SKIP_DOC_EXAMPLES = bool(os.environ.get("SKIP_DOC_EXAMPLES", False)) 39 | if not os.path.isdir("_examples") and not SKIP_DOC_EXAMPLES: 40 | os.makedirs("_examples") 41 | copy_tree("../examples", "_examples") 42 | 43 | # -- General configuration ------------------------------------------------ 44 | 45 | # If your documentation needs a minimal Sphinx version, state it here. 46 | # 47 | # needs_sphinx = '1.0' 48 | 49 | # Add any Sphinx extension module names here, as strings. They can be 50 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 51 | # ones. 52 | extensions = [ 53 | "sphinx.ext.autodoc", 54 | "sphinx.ext.viewcode", 55 | "sphinx.ext.todo", 56 | "sphinx.ext.napoleon", 57 | "sphinx.ext.autosummary", 58 | "sphinx.ext.githubpages", 59 | "sphinx.ext.intersphinx", 60 | "IPython.sphinxext.ipython_directive", 61 | "IPython.sphinxext.ipython_console_highlighting", 62 | ] 63 | 64 | if not SKIP_DOC_EXAMPLES: 65 | extensions.append("nbsphinx") 66 | 67 | 68 | autosummary_generate = True 69 | 70 | # Add any paths that contain templates here, relative to this directory. 71 | templates_path = [ 72 | "_templates", 73 | ] 74 | # The suffix(es) of source filenames. 75 | # You can specify multiple suffix as a list of string: 76 | # 77 | # source_suffix = ['.rst', '.md'] 78 | source_suffix = ".rst" 79 | # The master toctree document. 80 | master_doc = "index" 81 | 82 | # The language for content autogenerated by Sphinx. Refer to documentation 83 | # for a list of supported languages. 84 | # 85 | # This is also used if you do content translation via gettext catalogs. 86 | # Usually you set "language" from the command line for these cases. 87 | language = "en" 88 | 89 | # List of patterns, relative to source directory, that match files and 90 | # directories to ignore when looking for source files. 91 | # This patterns also effect to html_static_path and html_extra_path 92 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 93 | 94 | # The name of the Pygments (syntax highlighting) style to use. 95 | pygments_style = "sphinx" 96 | 97 | # If true, `todo` and `todoList` produce output, else they produce nothing. 98 | todo_include_todos = False 99 | 100 | # -- Options for IPython output ------------------------------------------- 101 | # continue doc build and only print warnings/errors in examples 102 | # ipython_savefig_dir = "./" 103 | # ipython_warning_is_error = False 104 | 105 | # -- Options for HTML output ---------------------------------------------- 106 | 107 | # The theme to use for HTML and HTML Help pages. See the documentation for 108 | # a list of builtin themes. 109 | # 110 | html_theme = "pydata_sphinx_theme" 111 | html_logo = "_static/pyflwdir_icon.png" 112 | autodoc_member_order = "bysource" # overwrite default alphabetical sort 113 | autoclass_content = "both" 114 | 115 | # Theme options are theme-specific and customize the look and feel of a theme 116 | # further. For a list of options available for each theme, see the 117 | # documentation. 118 | # 119 | html_static_path = ["_static"] 120 | html_css_files = ["theme-deltares.css"] 121 | html_theme_options = { 122 | "show_nav_level": 1, 123 | "navbar_align": "content", 124 | "use_edit_page_button": True, 125 | "icon_links": [ 126 | { 127 | "name": "GitHub", 128 | "url": "https://github.com/Deltares/pyflwdir", # required 129 | "icon": "https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg", 130 | "type": "url", 131 | }, 132 | { 133 | "name": "Deltares", 134 | "url": "https://www.deltares.nl/en/", 135 | "icon": "_static/deltares-blue.svg", 136 | "type": "local", 137 | }, 138 | ], 139 | "logo": { 140 | "text": "PyFlwDir", 141 | }, 142 | "navbar_end": ["navbar-icon-links"], # remove dark mode switch 143 | } 144 | 145 | html_context = { 146 | "github_url": "https://github.com", # or your GitHub Enterprise interprise 147 | "github_user": "Deltares", 148 | "github_repo": "pyflwdir", 149 | "github_version": "main", 150 | "doc_path": "docs", 151 | "default_mode": "light", 152 | } 153 | 154 | # remove_from_toctrees = ["_generated/*"] 155 | 156 | 157 | # Custom sidebar templates, must be a dictionary that maps document names 158 | # to template names. 159 | # 160 | # This is required for the alabaster theme 161 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 162 | # html_sidebars = { 163 | # "**": [ 164 | # "relations.html", # needs 'show_related': True theme option to display 165 | # "searchbox.html", 166 | # ] 167 | # } 168 | 169 | 170 | # -- Options for HTMLHelp output ------------------------------------------ 171 | 172 | # Output file base name for HTML help builder. 173 | htmlhelp_basename = "pyflwdir_doc" 174 | 175 | 176 | # -- Options for LaTeX output --------------------------------------------- 177 | 178 | latex_elements = { 179 | # The paper size ('letterpaper' or 'a4paper'). 180 | # 181 | # 'papersize': 'letterpaper', 182 | # The font size ('10pt', '11pt' or '12pt'). 183 | # 184 | # 'pointsize': '10pt', 185 | # Additional stuff for the LaTeX preamble. 186 | # 187 | # 'preamble': '', 188 | # Latex figure (float) alignment 189 | # 190 | # 'figure_align': 'htbp', 191 | } 192 | 193 | # Grouping the document tree into LaTeX files. List of tuples 194 | # (source start file, target name, title, 195 | # author, documentclass [howto, manual, or own class]). 196 | latex_documents = [ 197 | ( 198 | master_doc, 199 | "pyflwdir.tex", 200 | "pyflwdir Documentation", 201 | [author], 202 | "manual", 203 | ), 204 | ] 205 | 206 | 207 | # -- Options for manual page output --------------------------------------- 208 | 209 | # One entry per manual page. List of tuples 210 | # (source start file, name, description, authors, manual section). 211 | man_pages = [(master_doc, "pyflwdir", "pyflwdir Documentation", [author], 1)] 212 | 213 | 214 | # -- Options for Texinfo output ------------------------------------------- 215 | 216 | # Grouping the document tree into Texinfo files. List of tuples 217 | # (source start file, target name, title, author, 218 | # dir menu entry, description, category) 219 | texinfo_documents = [ 220 | ( 221 | master_doc, 222 | "pyflwdir", 223 | "pyflwdir Documentation", 224 | author, 225 | "pyflwdir", 226 | "Fast methods to work with hydro- and topography data in pure Python.", 227 | "Miscellaneous", 228 | ), 229 | ] 230 | 231 | # Example configuration for intersphinx: refer to the Python standard library. 232 | intersphinx_mapping = { 233 | "python": ("https://docs.python.org/3/", None), 234 | "geopandas": (" https://geopandas.org/en/stable/", None), 235 | "numpy": ("https://numpy.org/doc/stable", None), 236 | "scipy": ("https://docs.scipy.org/doc/scipy", None), 237 | # "numba": ("https://numba.pydata.org/numba-doc/latest", None), 238 | } 239 | 240 | # This is processed by Jinja2 and inserted before each notebook 241 | nbsphinx_prolog = r""" 242 | {% set docname = env.doc2path(env.docname, base=None).split('\\')[-1].split('/')[-1] %} 243 | 244 | .. TIP:: 245 | 246 | .. raw:: html 247 | 248 |
249 | For an interactive online version click here: 250 | Binder badge 251 |
252 | """ 253 | -------------------------------------------------------------------------------- /pyflwdir/basins.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Methods to delineate (sub)basins.""" 3 | from numba import njit 4 | import numpy as np 5 | 6 | from . import core, streams 7 | 8 | _mv = core._mv 9 | all = [] 10 | 11 | 12 | def basins(idxs_ds, idxs_pit, seq, ids=None): 13 | """Return basin map""" 14 | if ids is None: 15 | ids = np.arange(1, idxs_pit.size + 1, dtype=np.uint32) 16 | basins = np.zeros(idxs_ds.size, dtype=ids.dtype) 17 | basins[idxs_pit] = ids 18 | return core.fillnodata_upstream(idxs_ds, seq, basins, 0) 19 | 20 | 21 | # NOTE not unit tested 22 | # TODO: change this method to derive the interbasin for a single outflow as currently 23 | # its results are ambiguous?! 24 | @njit 25 | def interbasin_mask(idxs_ds, seq, region, stream=None): 26 | """Returns most downstream contiguous area within region, i.e.: if a stream flows 27 | in and out of the region, only the most downstream contiguous area within region 28 | will be True in output mask. If a stream mask is provided the area is reduced to 29 | cells which drain to the stream. 30 | 31 | Parameters 32 | ---------- 33 | idxs_ds : 1D-array of intp 34 | index of next downstream cell 35 | seq : 1D array of int 36 | ordered cell indices from down- to upstream 37 | region : 1D array of bool 38 | mask of region 39 | stream : 1D array of bool, optional 40 | mask of stream 41 | 42 | Returns 43 | ------- 44 | mask: 1D array of bool 45 | Mask of most downstream contiguous area within region 46 | """ 47 | # get area upstream of streams within region 48 | if stream is not None: 49 | mask = stream.copy() 50 | # make sure all mask contains most downstream stream cells 51 | for idx0 in seq[::-1]: # up- to downstream 52 | if mask[idx0]: 53 | mask[idxs_ds[idx0]] = True 54 | else: 55 | mask = np.ones(region.size, dtype=np.bool_) 56 | # keep only the most downstream contiguous area within region 57 | for idx0 in seq: # down- to upstream 58 | idx_ds = idxs_ds[idx0] 59 | mask[idx0] = mask[idx_ds] 60 | if not region[idx0] and region[idx_ds]: 61 | # set mask to false in first cell(s) upstream of region 62 | mask[idx0] = False 63 | # propagate mask upstream 64 | return np.logical_and(mask, region) 65 | 66 | 67 | @njit 68 | def subbasins_streamorder(idxs_ds, seq, strord, mask=None, min_sto=-2): 69 | """Returns a subbasin map with unique IDs starting from one. 70 | Subbasins are defined based on a minimum stream order. 71 | 72 | Parameters 73 | ---------- 74 | idxs_ds : 1D-array of intp 75 | index of next downstream cell 76 | seq : 1D array of int 77 | ordered cell indices from down- to upstream 78 | strord : 1D-array of uint8 79 | stream order 80 | mask : 1D array of bool, optional 81 | consider only True cells 82 | min_sto : int, optional 83 | minimum stream order of subbasins, by default the stream order is set to 84 | two under the global maxmium stream order. 85 | 86 | Returns 87 | ------- 88 | basins : 1D-arrays of int32 89 | map with unique IDs for stream_order>=min_sto subbasins 90 | """ 91 | if min_sto < 0: 92 | min_sto = int(strord.max()) + min_sto 93 | subbas = np.full(idxs_ds.shape, 0, dtype=np.int32) 94 | idxs = [] 95 | for idx0 in seq[::-1]: # up- to downstream 96 | if (mask is not None and mask[idx0] is False) or strord[idx0] < min_sto: 97 | continue 98 | idx_ds = idxs_ds[idx0] 99 | if strord[idx0] != strord[idx_ds] or idx_ds == idx0: 100 | idxs.append(idx0) 101 | subbas[idx0] = len(idxs) 102 | idxs1 = np.array(idxs, dtype=idxs_ds.dtype) 103 | return core.fillnodata_upstream(idxs_ds, seq, subbas, 0), idxs1 104 | 105 | 106 | @njit 107 | def _tributaries(idxs_ds, seq, strord): 108 | idxs_trib = [] 109 | for idx0 in seq: # down- to upstream 110 | idx_ds = idxs_ds[idx0] 111 | if strord[idx0] > 0 and strord[idx0] > strord[idx_ds]: 112 | idxs_trib.append(idx0) 113 | return np.array(idxs_trib, idxs_ds.dtype) 114 | 115 | 116 | @njit 117 | def subbasins_pfafstetter( 118 | idxs_pit, idxs_ds, seq, idxs_us_main, uparea, mask=None, depth=1, mv=_mv 119 | ): 120 | strord = streams.stream_order(idxs_ds, seq, idxs_us_main, mask=mask, mv=mv) 121 | strord = np.where(strord <= depth + 1, strord, 0).astype(strord.dtype) 122 | idxs_trib = _tributaries(idxs_ds, seq, strord) 123 | # initiate map with pfaf id at river branch based on classic stream order map 124 | pfaf_branch = np.zeros(idxs_ds.size, np.int32) 125 | idxs = [] 126 | # keep basin label; depth; outlet index 127 | labs = [(int(0), int(0)) for _ in range(0)] # set dtypes 128 | # propagate basin labels upstream its main stem 129 | pfaf0 = 1 130 | for d0 in range(1, depth): 131 | pfaf0 += 10**d0 132 | for i, idx in enumerate(idxs_pit): 133 | idxs.append(idx) 134 | pfaf1 = pfaf0 + (i + 1) * 10**depth 135 | labs.append((pfaf1, 1)) 136 | pfaf_branch[idx] = pfaf1 137 | while True: 138 | idx = idxs_us_main[idx] 139 | if idx == mv or strord[idx] == 0: 140 | break 141 | pfaf_branch[idx] = pfaf1 142 | while len(labs) > 0: 143 | pfaf0, d0 = labs.pop(0) 144 | # get tributaries to pfaf0 145 | idxs0 = np.array( 146 | [ 147 | idx 148 | for idx in idxs_trib 149 | if pfaf_branch[idx] == 0 and pfaf_branch[idxs_ds[idx]] == pfaf0 150 | ], 151 | dtype=idxs_trib.dtype, 152 | ) 153 | if idxs0.size == 0: 154 | continue 155 | # sort in descending order of subbasin uparea to get 4 largest subbasins 156 | idxs0s = idxs0[np.argsort(-uparea[idxs0])] 157 | idxs_trib0 = idxs0s[:4] 158 | # sort in down- to upstream order 159 | idxs_trib0s = idxs_trib0[np.argsort(-uparea[idxs_ds[idxs_trib0]])] 160 | # write label at sub- & interbasin outlets 161 | # pfaf_branch[idx0] = pfaf0 162 | pfaf_int_ds = pfaf0 # downstream interbasin 163 | for i, idx in enumerate(idxs_trib0s): 164 | idxs.append(idx) 165 | idx1 = idxs_us_main[idxs_ds[idx]] # interbasin outlet 166 | # propagate subbasin labels upstream its main stem 167 | pfaf_sub = pfaf0 + (i * 2 + 1) * 10 ** (depth - d0) # subbasin 168 | pfaf_branch[idx] = pfaf_sub 169 | while True: 170 | idx = idxs_us_main[idx] 171 | if idx == mv or strord[idx] == 0: 172 | break 173 | pfaf_branch[idx] = pfaf_sub 174 | if d0 < depth: # next iter 175 | labs.append((pfaf_sub, d0 + 1)) 176 | # propagate interbasin labels upstream main stem 177 | if idx1 not in idxs: 178 | idxs.append(idx1) 179 | pfaf_int = pfaf0 + (i + 1) * 2 * 10 ** (depth - d0) # interbasin 180 | pfaf_branch[idx1] = pfaf_int 181 | while True: 182 | idx1 = idxs_us_main[idx1] 183 | if idx1 == mv or pfaf_branch[idx1] != pfaf_int_ds: 184 | break 185 | pfaf_branch[idx1] = pfaf_int 186 | pfaf_int_ds = pfaf_int 187 | if d0 < depth: # next iter 188 | labs.append((pfaf_int, d0 + 1)) 189 | idxs1 = np.array(idxs, dtype=idxs_ds.dtype) 190 | pfafbas = core.fillnodata_upstream(idxs_ds, seq, pfaf_branch, 0) % 10**depth 191 | return pfafbas, idxs1 192 | 193 | 194 | @njit 195 | def subbasins_area(idxs_ds, seq, idxs_us_main, uparea, area_min): 196 | """Returns map with basin IDs, with a minimal area of `area_min`. 197 | Moving upstream from the basin outlets a new subbasin starts at tributaries 198 | with a contributing area larger than `area_min` and new interbasins when its area 199 | exceeds the `area_min`. 200 | 201 | Returns 202 | ------- 203 | basins: 2D array of int32 204 | raster with basin IDs 205 | idxs1: 1D array of int 206 | linear indices of subbasin outlet cells 207 | """ 208 | upa_out = uparea.copy() 209 | subbas = np.zeros(idxs_ds.size, dtype=np.uint32) 210 | idxs = [] 211 | for idx in seq: # down- to upstream 212 | idx_ds = idxs_ds[idx] 213 | if idx_ds == idx: 214 | idxs.append(idx) 215 | subbas[idx] = len(idxs) 216 | continue 217 | upa0 = upa_out[idx_ds] 218 | upa = uparea[idx] 219 | if (upa0 - upa) > area_min and upa > area_min: 220 | conf = (uparea[idx_ds] - upa) > area_min 221 | trib = idxs_us_main[idx_ds] != idx 222 | if not conf or trib: 223 | idxs.append(idx) 224 | subbas[idx] = len(idxs) 225 | upa_out[idx] = upa 226 | if trib: 227 | idx1 = idxs_us_main[idx_ds] # main stem 228 | upa_out[idx_ds] -= upa 229 | upa_out[idx1] = upa_out[idx_ds] 230 | else: 231 | upa_out[idx] = upa0 232 | idxs1 = np.array(idxs, dtype=idxs_ds.dtype) 233 | return core.fillnodata_upstream(idxs_ds, seq, subbas, 0), idxs1 234 | -------------------------------------------------------------------------------- /examples/basins.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Delineation of (sub)basins" 8 | ] 9 | }, 10 | { 11 | "cell_type": "markdown", 12 | "metadata": {}, 13 | "source": [ 14 | "Here we assume that flow directions are known. We read the flow direction raster data, including meta-data, using [rasterio](https://rasterio.readthedocs.io/en/latest/) and parse it to a pyflwdir `FlwDirRaster` object, see earlier examples for more background." 15 | ] 16 | }, 17 | { 18 | "cell_type": "code", 19 | "execution_count": null, 20 | "metadata": {}, 21 | "outputs": [], 22 | "source": [ 23 | "# import pyflwdir, some dependencies\n", 24 | "import geopandas as gpd\n", 25 | "import numpy as np\n", 26 | "import rasterio\n", 27 | "import pyflwdir\n", 28 | "\n", 29 | "# local convenience methods (see utils.py script in notebooks folder)\n", 30 | "from utils import vectorize # convenience method to vectorize rasters\n", 31 | "from utils import quickplot, colors, cm # data specific quick plot method\n", 32 | "\n", 33 | "# read and parse data\n", 34 | "with rasterio.open(\"rhine_d8.tif\", \"r\") as src:\n", 35 | " flwdir = src.read(1)\n", 36 | " crs = src.crs\n", 37 | " flw = pyflwdir.from_array(\n", 38 | " flwdir,\n", 39 | " ftype=\"d8\",\n", 40 | " transform=src.transform,\n", 41 | " latlon=crs.is_geographic,\n", 42 | " cache=True,\n", 43 | " )" 44 | ] 45 | }, 46 | { 47 | "cell_type": "markdown", 48 | "metadata": {}, 49 | "source": [ 50 | "## Outlet based (sub)basins" 51 | ] 52 | }, 53 | { 54 | "cell_type": "markdown", 55 | "metadata": {}, 56 | "source": [ 57 | "The [basins()](https://deltares.github.io/pyflwdir/latest/_generated/pyflwdir.FlwdirRaster.basins.html) method delineates (sub)basins defined by its outlet location. \n", 58 | "By default the method uses pits from the flow direction raster as outlets to delineate basins, but if outlet locations are profided these are used instead. An additional `streams` argument can be added to make sure the outlet locations are snapped to the nearest downstream stream cell, using the [snap()](https://deltares.github.io/pyflwdir/latest/_generated/pyflwdir.FlwdirRaster.snap.html method under the hood. Here streams are defined by a minimum Strahler stream order of 4." 59 | ] 60 | }, 61 | { 62 | "cell_type": "code", 63 | "execution_count": null, 64 | "metadata": {}, 65 | "outputs": [], 66 | "source": [ 67 | "# define output locations\n", 68 | "x, y = np.array([4.67916667, 7.60416667]), np.array([51.72083333, 50.3625])\n", 69 | "gdf_out = gpd.GeoSeries(gpd.points_from_xy(x, y, crs=4326))\n", 70 | "# delineate subbasins\n", 71 | "subbasins = flw.basins(xy=(x, y), streams=flw.stream_order() >= 4)\n", 72 | "# vectorize subbasins using the vectorize convenience method from utils.py\n", 73 | "gdf_bas = vectorize(subbasins.astype(np.int32), 0, flw.transform, name=\"basin\")\n", 74 | "gdf_bas.head()" 75 | ] 76 | }, 77 | { 78 | "cell_type": "code", 79 | "execution_count": null, 80 | "metadata": {}, 81 | "outputs": [], 82 | "source": [ 83 | "# plot\n", 84 | "# key-word arguments passed to GeoDataFrame.plot()\n", 85 | "gpd_plot_kwds = dict(\n", 86 | " column=\"basin\",\n", 87 | " cmap=cm.Set3,\n", 88 | " legend=True,\n", 89 | " categorical=True,\n", 90 | " legend_kwds=dict(title=\"Basin ID [-]\"),\n", 91 | " alpha=0.5,\n", 92 | " edgecolor=\"black\",\n", 93 | " linewidth=0.8,\n", 94 | ")\n", 95 | "points = (gdf_out, dict(color=\"red\", markersize=20))\n", 96 | "bas = (gdf_bas, gpd_plot_kwds)\n", 97 | "# plot using quickplot convenience method from utils.py\n", 98 | "ax = quickplot([bas, points], title=\"Basins from point outlets\", filename=\"flw_basins\")" 99 | ] 100 | }, 101 | { 102 | "cell_type": "markdown", 103 | "metadata": {}, 104 | "source": [ 105 | "## Stream order subbasins" 106 | ] 107 | }, 108 | { 109 | "cell_type": "markdown", 110 | "metadata": {}, 111 | "source": [ 112 | "The [subbasins_streamorder()](https://deltares.github.io/pyflwdir/latest/_generated/pyflwdir.FlwdirRaster.subbasins_streamorder.html) method creates subbasins at all confluences where each branch has a minimal stream order set by `min_sto`. An optional mask can be added to select a subset of the outlets (i.e. confluences) which are located inside the mask." 113 | ] 114 | }, 115 | { 116 | "cell_type": "code", 117 | "execution_count": null, 118 | "metadata": {}, 119 | "outputs": [], 120 | "source": [ 121 | "# calculate subbasins with a minimum stream order 7 and its outlets\n", 122 | "subbas, idxs_out = flw.subbasins_streamorder(min_sto=7, mask=None)\n", 123 | "# transfrom map and point locations to GeoDataFrames\n", 124 | "gdf_subbas = vectorize(subbas.astype(np.int32), 0, flw.transform, name=\"basin\")\n", 125 | "gdf_out = gpd.GeoSeries(gpd.points_from_xy(*flw.xy(idxs_out), crs=4326))\n", 126 | "# plot\n", 127 | "gpd_plot_kwds = dict(\n", 128 | " column=\"basin\", cmap=cm.Set3, edgecolor=\"black\", alpha=0.6, linewidth=0.5\n", 129 | ")\n", 130 | "bas = (gdf_subbas, gpd_plot_kwds)\n", 131 | "points = (gdf_out, dict(color=\"k\", markersize=20))\n", 132 | "title = \"Subbasins based on a minimum stream order\"\n", 133 | "ax = quickplot([bas, points], title=title, filename=\"flw_subbasins\")" 134 | ] 135 | }, 136 | { 137 | "cell_type": "markdown", 138 | "metadata": {}, 139 | "source": [ 140 | "## Pfafstetter subbasins" 141 | ] 142 | }, 143 | { 144 | "cell_type": "markdown", 145 | "metadata": {}, 146 | "source": [ 147 | "The [subbasins_pfafstetter()](https://deltares.github.io/pyflwdir/latest/_generated/pyflwdir.FlwdirRaster.subbasins_pfafstetter.html) method creates subbasins with the hierarchical pfafstetter coding system. It is designed such that topological information is embedded in the code, which makes it easy to determine whether a subbasin is downstream of another subbasin. At each level the four largest subbasins have even numbers and five largest interbasins have odd numbers. The `depth` argument is used to set the number of subbasin levels, i.e.: `depth=1` nine, and with `depth=2` 81 sub/interbasins are found. The `subbasins_pfafstetter` method requires upstream area of each cell, which is calculated on the fly if not provided with the `uparea` argument." 148 | ] 149 | }, 150 | { 151 | "cell_type": "code", 152 | "execution_count": null, 153 | "metadata": {}, 154 | "outputs": [], 155 | "source": [ 156 | "# get the first level nine pfafstetter basins\n", 157 | "pfafbas1, idxs_out = flw.subbasins_pfafstetter(depth=1)\n", 158 | "# vectorize raster to obtain polygons\n", 159 | "gdf_pfaf1 = vectorize(pfafbas1.astype(np.int32), 0, flw.transform, name=\"pfaf\")\n", 160 | "gdf_out = gpd.GeoSeries(gpd.points_from_xy(*flw.xy(idxs_out), crs=4326))\n", 161 | "gdf_pfaf1.head()" 162 | ] 163 | }, 164 | { 165 | "cell_type": "code", 166 | "execution_count": null, 167 | "metadata": {}, 168 | "outputs": [], 169 | "source": [ 170 | "# plot\n", 171 | "gpd_plot_kwds = dict(\n", 172 | " column=\"pfaf\",\n", 173 | " cmap=cm.Set3_r,\n", 174 | " legend=True,\n", 175 | " categorical=True,\n", 176 | " legend_kwds=dict(title=\"Pfafstetter \\nlevel 1 index [-]\", ncol=3),\n", 177 | " alpha=0.6,\n", 178 | " edgecolor=\"black\",\n", 179 | " linewidth=0.4,\n", 180 | ")\n", 181 | "\n", 182 | "points = (gdf_out, dict(color=\"k\", markersize=20))\n", 183 | "bas = (gdf_pfaf1, gpd_plot_kwds)\n", 184 | "title = \"Subbasins based on pfafstetter coding (level=1)\"\n", 185 | "ax = quickplot([bas, points], title=title, filename=\"flw_pfafbas1\")" 186 | ] 187 | }, 188 | { 189 | "cell_type": "code", 190 | "execution_count": null, 191 | "metadata": {}, 192 | "outputs": [], 193 | "source": [ 194 | "# lets create a second pfafstetter layer with a minimum subbasin area of 5000 km2\n", 195 | "pfafbas2, idxs_out = flw.subbasins_pfafstetter(depth=2, upa_min=5000)\n", 196 | "gdf_pfaf2 = vectorize(pfafbas2.astype(np.int32), 0, flw.transform, name=\"pfaf2\")\n", 197 | "gdf_out = gpd.GeoSeries(gpd.points_from_xy(*flw.xy(idxs_out), crs=4326))\n", 198 | "gdf_pfaf2[\"pfaf\"] = gdf_pfaf2[\"pfaf2\"] // 10\n", 199 | "gdf_pfaf2.head()" 200 | ] 201 | }, 202 | { 203 | "cell_type": "code", 204 | "execution_count": null, 205 | "metadata": {}, 206 | "outputs": [], 207 | "source": [ 208 | "# plot\n", 209 | "bas = (gdf_pfaf2, gpd_plot_kwds)\n", 210 | "points = (gdf_out, dict(color=\"k\", markersize=20))\n", 211 | "title = \"Subbasins based on pfafstetter coding (level=2)\"\n", 212 | "ax = quickplot([bas, points], title=title, filename=\"flw_pfafbas2\")" 213 | ] 214 | }, 215 | { 216 | "cell_type": "markdown", 217 | "metadata": {}, 218 | "source": [ 219 | "## Minimal area based subbasins" 220 | ] 221 | }, 222 | { 223 | "cell_type": "markdown", 224 | "metadata": {}, 225 | "source": [ 226 | "The [subbasins_area()](https://deltares.github.io/pyflwdir/latest/_generated/pyflwdir.FlwdirRaster.subbasins_area.html) method creates subbasins with a minimal area of `area_min`.\n", 227 | "Moving upstream from the basin outlets a new subbasin starts at tributaries with a contributing area larger than `area_min` and new interbasins when its area exceeds the `area_min`." 228 | ] 229 | }, 230 | { 231 | "cell_type": "code", 232 | "execution_count": null, 233 | "metadata": {}, 234 | "outputs": [], 235 | "source": [ 236 | "# calculate subbasins with a minimum stream order 7 and its outlets\n", 237 | "min_area = 2000\n", 238 | "subbas, idxs_out = flw.subbasins_area(min_area)\n", 239 | "# transfrom map and point locations to GeoDataFrames\n", 240 | "gdf_subbas = vectorize(subbas.astype(np.int32), 0, flw.transform, name=\"basin\")\n", 241 | "# randomize index for visualization\n", 242 | "basids = gdf_subbas[\"basin\"].values\n", 243 | "gdf_subbas[\"color\"] = np.random.choice(basids, size=basids.size, replace=False)\n", 244 | "# plot\n", 245 | "gpd_plot_kwds = dict(\n", 246 | " column=\"color\", cmap=cm.Set3, edgecolor=\"black\", alpha=0.6, linewidth=0.5\n", 247 | ")\n", 248 | "bas = (gdf_subbas, gpd_plot_kwds)\n", 249 | "title = f\"Subbasins based on a minimum area of {min_area} km2\"\n", 250 | "ax = quickplot([bas], title=title, filename=\"flw_subbasins_area\")" 251 | ] 252 | } 253 | ], 254 | "metadata": { 255 | "kernelspec": { 256 | "display_name": "default", 257 | "language": "python", 258 | "name": "python3" 259 | }, 260 | "language_info": { 261 | "codemirror_mode": { 262 | "name": "ipython", 263 | "version": 3 264 | }, 265 | "file_extension": ".py", 266 | "mimetype": "text/x-python", 267 | "name": "python", 268 | "nbconvert_exporter": "python", 269 | "pygments_lexer": "ipython3", 270 | "version": "3.11.11" 271 | } 272 | }, 273 | "nbformat": 4, 274 | "nbformat_minor": 4 275 | } 276 | -------------------------------------------------------------------------------- /pyflwdir/streams.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Methods to derive maps of basin/stream characteristics. These methods require 3 | the basin indices to be ordered from down- to upstream.""" 4 | 5 | from numba import njit 6 | import numpy as np 7 | 8 | # import local libraries 9 | from . import gis_utils, core 10 | 11 | __all__ = [] 12 | 13 | 14 | # general methods 15 | @njit 16 | def accuflux(idxs_ds, seq, data, nodata): 17 | """Returns maps of accumulate upstream 18 | 19 | Parameters 20 | ---------- 21 | idxs_ds : 1D-array of intp 22 | index of next downstream cell 23 | seq : 1D array of int 24 | ordered cell indices from down- to upstream 25 | data : 1D array 26 | local values to be accumulated 27 | nodata : float, integer 28 | nodata value 29 | 30 | Returns 31 | ------- 32 | 1D array of data.dtype 33 | accumulated upstream data 34 | """ 35 | # intialize output with correct dtype 36 | accu = data.copy() 37 | for idx0 in seq[::-1]: # up- to downstream 38 | idx_ds = idxs_ds[idx0] 39 | if idx0 != idx_ds and accu[idx_ds] != nodata and accu[idx0] != nodata: 40 | accu[idx_ds] += accu[idx0] 41 | return accu 42 | 43 | 44 | @njit 45 | def accuflux_ds(idxs_ds, seq, data, nodata): 46 | """Returns maps of accumulate downstream 47 | 48 | Parameters 49 | ---------- 50 | idxs_ds : 1D-array of intp 51 | index of next downstream cell 52 | seq : 1D array of int 53 | ordered cell indices from down- to upstream 54 | data : 1D array 55 | local values to be accumulated 56 | nodata : float, integer 57 | nodata value 58 | 59 | Returns 60 | ------- 61 | 1D array of data.dtype 62 | accumulated upstream data 63 | """ 64 | # intialize output with correct dtype 65 | accu = data.copy() 66 | for idx0 in seq: # down- to upstream 67 | idx_ds = idxs_ds[idx0] 68 | if idx0 != idx_ds and accu[idx_ds] != nodata and accu[idx0] != nodata: 69 | accu[idx0] += accu[idx_ds] 70 | return accu 71 | 72 | 73 | @njit() 74 | def upstream_area( 75 | idxs_ds, 76 | seq, 77 | ncol, 78 | latlon=False, 79 | transform=gis_utils.IDENTITY, 80 | area_factor=1, 81 | nodata=-9999.0, 82 | dtype=np.float64, 83 | ): 84 | """Returns the accumulated upstream area, invalid cells are assinged a the nodata 85 | value. The arae is calculated using the transform. If latlon is True, the resolution 86 | is interpreted in degree and transformed to m2. 87 | 88 | NOTE: does not require area grid in memory 89 | 90 | Parameters 91 | ---------- 92 | idxs_ds : 1D-array of intp 93 | index of next downstream cell 94 | seq : 1D array of int 95 | ordered cell indices from down- to upstream 96 | ncol : int 97 | number of columns in raster 98 | latlon : bool, optional 99 | True if WGS84 coordinates, by default False 100 | transform : affine transform 101 | two dimensional transform for 2D linear mapping, by default gis_utils.IDENTITY 102 | area_factor : float, optional 103 | multiplication factor for unit conversion, by default 1 104 | nodata : float, optional 105 | nodata value, by default -9999.0 106 | dtype : numpy.dtype, optional 107 | data output type, by default numpy.float64 108 | 109 | Returns 110 | ------- 111 | 1D array of 112 | accumulated upstream area 113 | """ 114 | # intialize uparea with correct dtype 115 | uparea = np.full(idxs_ds.size, nodata, dtype=dtype) 116 | # local area 117 | xres, yres, north = transform[0], transform[4], transform[5] 118 | if latlon: 119 | for idx in seq: 120 | lat = north + (idx // ncol + 0.5) * yres 121 | uparea[idx] = gis_utils.cellarea(lat, xres, yres) / area_factor 122 | else: 123 | uparea[seq] = abs(xres * yres) / area_factor 124 | # accumulate upstream area 125 | for idx0 in seq[::-1]: # up- to downstream 126 | idx_ds = idxs_ds[idx0] 127 | if idx0 != idx_ds: 128 | uparea[idx_ds] += uparea[idx0] 129 | return uparea 130 | 131 | 132 | @njit 133 | def streams(idxs_ds, seq, mask=None, max_len=0, mv=core._mv): 134 | """Returns list of linear indices per stream of equal stream order. 135 | 136 | Parameters 137 | ---------- 138 | idxs_ds : 1D-array of intp 139 | index of next downstream cell 140 | seq : 1D array of int 141 | ordered cell indices from down- to upstream 142 | mask : 1D-array of bool, optional 143 | Mask of stream cells 144 | max_len: int, optional 145 | Maximum length of a single stream segment measured in cells 146 | Longer streams segments are divided into smaller segments of equal length 147 | as close as possible to max_len. 148 | 149 | Returns 150 | ------- 151 | streams : list of 1D-arrays of intp 152 | linear indices of streams 153 | """ 154 | nup = core.upstream_count(idxs_ds=idxs_ds, mask=mask, mv=mv) 155 | # get list of indices arrays of segments 156 | streams = [] 157 | done = np.zeros(idxs_ds.size, dtype=np.bool_) 158 | for idx0 in seq[::-1]: # up- to downstream 159 | if done[idx0] or (mask is not None and ~mask[idx0]): 160 | continue 161 | idxs = [idx0] # initiate with correct dtype 162 | while True: 163 | done[idx0] = True 164 | idx_ds = idxs_ds[idx0] 165 | pit = idx_ds == idx0 166 | if not pit: 167 | idxs.append(idx_ds) 168 | if nup[idx_ds] > 1 or pit: 169 | l = len(idxs) 170 | if l > max_len > 0: 171 | n, k = l, 1 172 | if (l / max_len) > 1.5: 173 | k = round(l / max_len) 174 | n = round(l / k) 175 | for i in range(k): # split into k segments with overlapping point 176 | if i + 1 == k: 177 | streams.append(np.array(idxs[i * n :], dtype=idxs_ds.dtype)) 178 | else: 179 | _idxs = idxs[i * n : n * (i + 1) + 1] 180 | streams.append(np.array(_idxs, dtype=idxs_ds.dtype)) 181 | else: 182 | streams.append(np.array(idxs, dtype=idxs_ds.dtype)) 183 | # CHANGED in v0.5.2: add zero length LineString at pits 184 | if pit: 185 | streams.append(np.array([idx_ds, idx_ds], dtype=idxs_ds.dtype)) 186 | break 187 | idx0 = idx_ds 188 | return streams 189 | 190 | 191 | @njit 192 | def stream_order(idxs_ds, seq, idxs_us_main, mask=None, mv=core._mv): 193 | """Returns the classic or Hack's "bottum up" stream order. 194 | 195 | The main stem, based on upstream area has order 1. 196 | Each tributary is given a number one greater than that of the 197 | river or stream into which they discharge. 198 | 199 | Parameters 200 | ---------- 201 | idxs_ds : 1D-array of intp 202 | index of next downstream cell 203 | seq : 1D array of int 204 | ordered cell indices from down- to upstream 205 | mask : 1D-array of bool, optional 206 | True if stream cell 207 | 208 | Returns 209 | ------- 210 | 1D array of uint8 211 | stream order 212 | """ 213 | nup = core.upstream_count(idxs_ds=idxs_ds, mask=mask, mv=mv) 214 | strord = np.full(idxs_ds.size, 0, dtype=np.uint8) 215 | for idx0 in seq: # down- to upstream 216 | if mask is not None and not mask[idx0]: # invalid cell 217 | continue 218 | idx_ds = idxs_ds[idx0] 219 | if idx_ds == idx0: # pit 220 | strord[idx0] = 1 221 | elif nup[idx_ds] > 1 and idxs_us_main[idx_ds] != idx0: 222 | strord[idx0] = strord[idx_ds] + 1 223 | else: 224 | strord[idx0] = strord[idx_ds] 225 | return strord 226 | 227 | 228 | @njit 229 | def strahler_order(idxs_ds, seq, mask=None): 230 | """Returns the strahler "top down" stream order. 231 | 232 | Rivers of the first order are the most upstream tributaries or head water cells. 233 | If two streams of the same order merge, the resulting stream has an order of one higher. 234 | If two rivers with different stream orders merge, the resulting stream is given the maximum of the two order. 235 | 236 | Parameters 237 | ---------- 238 | idxs_ds : 1D-array of intp 239 | index of next downstream cell 240 | seq : 1D array of int 241 | ordered cell indices from down- to upstream 242 | mask : 1D-array of bool, optional 243 | True if stream cell 244 | 245 | Returns 246 | ------- 247 | 1D array of uint8 248 | stream order 249 | """ 250 | strord = np.full(idxs_ds.size, 0, dtype=np.uint8) 251 | strmax = np.full(idxs_ds.size, 0, dtype=np.uint8) 252 | for idx0 in seq[::-1]: # up- to downstream 253 | if mask is not None and not mask[idx0]: # invalid 254 | continue 255 | if strord[idx0] == 0: # headwater cell 256 | strord[idx0] = 1 257 | idx_ds = idxs_ds[idx0] 258 | if idx_ds == idx0: 259 | continue 260 | sto, sto_ds = strord[idx0], strord[idx_ds] 261 | sto_up = strmax[idx_ds] # max order of other upstream tributaries 262 | if sto_ds < sto: 263 | strord[idx_ds] = sto 264 | # increment order if same order joins 265 | elif sto == sto_ds and sto_up == sto: 266 | strord[idx_ds] += 1 267 | if sto_up < sto: 268 | strmax[idx_ds] = sto 269 | return strord 270 | 271 | 272 | def stream_distance( 273 | idxs_ds, 274 | seq, 275 | ncol, 276 | mask=None, 277 | real_length=True, 278 | latlon=False, 279 | transform=gis_utils.IDENTITY, 280 | ): 281 | """Returns distance to outlet or next downstream True cell in mask 282 | 283 | Parameters 284 | ---------- 285 | idxs_ds : 1D-array of intp 286 | index of next downstream cell 287 | seq : 1D array of int 288 | ordered cell indices from down- to upstream 289 | ncol : int 290 | number of columns in raster 291 | mask : 1D-array of bool, optional 292 | True if stream cell 293 | latlon : bool, optional 294 | True if WGS84 coordinates, by default False 295 | transform : affine transform 296 | Two dimensional transform for 2D linear mapping, by default gis_utils.IDENTITY 297 | 298 | Returns 299 | ------- 300 | 1D array of float 301 | distance to outlet or next downstream True cell 302 | """ 303 | mv = -9999.0 304 | dist = np.full(idxs_ds.size, mv, dtype=np.float32 if real_length else np.int32) 305 | dist[seq] = 0 # initialize valid cells with zero length 306 | d = 1 307 | for idx0 in seq: # down- to upstream 308 | idx_ds = idxs_ds[idx0] 309 | # sum distances; skip if at pit or mask is True 310 | if idx0 == idx_ds or (mask is not None and mask[idx0]): 311 | continue 312 | if real_length: 313 | d = gis_utils.distance(idx0, idx_ds, ncol, latlon, transform) 314 | dist[idx0] = dist[idx_ds] + d 315 | return dist 316 | 317 | 318 | @njit 319 | def smooth_rivlen( 320 | idxs_ds, 321 | idxs_us_main, 322 | rivlen, 323 | min_rivlen, 324 | max_window=10, 325 | nodata=-9999.0, 326 | mv=core._mv, 327 | ): 328 | """Return smoothed river length, by taking the window average of river length. 329 | The window size is increased until the average exceeds the `min_rivlen` threshold 330 | or the max_window size is reached. 331 | 332 | Parameters 333 | ---------- 334 | rivlen : 1D array 335 | River length values. 336 | min_rivlen : float 337 | Minimum river length. 338 | max_window : int 339 | maximum window size 340 | 341 | Returns 342 | ------- 343 | 1D array of float 344 | River length values. 345 | """ 346 | rivlen_out = rivlen.copy() 347 | n = max_window // 2 348 | for idx0 in range(rivlen.size): 349 | len0 = rivlen_out[idx0] 350 | if len0 != nodata and len0 < min_rivlen: 351 | len_avg1 = len0 352 | idxs = core._window(idx0, n, idxs_ds, idxs_us_main, mv=mv) 353 | # smooth over increasing window until min river length is reached 354 | for i in range(1, n): 355 | idxs0 = idxs[n - i : n + i + 1] 356 | idxs0 = idxs0[idxs0 != mv] 357 | idxs0 = idxs0[rivlen_out[idxs0] != nodata] 358 | len_avg0 = np.mean(rivlen_out[idxs0]) 359 | if len_avg0 > len_avg1: 360 | idxs1 = idxs0 361 | len_avg1 = len_avg0 362 | # break at smallest window if average > min_rivlen 363 | if len_avg1 > min_rivlen: 364 | break 365 | # replace lengths in window with average 366 | if len_avg1 > len0: 367 | rivlen_out[idxs1] = len_avg1 368 | 369 | return rivlen_out 370 | --------------------------------------------------------------------------------