├── .editorconfig ├── .github └── ISSUE_TEMPLATE.md ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── demquery ├── __init__.py ├── cli.py └── demquery.py ├── environment.yml ├── requirements.txt ├── requirements_dev.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py └── test_demquery.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*{.yaml,.yml}] 14 | indent_size = 2 15 | 16 | [*.bat] 17 | indent_style = tab 18 | end_of_line = crlf 19 | 20 | [LICENSE] 21 | insert_final_newline = false 22 | 23 | [Makefile] 24 | indent_style = tab 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * demquery version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | 104 | # IDE settings 105 | .vscode/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Config file for automatic testing at travis-ci.org 2 | language: python 3 | python: 4 | # We don't actually use the Travis Python, but this keeps it organized. 5 | - "3.6" 6 | - "3.7" 7 | - "3.8" 8 | before_install: 9 | - sudo apt-get update 10 | - wget https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh -O miniconda.sh; 11 | - bash miniconda.sh -b -p $HOME/miniconda 12 | - export PATH="$HOME/miniconda/bin:$PATH" 13 | - hash -r 14 | - conda config --set always_yes yes --set show_channel_urls true --set changeps1 no 15 | - conda update -q conda 16 | # Useful for debugging any issues with conda 17 | - conda info -a 18 | 19 | - conda config --prepend channels conda-forge 20 | - conda create -q -n TEST python=$TRAVIS_PYTHON_VERSION --strict-channel-priority --file requirements.txt --file requirements_dev.txt 21 | - source activate TEST 22 | - conda info --all 23 | 24 | install: 25 | - python setup.py install 26 | - conda list 27 | 28 | script: 29 | - pytest 30 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.3.1] - 2020-08-19 4 | 5 | - No changes: try to get conda-forge package to work correctly 6 | 7 | ## [0.3.0] - 2020-01-28 8 | 9 | - Add CLI script 10 | 11 | ## [0.2.1] - 2019-12-04 12 | 13 | - Include requirements.txt and requirements_dev.txt in manifest bundle 14 | 15 | ## [0.2.0] - 2019-12-02 16 | 17 | - Fix virtual raster issues. 18 | 19 | ## [0.1.0] - 2019-11-27 20 | 21 | - Initial release on PyPI 22 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every little bit 8 | helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/kylebarron/demquery/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | Fix Bugs 27 | ~~~~~~~~ 28 | 29 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 30 | wanted" is open to whoever wants to implement it. 31 | 32 | Implement Features 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | Look through the GitHub issues for features. Anything tagged with "enhancement" 36 | and "help wanted" is open to whoever wants to implement it. 37 | 38 | Write Documentation 39 | ~~~~~~~~~~~~~~~~~~~ 40 | 41 | demquery could always use more documentation, whether as part of the 42 | official demquery docs, in docstrings, or even on the web in blog posts, 43 | articles, and such. 44 | 45 | Submit Feedback 46 | ~~~~~~~~~~~~~~~ 47 | 48 | The best way to send feedback is to file an issue at https://github.com/kylebarron/demquery/issues. 49 | 50 | If you are proposing a feature: 51 | 52 | * Explain in detail how it would work. 53 | * Keep the scope as narrow as possible, to make it easier to implement. 54 | * Remember that this is a volunteer-driven project, and that contributions 55 | are welcome :) 56 | 57 | Get Started! 58 | ------------ 59 | 60 | Ready to contribute? Here's how to set up `demquery` for local development. 61 | 62 | 1. Fork the `demquery` repo on GitHub. 63 | 2. Clone your fork locally:: 64 | 65 | $ git clone git@github.com:your_name_here/demquery.git 66 | 67 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 68 | 69 | $ mkvirtualenv demquery 70 | $ cd demquery/ 71 | $ python setup.py develop 72 | 73 | 4. Create a branch for local development:: 74 | 75 | $ git checkout -b name-of-your-bugfix-or-feature 76 | 77 | Now you can make your changes locally. 78 | 79 | 5. When you're done making changes, check that your changes pass flake8 and the 80 | tests, including testing other Python versions with tox:: 81 | 82 | $ flake8 demquery tests 83 | $ python setup.py test or pytest 84 | $ tox 85 | 86 | To get flake8 and tox, just pip install them into your virtualenv. 87 | 88 | 6. Commit your changes and push your branch to GitHub:: 89 | 90 | $ git add . 91 | $ git commit -m "Your detailed description of your changes." 92 | $ git push origin name-of-your-bugfix-or-feature 93 | 94 | 7. Submit a pull request through the GitHub website. 95 | 96 | Pull Request Guidelines 97 | ----------------------- 98 | 99 | Before you submit a pull request, check that it meets these guidelines: 100 | 101 | 1. The pull request should include tests. 102 | 2. If the pull request adds functionality, the docs should be updated. Put 103 | your new functionality into a function with a docstring, and add the 104 | feature to the list in README.rst. 105 | 3. The pull request should work for Python 3.5, 3.6, 3.7 and 3.8, and for PyPy. Check 106 | https://travis-ci.org/kylebarron/demquery/pull_requests 107 | and make sure that the tests pass for all supported Python versions. 108 | 109 | Tips 110 | ---- 111 | 112 | To run a subset of tests:: 113 | 114 | $ pytest tests.test_demquery 115 | 116 | 117 | Deploying 118 | --------- 119 | 120 | A reminder for the maintainers on how to deploy. 121 | Make sure all your changes are committed (including an entry in HISTORY.rst). 122 | Then run:: 123 | 124 | $ bump2version patch # possible: major / minor / patch 125 | $ git push 126 | $ git push --tags 127 | 128 | Travis will then deploy to PyPI if tests pass. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019, Kyle Barron 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 | 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGELOG.md 2 | include CONTRIBUTING.rst 3 | include LICENSE 4 | include README.md 5 | include Makefile 6 | include requirements.txt 7 | include requirements_dev.txt 8 | 9 | recursive-include tests * 10 | recursive-exclude * __pycache__ 11 | recursive-exclude * *.py[co] 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean clean-test clean-pyc clean-build docs help 2 | .DEFAULT_GOAL := help 3 | 4 | define BROWSER_PYSCRIPT 5 | import os, webbrowser, sys 6 | 7 | from urllib.request import pathname2url 8 | 9 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 10 | endef 11 | export BROWSER_PYSCRIPT 12 | 13 | define PRINT_HELP_PYSCRIPT 14 | import re, sys 15 | 16 | for line in sys.stdin: 17 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 18 | if match: 19 | target, help = match.groups() 20 | print("%-20s %s" % (target, help)) 21 | endef 22 | export PRINT_HELP_PYSCRIPT 23 | 24 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 25 | 26 | help: 27 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 28 | 29 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 30 | 31 | clean-build: ## remove build artifacts 32 | rm -fr build/ 33 | rm -fr dist/ 34 | rm -fr .eggs/ 35 | find . -name '*.egg-info' -exec rm -fr {} + 36 | find . -name '*.egg' -exec rm -f {} + 37 | 38 | clean-pyc: ## remove Python file artifacts 39 | find . -name '*.pyc' -exec rm -f {} + 40 | find . -name '*.pyo' -exec rm -f {} + 41 | find . -name '*~' -exec rm -f {} + 42 | find . -name '__pycache__' -exec rm -fr {} + 43 | 44 | clean-test: ## remove test and coverage artifacts 45 | rm -fr .tox/ 46 | rm -f .coverage 47 | rm -fr htmlcov/ 48 | rm -fr .pytest_cache 49 | 50 | lint: ## check style with flake8 51 | flake8 demquery tests 52 | 53 | test: ## run tests quickly with the default Python 54 | pytest 55 | 56 | test-all: ## run tests on every Python version with tox 57 | tox 58 | 59 | coverage: ## check code coverage quickly with the default Python 60 | coverage run --source demquery -m pytest 61 | coverage report -m 62 | coverage html 63 | $(BROWSER) htmlcov/index.html 64 | 65 | docs: ## generate Sphinx HTML documentation, including API docs 66 | rm -f docs/demquery.rst 67 | rm -f docs/modules.rst 68 | sphinx-apidoc -o docs/ demquery 69 | $(MAKE) -C docs clean 70 | $(MAKE) -C docs html 71 | $(BROWSER) docs/_build/html/index.html 72 | 73 | servedocs: docs ## compile the docs watching for changes 74 | watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . 75 | 76 | release: dist ## package and upload a release 77 | twine upload dist/* 78 | 79 | dist: clean ## builds source and wheel package 80 | python setup.py sdist 81 | python setup.py bdist_wheel 82 | ls -l dist 83 | 84 | install: clean ## install the package to the active Python's site-packages 85 | python setup.py install 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # demquery 2 | 3 | 4 | [![Pypi](https://img.shields.io/pypi/v/demquery.svg)](https://pypi.python.org/pypi/demquery) [![Downloads](https://img.shields.io/travis/kylebarron/demquery.svg)](https://travis-ci.org/kylebarron/demquery) [![Supported Python Versions](https://img.shields.io/pypi/pyversions/demquery.svg)](https://pypi.org/project/demquery/#supported-versions) 5 | 6 | Wrapper around rasterio to query points on a Digital Elevation Model. 7 | 8 | ## Features 9 | 10 | - Use multiple raster files without having to merge them into a new file 11 | - Query many points at once 12 | - Optional 2D interpolation (linear, cubic, or quintic) 13 | - Reasonably performant by reading the minimal data required from raster 14 | 15 | ## Install 16 | 17 | I recommend first installing dependencies with Conda, then installing demquery 18 | itself with pip. 19 | 20 | ``` 21 | conda install gdal rasterio numpy scipy -c conda-forge 22 | ``` 23 | 24 | ``` 25 | pip install demquery 26 | ``` 27 | 28 | ## CLI Script 29 | 30 | ``` 31 | > demquery --help 32 | Usage: demquery [OPTIONS] FEATURES... 33 | 34 | Assign elevations to GeoJSON 35 | 36 | Options: 37 | -d, --dem PATH Paths to DEM files. [required] 38 | -g, --dem-glob TEXT Glob expression for DEM paths if folder is provided. 39 | -b, --band INTEGER Band of rasters to use [default: 1] 40 | -i, --interp-kind TEXT either None, "linear", "cubic", or "quintic". None 41 | will do no interpolation and choose the value in the 42 | DEM closest to the provided point. linear creates a 43 | 3x3 grid and runs linear interpolation; cubic 44 | creates a 5x5 grid and runs cubic interpolation; 45 | quintic creates a 7x7 grid and runs quintic 46 | interpolation. 47 | --help Show this message and exit. 48 | ``` 49 | 50 | ```bash 51 | echo \ 52 | '{"type":"Feature","properties":{"name": "Glacier Peak"},"geometry":{"type":"Point","coordinates":[-121.2436843,48.0163834]}}' \ 53 | | demquery -d /path/to/dem/files 54 | ``` 55 | Outputs: 56 | ```json 57 | {"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [-121.243684, 48.016383, 1431.5755615234375]}, "properties": {"name": "Glacier Peak"}}]} 58 | ``` 59 | 60 | ## Documentation 61 | 62 | ```py 63 | from demquery import Query 64 | 65 | dem_paths = ['dem1.tif', 'dem2.tif'] 66 | query = Query(dem_paths) 67 | 68 | # Points must be in longitude, latitude order! 69 | # These points are in Manhattan, not Antarctica 70 | points = [(-73.985564, 40.757965), (-73.968520, 40.778912)] 71 | elevations = query.query_points(points, interp_kind='linear') 72 | ``` 73 | 74 | ## Data Download 75 | 76 | For a great visual tool to download worldwide SRTM data, check out these sites: 77 | 78 | - 30m resolution: http://dwtkns.com/srtm30m/ 79 | - 90m resolution: http://dwtkns.com/srtm/ 80 | 81 | ## Releasing 82 | 83 | To upload a new release to PyPI 84 | 85 | ```bash 86 | python setup.py sdist 87 | twine upload dist/demquery-0.3.0.tar.gz 88 | ``` 89 | -------------------------------------------------------------------------------- /demquery/__init__.py: -------------------------------------------------------------------------------- 1 | """Top-level package for demquery.""" 2 | 3 | __author__ = """Kyle Barron""" 4 | __email__ = 'kylebarron2@gmail.com' 5 | __version__ = '0.3.1' 6 | 7 | from .demquery import Query 8 | -------------------------------------------------------------------------------- /demquery/cli.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | import click 5 | import cligj 6 | import geojson 7 | 8 | from .demquery import NoDataException, Query 9 | 10 | 11 | @click.command() 12 | @cligj.features_in_arg 13 | @click.option( 14 | '-d', 15 | '--dem', 16 | type=click.Path(exists=True, file_okay=True, dir_okay=True, readable=True), 17 | required=True, 18 | help='Paths to DEM files.') 19 | @click.option( 20 | '-g', 21 | '--dem-glob', 22 | type=str, 23 | required=False, 24 | default=None, 25 | help='Glob expression for DEM paths if folder is provided.') 26 | @click.option( 27 | '-b', 28 | '--band', 29 | type=int, 30 | required=False, 31 | default=1, 32 | show_default=True, 33 | help='Band of rasters to use') 34 | @click.option( 35 | '-i', 36 | '--interp-kind', 37 | type=str, 38 | required=False, 39 | default=None, 40 | show_default=True, 41 | help= 42 | 'either None, "linear", "cubic", or "quintic". None will do no interpolation and choose the value in the DEM closest to the provided point. linear creates a 3x3 grid and runs linear interpolation; cubic creates a 5x5 grid and runs cubic interpolation; quintic creates a 7x7 grid and runs quintic interpolation.' 43 | ) 44 | def main(features, dem, dem_glob, band, interp_kind): 45 | """Assign elevations to GeoJSON 46 | """ 47 | dem_path = Path(dem) 48 | if dem_path.is_dir(): 49 | if dem_glob is not None: 50 | dem_paths = list(dem_path.glob(dem_glob)) 51 | else: 52 | dem_paths = list(dem_path.iterdir()) 53 | else: 54 | dem_paths = [dem_path] 55 | 56 | query = Query(dem_paths=dem_paths, band=band) 57 | click.echo( 58 | json.dumps({ 59 | 'type': 'FeatureCollection', 60 | 'features': list(process_features(features, query, interp_kind)) 61 | })) 62 | 63 | 64 | def process_features(features, query, interp_kind): 65 | """Assign elevations to individual GeoJSON features 66 | """ 67 | for feature in features: 68 | f = geojson.loads(json.dumps(feature)) 69 | yield geojson.utils.map_tuples( 70 | lambda t: _add_elevation_to_tuple( 71 | t, query=query, interp_kind=interp_kind), f) 72 | 73 | 74 | def _add_elevation_to_tuple(t, query, interp_kind): 75 | try: 76 | ele = query.query_points([t], interp_kind=interp_kind)[0] 77 | except NoDataException: 78 | if len(t) == 3: 79 | ele = t[2] 80 | else: 81 | ele = -9999 82 | 83 | return (t[0], t[1], ele) 84 | 85 | 86 | if __name__ == '__main__': 87 | main() 88 | -------------------------------------------------------------------------------- /demquery/demquery.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # Module: demquery.py 3 | # Description: Wrapper around rasterio to query a Digital Elevation Model 4 | # License: MIT, see full license in LICENSE 5 | # Web: https://github.com/kylebarron/demquery 6 | ################################################################################ 7 | 8 | import os 9 | import os.path 10 | import tempfile 11 | from pathlib import Path 12 | 13 | import numpy as np 14 | import rasterio 15 | from osgeo import gdal 16 | from scipy.interpolate import interp2d 17 | 18 | 19 | class NoDataException(Exception): 20 | pass 21 | 22 | 23 | class Query: 24 | def __init__(self, dem_paths, band=1): 25 | """Query Digital Elevation Model 26 | 27 | Parameters 28 | ---------- 29 | dem_paths : list 30 | list of paths to DEM files. DEM files can be any format readable by 31 | GDAL. 32 | band : int 33 | band of DEM file to query data from; 1 by default. 34 | """ 35 | super(Query, self).__init__() 36 | self.band = band 37 | 38 | if len(dem_paths) > 1: 39 | self.dem_path = self._build_vrt(dem_paths=dem_paths) 40 | else: 41 | self.dem_path = dem_paths[0] 42 | 43 | def query_points(self, points, interp_kind=None): 44 | """Query points in DEM 45 | 46 | Parameters 47 | ---------- 48 | points : list of float or int 49 | list of tuples **in longitude, latitude order** representing points 50 | to query from the DEM 51 | interp_kind : None or str 52 | one of None, 'linear', 'cubic', 'quintic'. None will do no 53 | interpolation and choose the value in the DEM closest to the 54 | provided point. linear creates a 3x3 grid and runs linear 55 | interpolation; cubic creates a 5x5 grid and runs cubic 56 | interpolation; quintic creates a 7x7 grid and runs quintic 57 | interpolation 58 | 59 | Returns 60 | ------- 61 | List[float]: queried elevation values, in the units of the DEM 62 | """ 63 | # interp_kind: num_buffer (number of bordering cells required for 64 | # interpolation) 65 | interp_allowed = {None: 0, 'linear': 1, 'cubic': 2, 'quintic': 3} 66 | num_buffer = interp_allowed.get(interp_kind) 67 | if num_buffer is None: 68 | msg = ( 69 | 'interp_kind must be one of ' + 70 | ', '.join(map(str, interp_allowed.keys()))) 71 | raise ValueError(msg) 72 | 73 | with rasterio.open(self.dem_path) as dem: 74 | self._check_bounds(dem, points, num_buffer=num_buffer) 75 | 76 | # This must be a list comprehension and not a generator, because 77 | # with a generator, when it tries to create the values, the dem 78 | # object is already closed. 79 | return [ 80 | self._query_point( 81 | dem, point, num_buffer=num_buffer, interp_kind=interp_kind) 82 | for point in points 83 | ] 84 | 85 | def _build_vrt(self, dem_paths): 86 | """Create virtual raster using gdal 87 | 88 | Parameters 89 | ---------- 90 | dem_paths : list 91 | list of strings or pathlib.Path to DEM paths 92 | 93 | Returns 94 | ------- 95 | str : path to virtual raster file 96 | """ 97 | # Make sure all dem_paths exist 98 | # An obscure error is given if the files don't exist 99 | for dem_path in dem_paths: 100 | if not Path(dem_path).exists(): 101 | raise FileNotFoundError(dem_path) 102 | 103 | tmpdir = tempfile.mkdtemp() 104 | vrt_path = os.path.join(tmpdir, 'dem.vrt') 105 | 106 | # Setting vrt to None is weird but required 107 | # https://gis.stackexchange.com/a/314580 108 | # https://gdal.org/tutorials/raster_api_tut.html#using-createcopy 109 | # The dem_paths must be str, not pathlib.Path! 110 | vrt = gdal.BuildVRT(vrt_path, list(map(str, dem_paths))) 111 | vrt = None 112 | 113 | # Check that vrt_path actually was created 114 | if not Path(vrt_path).exists(): 115 | raise ValueError('Unable to create virtual raster') 116 | 117 | return vrt_path 118 | 119 | def _check_bounds(self, dem, points, num_buffer): 120 | """Check lon, lat is within bounds 121 | 122 | Note that this doesn't check that these values are non-missing. With a 123 | mosaic of tiles, the lon/lat could be within bounds of the virtual 124 | raster, but have no data. 125 | 126 | Parameters 127 | ---------- 128 | dem : rasterio.DatasetReader 129 | open rasterio DatasetReader 130 | points : List[tuple] 131 | list of tuples in longitude, latitude order 132 | num_buffer : int 133 | number of bordering cells around point to check 134 | """ 135 | for point in points: 136 | # Split after for line to allow Z in source points 137 | lon, lat = point[0], point[1] 138 | 139 | # Find row, column of elevation square inside raster 140 | # Note that row should be thought of as the "y" value; it's the 141 | # number _across_ rows, and col should be thought of as the "y" 142 | # value _across_ columns. 143 | row, col = dem.index(lon, lat) 144 | minrow, maxrow = row - num_buffer, row + num_buffer 145 | mincol, maxcol = col - num_buffer, col + num_buffer 146 | 147 | msg = 'longitude outside DEM bounds' 148 | msg += '\npoints should be provided in longitude, latitude order.' 149 | assert minrow >= 0, msg 150 | assert maxrow <= dem.height 151 | 152 | msg = 'latitude outside DEM bounds' 153 | msg += '\npoints should be provided in longitude, latitude order.' 154 | assert mincol >= 0, msg 155 | assert maxcol <= dem.width 156 | 157 | def _get_buffer_grid(self, dem, point, num_buffer): 158 | """Get array of longitude, latitude, and elevation values from DEM file 159 | 160 | Parameters 161 | ---------- 162 | dem : rasterio.DatasetReader 163 | open rasterio DatasetReader 164 | point : tuple 165 | tuple of int or float representing longitude and latitude 166 | num_buffer : int 167 | number of bordering cells around point to retrieve 168 | 169 | Returns 170 | ------- 171 | array : 3D Numpy array 172 | (array of longitude values, array of latitude values, array of 173 | elevation values) 174 | """ 175 | # Find row, column of elevation square inside raster 176 | # Note that row should be thought of as the "y" value; it's the number 177 | # _across_ rows, and col should be thought of as the "y" value _across_ 178 | # columns. 179 | lon, lat = point[0], point[1] 180 | row, col = dem.index(lon, lat) 181 | 182 | # Make window include cells around it 183 | # The number of additional cells depends on the value of num_buffer 184 | # When num_buffer==1, an additional 8 cells will be loaded and 185 | # interpolated on; 186 | # When num_buffer==2, an additional 24 cells will be loaded and 187 | # interpolated on, etc. 188 | # When using kind='linear' interpolation, I'm not sure if having the 189 | # extra cells makes a difference; ie if it creates the plane based only 190 | # on the closest cells or from all. When using kind='cubic', it's 191 | # probably more accurate with more cells. 192 | minrow, maxrow = row - num_buffer, row + num_buffer 193 | mincol, maxcol = col - num_buffer, col + num_buffer 194 | 195 | # Add +1 to deal with range() not including end 196 | maxrow += 1 197 | maxcol += 1 198 | 199 | # Retrieve just this window of values from the DEM 200 | window = ([minrow, maxrow], [mincol, maxcol]) 201 | val_arr = dem.read(self.band, window=window) 202 | 203 | # Check the nodata value for the given band against retrieved values 204 | try: 205 | nodataval = dem.nodatavals[self.band - 1] 206 | if np.any(val_arr == nodataval): 207 | msg = ( 208 | 'Raster nodata value found near lon: {}, lat: {}'.format( 209 | lon, lat)) 210 | raise NoDataException(msg) 211 | except IndexError: 212 | # nodataval is not required to exist for each band 213 | pass 214 | 215 | # Check shape 216 | expected_rows = 2 * num_buffer + 1 217 | expected_cols = 2 * num_buffer + 1 218 | msg = 'unexpected array shape' 219 | assert val_arr.shape == (expected_rows, expected_cols), msg 220 | 221 | lons, lats = self._lon_lat_grid(dem, minrow, maxrow, mincol, maxcol) 222 | 223 | # Array with longitudes, latitudes, values 224 | # I.e. x, y, z 225 | return np.array([np.array(lons), np.array(lats), val_arr]) 226 | 227 | def _lon_lat_grid(self, dem, minrow, maxrow, mincol, maxcol): 228 | """Create grids of longitude and latitude values from column indices 229 | 230 | Each value corresponds to the center of the given cell. 231 | 232 | Parameters 233 | ---------- 234 | dem : rasterio.DatasetReader 235 | open rasterio DatasetReader 236 | minrow : int 237 | min row to query 238 | maxrow : int 239 | max row to query 240 | mincol : int 241 | min col to query 242 | maxcol : int 243 | max col to query 244 | 245 | Returns 246 | ------- 247 | List[float]: queried elevation values, in the units of the DEM 248 | """ 249 | # Create array of latitude/longitude pairs for each cell center 250 | lons = [] 251 | lats = [] 252 | for row in range(minrow, maxrow): 253 | lon_cols = [] 254 | lat_cols = [] 255 | for col in range(mincol, maxcol): 256 | lon, lat = dem.xy(row, col) 257 | lon_cols.append(lon) 258 | lat_cols.append(lat) 259 | 260 | lons.append(lon_cols) 261 | lats.append(lat_cols) 262 | 263 | return lons, lats 264 | 265 | def _query_point(self, dem, point, num_buffer, interp_kind): 266 | """Query elevation data for given point 267 | 268 | Parameters 269 | ---------- 270 | dem : rasterio.DatasetReader 271 | point : tuple 272 | tuple of int or float representing longitude and latitude 273 | num_buffer : int 274 | number of bordering cells around point to use when interpolating 275 | interp_kind : str 276 | kind of interpolation. Passed to scipy.interpolate.interp2d. Can be 277 | ['linear', 'cubic', 'quintic']. Note that 'cubic' requires 278 | 'num_buffer' of at least 3 and 'quintic' requires 'num_buffer' of at 279 | least 5. 280 | 281 | Returns 282 | ------- 283 | value : float 284 | elevation in terms of the unit of the DEM (usually meters) 285 | """ 286 | arr = self._get_buffer_grid(dem=dem, point=point, num_buffer=num_buffer) 287 | 288 | # Don't attempt interpolation if not necessary. 289 | # arr[2, 0, 0] selects the single z value. arr[2] is the 2D array of z 290 | # values; there's only one value there so it's [0, 0] 291 | if interp_kind is None: 292 | return arr[2, 0, 0] 293 | 294 | # Take responses and create lists of lat/lons/values to interpolate over 295 | x = arr[0].flatten() 296 | y = arr[1].flatten() 297 | z = arr[2].flatten() 298 | 299 | # Interpolate over the values 300 | # fun() returns an array of length 1 301 | fun = interp2d(x=x, y=y, z=z, kind=interp_kind, bounds_error=True) 302 | return fun(point[0], point[1])[0] 303 | -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: demquery 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - click>=7.0 6 | - cligj>=0.5.0 7 | - gdal 8 | - numpy>=1.16 9 | - rasterio>=1.0 10 | - scipy>=1.0.0 11 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click>=7.0 2 | cligj>=0.5.0 3 | gdal>=2.4.0 4 | numpy>=1.16 5 | rasterio>=1.0 6 | scipy>=1.0.0 7 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | bump2version 2 | coverage 3 | flake8 4 | pip 5 | pytest-runner 6 | pytest 7 | tox 8 | twine 9 | watchdog 10 | wheel 11 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.3.1 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version='{current_version}' 8 | replace = version='{new_version}' 9 | 10 | [bumpversion:file:demquery/__init__.py] 11 | search = __version__ = '{current_version}' 12 | replace = __version__ = '{new_version}' 13 | 14 | [bdist_wheel] 15 | universal = 1 16 | 17 | [flake8] 18 | exclude = docs 19 | 20 | [aliases] 21 | test = pytest 22 | 23 | [tool:pytest] 24 | collect_ignore = ['setup.py'] 25 | 26 | [yapf] 27 | align_closing_bracket_with_visual_indent = False 28 | allow_multiline_dictionary_keys = False 29 | allow_multiline_lambdas = False 30 | allow_split_before_dict_value = True 31 | blank_line_before_class_docstring = False 32 | blank_line_before_nested_class_or_def = False 33 | coalesce_brackets = True 34 | column_limit = 80 35 | continuation_indent_width = 4 36 | dedent_closing_brackets = False 37 | each_dict_entry_on_separate_line = True 38 | i18n_comment = 39 | i18n_function_call = 40 | indent_dictionary_value = True 41 | indent_width = 4 42 | join_multiple_lines = True 43 | no_spaces_around_selected_binary_operators = set() 44 | spaces_around_default_or_named_assign = False 45 | spaces_around_power_operator = True 46 | spaces_before_comment = 2 47 | space_between_ending_comma_and_closing_bracket = True 48 | split_arguments_when_comma_terminated = False 49 | split_before_bitwise_operator = True 50 | split_before_closing_bracket = True 51 | split_before_dict_set_generator = True 52 | split_before_expression_after_opening_paren = False 53 | split_before_first_argument = True 54 | split_before_logical_operator = True 55 | split_before_named_assigns = True 56 | split_complex_comprehension = True 57 | split_penalty_after_opening_bracket = 0 58 | split_penalty_after_unary_operator = 10000 59 | split_penalty_before_if_expr = 30 60 | split_penalty_bitwise_operator = 300 61 | split_penalty_comprehension = 80 62 | split_penalty_excess_character = 4500 63 | split_penalty_for_added_line_split = 30 64 | split_penalty_import_names = 0 65 | split_penalty_logical_operator = 300 66 | use_tabs = False 67 | 68 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """The setup script.""" 3 | 4 | from setuptools import find_packages, setup 5 | 6 | with open('README.md') as f: 7 | readme = f.read() 8 | 9 | with open('CHANGELOG.md') as history_file: 10 | history = history_file.read() 11 | 12 | with open('requirements.txt') as requirements_file: 13 | requirements = requirements_file.readlines() 14 | requirements = [x[:-1] for x in requirements] 15 | 16 | with open('requirements_dev.txt') as test_requirements_file: 17 | test_requirements = test_requirements_file.readlines() 18 | test_requirements = [x[:-1] for x in test_requirements] 19 | 20 | setup_requirements = ['setuptools >= 38.6.0', 'twine >= 1.11.0'] 21 | 22 | # yapf: disable 23 | setup( 24 | author="Kyle Barron", 25 | author_email='kylebarron2@gmail.com', 26 | python_requires='>=3.5', 27 | classifiers=[ 28 | 'Development Status :: 4 - Beta', 29 | 'Intended Audience :: Developers', 30 | 'License :: OSI Approved :: MIT License', 31 | 'Natural Language :: English', 32 | 'Programming Language :: Python :: 3', 33 | 'Programming Language :: Python :: 3.5', 34 | 'Programming Language :: Python :: 3.6', 35 | 'Programming Language :: Python :: 3.7', 36 | 'Programming Language :: Python :: 3.8', 37 | ], 38 | description="Wrapper around rasterio to query points on a Digital Elevation Model", 39 | entry_points={ 40 | 'console_scripts': [ 41 | 'demquery=demquery.cli:main', 42 | ], 43 | }, 44 | install_requires=requirements, 45 | license="MIT license", 46 | long_description=readme + '\n\n' + history, 47 | long_description_content_type='text/markdown', 48 | include_package_data=True, 49 | keywords='demquery', 50 | name='demquery', 51 | packages=find_packages(include=['demquery', 'demquery.*']), 52 | setup_requires=setup_requirements, 53 | test_suite='tests', 54 | tests_require=test_requirements, 55 | url='https://github.com/kylebarron/demquery', 56 | version='0.3.1', 57 | zip_safe=False, 58 | ) 59 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit test package for demquery.""" 2 | -------------------------------------------------------------------------------- /tests/test_demquery.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Tests for `demquery` package.""" 3 | 4 | from urllib.request import urlretrieve 5 | from zipfile import ZipFile 6 | 7 | from demquery import Query 8 | 9 | # Download sample data 10 | stubs = ['USGS_NED_13_n33w117_IMG', 'USGS_NED_13_n34w117_IMG'] 11 | for stub in stubs: 12 | url = 'https://prd-tnm.s3.amazonaws.com/StagedProducts/Elevation/13/IMG/' 13 | url += stub 14 | url += '.zip' 15 | urlretrieve(url, stub + '.zip') 16 | 17 | # Extract file 18 | with ZipFile(stub + '.zip') as z: 19 | z.extractall('.') 20 | 21 | 22 | def test_create_query(): 23 | dem_paths = [x + '.img' for x in stubs] 24 | query = Query(dem_paths) 25 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py35, py36, py37, py38, flake8 3 | 4 | [travis] 5 | python = 6 | 3.8: py38 7 | 3.7: py37 8 | 3.6: py36 9 | 3.5: py35 10 | 11 | [testenv:flake8] 12 | basepython = python 13 | deps = flake8 14 | commands = flake8 demquery 15 | 16 | [testenv] 17 | setenv = 18 | PYTHONPATH = {toxinidir} 19 | deps = 20 | -r{toxinidir}/requirements_dev.txt 21 | ; If you want to make tox run the tests with the same versions, create a 22 | ; requirements.txt with the pinned versions and uncomment the following line: 23 | ; -r{toxinidir}/requirements.txt 24 | commands = 25 | pip install -U pip 26 | pytest --basetemp={envtmpdir} 27 | 28 | --------------------------------------------------------------------------------