From b4e4e388c317d5e417e4825a6ad3967c26958693 Mon Sep 17 00:00:00 2001 From: Duncan Martyn Date: Mon, 25 Mar 2024 10:10:29 +0000 Subject: [PATCH] Amendments to KDE calculation for rare but possible 0.0 distance value. Removed mypy as dev dependency, added coverage. Added tests - >=95% coverage on _utils and geokde. Added lint, test, and publish GH Actions YAML files. --- .github/workflows/lint.yaml | 27 +++++++ .github/workflows/publish.yaml | 29 +++++++ .github/workflows/test.yaml | 62 +++++++++++++++ .pre-commit-config.yaml | 13 ++-- README.md | 53 +++++++++++-- geokde/_kernels.py | 20 +++-- geokde/_utils.py | 19 ++--- geokde/geokde.py | 42 ++++++++-- poetry.lock | 135 ++++++++++++++++----------------- pyproject.toml | 4 +- tests/conftest.py | 22 ++++++ tests/test_geokde.py | 40 ++++++++++ tests/test_kernels.py | 27 +++++++ tests/test_utils.py | 31 ++++++++ 14 files changed, 412 insertions(+), 112 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_geokde.py create mode 100644 tests/test_kernels.py create mode 100644 tests/test_utils.py diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index e69de29..1823790 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -0,0 +1,27 @@ +--- +name: Lint +on: + push: + paths: + - geokde/** + - .github/** + - tests/** +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set-up Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + + - name: Install Poetry and dependencies + run: | + pip install poetry + poetry install + + - name: Run pre-commit + run: poetry run pre-commit run --all-files diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index e69de29..fc6b3c9 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -0,0 +1,29 @@ +--- +name: Publish +on: + release: + types: [published] +permissions: + contents: read +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set-up Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + - name: Install Poetry + run: pip install poetry + + - name: Set PyPI token + run: poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }} + + - name: Install dependencies + run: poetry install + + - name: Publish to PyPI + run: poetry publish --build diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e69de29..01cb284 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -0,0 +1,62 @@ +--- +name: Test +on: + push: + paths: + - geokde/** + - .github/** + - tests/** +jobs: + test: + name: ${{ matrix.os }} / ${{ matrix.python-version }} + runs-on: ${{ matrix.image }} + strategy: + matrix: + os: + - Ubuntu + - macOS + - Windows + python-version: + - '3.10' + include: + - os: Ubuntu + image: ubuntu-latest + - os: Windows + image: windows-latest + - os: macOS + image: macos-latest + fail-fast: false + defaults: + run: + shell: bash + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set-up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + run: curl -sL https://install.python-poetry.org | python - -y + + - name: Update PATH for Ubuntu and MacOS + if: ${{ matrix.os != 'Windows' }} + run: echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Update Path for Windows + if: ${{ matrix.os == 'Windows' }} + run: echo "$APPDATA\Python\Scripts" >> $GITHUB_PATH + + - name: Configure Poetry + run: poetry config virtualenvs.in-project true + + - name: Check Poetry lock + run: poetry check --lock + + - name: Install dependencies + run: poetry install + + - name: Run pytest + run: poetry run pytest -s diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 24a9937..f27390b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,14 +6,13 @@ repos: - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files - - id: check-toml - repo: https://github.com/PyCQA/bandit rev: 1.7.7 hooks: - id: bandit -- repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.971 - hooks: - - id: mypy - types: [python] - args: [--strict] +# - repo: https://github.com/pre-commit/mirrors-mypy +# rev: v0.971 +# hooks: +# - id: mypy +# types: [python] +# args: [--strict] diff --git a/README.md b/README.md index 30d61e2..ff048ce 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,49 @@ -## Roadmap -------- +![pypi version](https://img.shields.io/pypi/v/geokde) +![pypi downloads](https://img.shields.io/pypi/dm/geokde) +[![publish](https://github.com/duncanmartyn/geokde/actions/workflows/publish.yaml/badge.svg?branch=main)](https://github.com/duncanmartyn/geokde/actions/workflows/publish.yaml) +[![test](https://github.com/duncanmartyn/geokde/actions/workflows/test.yaml/badge.svg?branch=main)](https://github.com/duncanmartyn/geokde/actions/workflows/test.yaml) +[![security: bandit](https://img.shields.io/badge/security-bandit-yellow.svg)](https://github.com/PyCQA/bandit) + +# GeoKDE +Package for geospatial kernel density estimation (KDE). + +Written in Python 3.10.11 (though compatible with 3.10.11+), GeoKDE depends on the following: +- `geopandas` +- `numpy` (itself a dependency of `geopandas`) + +# Examples +Perform KDE on a GeoJSON of point geometries and write the result to a GeoTIFF raster file with `rasterio`: +``` +gdf = geopandas.read_file("vector_points.geojson") +kde_array, array_bounds = geokde.kde(gdf, 1, 0.1) +transform = rasterio.transform.from_bounds( + *array_bounds, + kde_array.shape[1], + kde_array.shape[0], +) + +with rasterio.open( + fp="raster.tif", + mode="w", + driver="GTiff", + width=kde_array.shape[1], + height=kde_array.shape[0], + count=1, + crs=gdf.crs, + transform=transform, + dtype=kde_array.dtype, + nodata=0.0, +) as dst: + dst.write(kde_array, 1) +``` + +# Roadmap - Add more kernels. -- Implement other methods of distance measurement, e.g. haversine, manhattan. -- Investigate possible alternatives to iterating over points. -- Enable use of single radius and weight values without filling array of the same length as the points GeoDataFrame/GeoSeries. Results in marginal speed up but the current approach may become an issue with large point datasets. +- Finish tests - coverage is >=95% for _utils.py and geokde.py as is. +- Implement other methods of distance measurement, e.g. haversine, Manhattan. +- Investigate alternatives to iterating over points. +- Enable use of single radius and weight values without filling array of the same length as the points GeoDataFrame/GeoSeries. Results in marginal speed up but the current approach may become an issue with very large point datasets. +- Integrate mypy in pre-commit, possibly also linter and formatter though flake8 and black used locally. + +# Contributions +Feel free to raise any issues, especially bugs and feature requests! diff --git a/geokde/_kernels.py b/geokde/_kernels.py index 345dba9..3dcd40a 100644 --- a/geokde/_kernels.py +++ b/geokde/_kernels.py @@ -19,8 +19,6 @@ Specified kernel's density estimation value. """ -from math import pi - import numpy as np @@ -38,7 +36,7 @@ def quartic_raw( weight: int | float, ) -> float: """Raw Quartic kernel.""" - if distance: + if distance < radius: value = weight * pow(1 - pow(distance / radius, 2), 2) else: value = 0.0 @@ -52,8 +50,8 @@ def quartic_scaled( weight: int | float, ) -> float: """Scaled Quartic kernel.""" - if distance: - norm_const = 116 / (5 * pi * pow(radius, 2)) + if distance < radius: + norm_const = 116 / (5 * np.pi * pow(radius, 2)) value = weight * (norm_const * (15 / 16) * pow(1 - pow(distance / radius, 2), 2)) else: value = 0.0 @@ -67,7 +65,7 @@ def epanechnikov_raw( weight: int | float, ) -> float: """Raw Epanechnikov kernel.""" - if distance: + if distance < radius: value = weight * (1 - pow(distance / radius, 2)) else: value = 0.0 @@ -81,8 +79,8 @@ def epanechnikov_scaled( weight: int | float, ) -> float: """Scaled Epanechnikov kernel.""" - if distance: - norm_const = 8 / (3 * pi * pow(radius, 2)) + if distance < radius: + norm_const = 8 / (3 * np.pi * pow(radius, 2)) value = weight * (norm_const * (3 / 4) * (1 - pow(distance / radius, 2))) else: value = 0.0 @@ -96,7 +94,7 @@ def triweight_raw( weight: int | float, ) -> float: """Raw triweight kernel.""" - if distance: + if distance < radius: value = weight * pow(1 - pow(distance / radius, 2), 3) else: value = 0.0 @@ -110,8 +108,8 @@ def triweight_scaled( weight: int | float, ) -> float: """Scaled triweight kernel.""" - if distance: - norm_const = 128 / (35 * pi * pow(radius, 2)) + if distance < radius: + norm_const = 128 / (35 * np.pi * pow(radius, 2)) value = weight * (norm_const * (35 / 32) * pow(1 - pow(distance / radius, 2), 3)) else: value = 0.0 diff --git a/geokde/_utils.py b/geokde/_utils.py index 3b17c62..525529b 100644 --- a/geokde/_utils.py +++ b/geokde/_utils.py @@ -1,10 +1,8 @@ -from typing import Callable - import geopandas as gpd import numpy as np import pandas as pd -from _kernels import ( +from geokde._kernels import ( epanechnikov_raw, epanechnikov_scaled, quartic_raw, @@ -193,18 +191,18 @@ def calculate_kde( array : numpy.ndarray Array to which KDE values will be added. kernel : str - Kernel function with which to perform KDE. + Kernel with which to perform KDE. scale : bool Whether to calculate raw or scaled KDE values. """ - kernel_funcs = { + kernel_vfuncs = { "epanechnikov": epanechnikov_scaled if scale else epanechnikov_raw, "quartic": quartic_scaled if scale else quartic_raw, "triweight": triweight_scaled if scale else triweight_raw, } # challenge to vectorise as needs to operate on >1 array element for point in points: - add_point_kde(*point, array, kernel_funcs[kernel]) + add_point_kde(*point, array, kernel_vfuncs[kernel]) def add_point_kde( @@ -213,7 +211,7 @@ def add_point_kde( window: float, weight: int | float, array: np.ndarray, - kernel_func: Callable, + kernel_vfunc: np.vectorize, ) -> None: """Perform KDE for a given point, window, and weight, adding the result to an array. @@ -229,8 +227,8 @@ def add_point_kde( Value with which the KDE value for a point will be weighted. array : numpy.ndarray Array to which KDE values will be added. - kernel_func : typing.Callable - Kernel function with which to perform KDE. + kernel_vfunc : numpy.vectorize + Vectorised kernel function with which to perform KDE. """ minx = round(x - window) miny = round(y - window) @@ -238,6 +236,5 @@ def add_point_kde( maxy = round(y + window) y_idx, x_idx = np.ogrid[miny + .5:maxy + .5, minx + .5:maxx + .5] dist_array = np.sqrt(pow(x_idx - x, 2) + pow(y_idx - y, 2)) - dist_array[dist_array >= window] = 0.0 - kde_array = kernel_func(dist_array, window, weight) + kde_array = kernel_vfunc(dist_array, window, weight) array[miny:maxy, minx:maxx] += kde_array diff --git a/geokde/geokde.py b/geokde/geokde.py index 0056580..79dfa10 100644 --- a/geokde/geokde.py +++ b/geokde/geokde.py @@ -1,17 +1,17 @@ import geopandas as gpd import numpy as np -from _utils import ( +from geokde._kernels import VALID_KERNELS +from geokde._utils import ( adjust_bounds, calculate_kde, create_array, get_points, validate_transform, ) -from _kernels import VALID_KERNELS -def kernel_density_estimation( +def kde( points: gpd.GeoDataFrame | gpd.GeoSeries, radius: int | float | str, resolution: int | float, @@ -51,20 +51,50 @@ def kernel_density_estimation( bounds : list[int | float] The array's bounding coordinates in minx, miny, maxx, maxy format. + Raises + ------ + ValueError + If the specified kernel is invalid or resolution is greater than the maximum + specified radius. + TypeError + If any geometries are not a point or scale is not boolean. + Examples -------- - # writing result etc. + Writing results with rasterio: + + >>> gdf = geopandas.read_file("vector_points.geojson") + >>> kde_array, array_bounds = geokde.kde(gdf, 1, 0.1) + >>> transform = rasterio.transform.from_bounds( + *array_bounds, + kde_array.shape[1], + kde_array.shape[0], + ) + + >>> with rasterio.open( + fp="raster.tif", + mode="w", + driver="GTiff", + width=kde_array.shape[1], + height=kde_array.shape[0], + count=1, + crs=gdf.crs, + transform=transform, + dtype=kde_array.dtype, + nodata=0.0, + ) as dst: + dst.write(kde_array, 1) """ if not all(points.geometry.geom_type == "Point"): raise TypeError("all geometries must be points.") - if resolution > radius: - raise ValueError("resolution must be greater than radius.") if kernel not in VALID_KERNELS: raise ValueError(f"kernel must be one of {VALID_KERNELS}, not: {kernel}") if not isinstance(scale, bool): raise TypeError(f"scale must be bool, not: {type(scale)}") radius = validate_transform(radius, "radius", points) weight = validate_transform(weight, "weight", points) + if resolution > radius.max(): + raise ValueError("resolution must be less than radius.") bounds = adjust_bounds(*points.total_bounds, radius.max()) array = create_array(*bounds, resolution) diff --git a/poetry.lock b/poetry.lock index 48496ac..bc16dfa 100644 --- a/poetry.lock +++ b/poetry.lock @@ -100,6 +100,70 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "coverage" +version = "7.4.4" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "coverage-7.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0be5efd5127542ef31f165de269f77560d6cdef525fffa446de6f7e9186cfb2"}, + {file = "coverage-7.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ccd341521be3d1b3daeb41960ae94a5e87abe2f46f17224ba5d6f2b8398016cf"}, + {file = "coverage-7.4.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fa497a8ab37784fbb20ab699c246053ac294d13fc7eb40ec007a5043ec91f8"}, + {file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b1a93009cb80730c9bca5d6d4665494b725b6e8e157c1cb7f2db5b4b122ea562"}, + {file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:690db6517f09336559dc0b5f55342df62370a48f5469fabf502db2c6d1cffcd2"}, + {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:09c3255458533cb76ef55da8cc49ffab9e33f083739c8bd4f58e79fecfe288f7"}, + {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8ce1415194b4a6bd0cdcc3a1dfbf58b63f910dcb7330fe15bdff542c56949f87"}, + {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b91cbc4b195444e7e258ba27ac33769c41b94967919f10037e6355e998af255c"}, + {file = "coverage-7.4.4-cp310-cp310-win32.whl", hash = "sha256:598825b51b81c808cb6f078dcb972f96af96b078faa47af7dfcdf282835baa8d"}, + {file = "coverage-7.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:09ef9199ed6653989ebbcaacc9b62b514bb63ea2f90256e71fea3ed74bd8ff6f"}, + {file = "coverage-7.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0f9f50e7ef2a71e2fae92774c99170eb8304e3fdf9c8c3c7ae9bab3e7229c5cf"}, + {file = "coverage-7.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:623512f8ba53c422fcfb2ce68362c97945095b864cda94a92edbaf5994201083"}, + {file = "coverage-7.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0513b9508b93da4e1716744ef6ebc507aff016ba115ffe8ecff744d1322a7b63"}, + {file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40209e141059b9370a2657c9b15607815359ab3ef9918f0196b6fccce8d3230f"}, + {file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a2b2b78c78293782fd3767d53e6474582f62443d0504b1554370bde86cc8227"}, + {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:73bfb9c09951125d06ee473bed216e2c3742f530fc5acc1383883125de76d9cd"}, + {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1f384c3cc76aeedce208643697fb3e8437604b512255de6d18dae3f27655a384"}, + {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:54eb8d1bf7cacfbf2a3186019bcf01d11c666bd495ed18717162f7eb1e9dd00b"}, + {file = "coverage-7.4.4-cp311-cp311-win32.whl", hash = "sha256:cac99918c7bba15302a2d81f0312c08054a3359eaa1929c7e4b26ebe41e9b286"}, + {file = "coverage-7.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:b14706df8b2de49869ae03a5ccbc211f4041750cd4a66f698df89d44f4bd30ec"}, + {file = "coverage-7.4.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:201bef2eea65e0e9c56343115ba3814e896afe6d36ffd37bab783261db430f76"}, + {file = "coverage-7.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41c9c5f3de16b903b610d09650e5e27adbfa7f500302718c9ffd1c12cf9d6818"}, + {file = "coverage-7.4.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d898fe162d26929b5960e4e138651f7427048e72c853607f2b200909794ed978"}, + {file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ea79bb50e805cd6ac058dfa3b5c8f6c040cb87fe83de10845857f5535d1db70"}, + {file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce4b94265ca988c3f8e479e741693d143026632672e3ff924f25fab50518dd51"}, + {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:00838a35b882694afda09f85e469c96367daa3f3f2b097d846a7216993d37f4c"}, + {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:fdfafb32984684eb03c2d83e1e51f64f0906b11e64482df3c5db936ce3839d48"}, + {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:69eb372f7e2ece89f14751fbcbe470295d73ed41ecd37ca36ed2eb47512a6ab9"}, + {file = "coverage-7.4.4-cp312-cp312-win32.whl", hash = "sha256:137eb07173141545e07403cca94ab625cc1cc6bc4c1e97b6e3846270e7e1fea0"}, + {file = "coverage-7.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:d71eec7d83298f1af3326ce0ff1d0ea83c7cb98f72b577097f9083b20bdaf05e"}, + {file = "coverage-7.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d5ae728ff3b5401cc320d792866987e7e7e880e6ebd24433b70a33b643bb0384"}, + {file = "coverage-7.4.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cc4f1358cb0c78edef3ed237ef2c86056206bb8d9140e73b6b89fbcfcbdd40e1"}, + {file = "coverage-7.4.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8130a2aa2acb8788e0b56938786c33c7c98562697bf9f4c7d6e8e5e3a0501e4a"}, + {file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf271892d13e43bc2b51e6908ec9a6a5094a4df1d8af0bfc360088ee6c684409"}, + {file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4cdc86d54b5da0df6d3d3a2f0b710949286094c3a6700c21e9015932b81447e"}, + {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ae71e7ddb7a413dd60052e90528f2f65270aad4b509563af6d03d53e979feafd"}, + {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:38dd60d7bf242c4ed5b38e094baf6401faa114fc09e9e6632374388a404f98e7"}, + {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa5b1c1bfc28384f1f53b69a023d789f72b2e0ab1b3787aae16992a7ca21056c"}, + {file = "coverage-7.4.4-cp38-cp38-win32.whl", hash = "sha256:dfa8fe35a0bb90382837b238fff375de15f0dcdb9ae68ff85f7a63649c98527e"}, + {file = "coverage-7.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:b2991665420a803495e0b90a79233c1433d6ed77ef282e8e152a324bbbc5e0c8"}, + {file = "coverage-7.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3b799445b9f7ee8bf299cfaed6f5b226c0037b74886a4e11515e569b36fe310d"}, + {file = "coverage-7.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b4d33f418f46362995f1e9d4f3a35a1b6322cb959c31d88ae56b0298e1c22357"}, + {file = "coverage-7.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aadacf9a2f407a4688d700e4ebab33a7e2e408f2ca04dbf4aef17585389eff3e"}, + {file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c95949560050d04d46b919301826525597f07b33beba6187d04fa64d47ac82e"}, + {file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff7687ca3d7028d8a5f0ebae95a6e4827c5616b31a4ee1192bdfde697db110d4"}, + {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5fc1de20b2d4a061b3df27ab9b7c7111e9a710f10dc2b84d33a4ab25065994ec"}, + {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c74880fc64d4958159fbd537a091d2a585448a8f8508bf248d72112723974cbd"}, + {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:742a76a12aa45b44d236815d282b03cfb1de3b4323f3e4ec933acfae08e54ade"}, + {file = "coverage-7.4.4-cp39-cp39-win32.whl", hash = "sha256:d89d7b2974cae412400e88f35d86af72208e1ede1a541954af5d944a8ba46c57"}, + {file = "coverage-7.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:9ca28a302acb19b6af89e90f33ee3e1906961f94b54ea37de6737b7ca9d8827c"}, + {file = "coverage-7.4.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:b2c5edc4ac10a7ef6605a966c58929ec6c1bd0917fb8c15cb3363f65aa40e677"}, + {file = "coverage-7.4.4.tar.gz", hash = "sha256:c901df83d097649e257e803be22592aedfd5182f07b3cc87d640bbb9afd50f49"}, +] + +[package.extras] +toml = ["tomli"] + [[package]] name = "distlib" version = "0.3.8" @@ -231,64 +295,6 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] -[[package]] -name = "mypy" -version = "1.9.0" -description = "Optional static typing for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "mypy-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f8a67616990062232ee4c3952f41c779afac41405806042a8126fe96e098419f"}, - {file = "mypy-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d357423fa57a489e8c47b7c85dfb96698caba13d66e086b412298a1a0ea3b0ed"}, - {file = "mypy-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49c87c15aed320de9b438ae7b00c1ac91cd393c1b854c2ce538e2a72d55df150"}, - {file = "mypy-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:48533cdd345c3c2e5ef48ba3b0d3880b257b423e7995dada04248725c6f77374"}, - {file = "mypy-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:4d3dbd346cfec7cb98e6cbb6e0f3c23618af826316188d587d1c1bc34f0ede03"}, - {file = "mypy-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:653265f9a2784db65bfca694d1edd23093ce49740b2244cde583aeb134c008f3"}, - {file = "mypy-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a3c007ff3ee90f69cf0a15cbcdf0995749569b86b6d2f327af01fd1b8aee9dc"}, - {file = "mypy-1.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2418488264eb41f69cc64a69a745fad4a8f86649af4b1041a4c64ee61fc61129"}, - {file = "mypy-1.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:68edad3dc7d70f2f17ae4c6c1b9471a56138ca22722487eebacfd1eb5321d612"}, - {file = "mypy-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:85ca5fcc24f0b4aeedc1d02f93707bccc04733f21d41c88334c5482219b1ccb3"}, - {file = "mypy-1.9.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd"}, - {file = "mypy-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6"}, - {file = "mypy-1.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185"}, - {file = "mypy-1.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913"}, - {file = "mypy-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6"}, - {file = "mypy-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e54396d70be04b34f31d2edf3362c1edd023246c82f1730bbf8768c28db5361b"}, - {file = "mypy-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5e6061f44f2313b94f920e91b204ec600982961e07a17e0f6cd83371cb23f5c2"}, - {file = "mypy-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a10926e5473c5fc3da8abb04119a1f5811a236dc3a38d92015cb1e6ba4cb9e"}, - {file = "mypy-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b685154e22e4e9199fc95f298661deea28aaede5ae16ccc8cbb1045e716b3e04"}, - {file = "mypy-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:5d741d3fc7c4da608764073089e5f58ef6352bedc223ff58f2f038c2c4698a89"}, - {file = "mypy-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:587ce887f75dd9700252a3abbc9c97bbe165a4a630597845c61279cf32dfbf02"}, - {file = "mypy-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f88566144752999351725ac623471661c9d1cd8caa0134ff98cceeea181789f4"}, - {file = "mypy-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61758fabd58ce4b0720ae1e2fea5cfd4431591d6d590b197775329264f86311d"}, - {file = "mypy-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e49499be624dead83927e70c756970a0bc8240e9f769389cdf5714b0784ca6bf"}, - {file = "mypy-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:571741dc4194b4f82d344b15e8837e8c5fcc462d66d076748142327626a1b6e9"}, - {file = "mypy-1.9.0-py3-none-any.whl", hash = "sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e"}, - {file = "mypy-1.9.0.tar.gz", hash = "sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974"}, -] - -[package.dependencies] -mypy-extensions = ">=1.0.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = ">=4.1.0" - -[package.extras] -dmypy = ["psutil (>=4.0)"] -install-types = ["pip"] -mypyc = ["setuptools (>=50)"] -reports = ["lxml"] - -[[package]] -name = "mypy-extensions" -version = "1.0.0" -description = "Type system extensions for programs checked with the mypy type checker." -optional = false -python-versions = ">=3.5" -files = [ - {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, - {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, -] - [[package]] name = "nodeenv" version = "1.8.0" @@ -721,17 +727,6 @@ files = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] -[[package]] -name = "typing-extensions" -version = "4.10.0" -description = "Backported and Experimental Type Hints for Python 3.8+" -optional = false -python-versions = ">=3.8" -files = [ - {file = "typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475"}, - {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"}, -] - [[package]] name = "tzdata" version = "2024.1" @@ -766,4 +761,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "c2aec94d2d42c1c2dd85f14a5ee8facc3bde0424c6204a67241e7e74c97ae74f" +content-hash = "c1856365fd234989ce6427353fbe111420bdc6d26e3032858a90c6a13154fcfa" diff --git a/pyproject.toml b/pyproject.toml index ab18676..ef0d339 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "geokde" version = "0.1.0" -description = "" +description = "Package for geospatial kernel density estimation (KDE)." authors = ["Duncan Martyn "] readme = "README.md" @@ -13,7 +13,7 @@ geopandas = "^0.14.3" [tool.poetry.group.dev.dependencies] pre-commit = "^3.6.2" pytest = "^8.1.1" -mypy = "^1.9.0" +coverage = "^7.4.4" [build-system] requires = ["poetry-core"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c6896f9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,22 @@ +from typing import Callable + +import geopandas as gpd +import pytest +import shapely + + +@pytest.fixture +def geodataframe() -> Callable: + def _create(geom_type: str) -> gpd.GeoDataFrame: + if geom_type == "points": + points = [(0, 0), (10, 10)] + geoms = [shapely.Point(*coords) for coords in points] + if geom_type == "polygons": + polygons = [(-180, 0, 0, 90), (0, 0, 90, 180)] + geoms = [shapely.box(*coords) for coords in polygons] + + gdf = gpd.GeoDataFrame(geometry=geoms, crs=4326) + gdf["radius"] = 1 + gdf["weight"] = 1 + return gdf + return _create diff --git a/tests/test_geokde.py b/tests/test_geokde.py new file mode 100644 index 0000000..63c3799 --- /dev/null +++ b/tests/test_geokde.py @@ -0,0 +1,40 @@ +import numpy as np +import pytest + +from geokde.geokde import kde + + +@pytest.mark.parametrize( + ["geom_type", "radius", "resolution", "kernel", "weight", "scale", "error"], + [ + # all valid + ("points", 1, 0.1, "quartic", 1, False, None), + # invalid geoms + ("polygons", 1, 0.1, "quartic", 1, False, TypeError), + # invalid kernel + ("points", 1, 0.1, "invalid_kernel", 1, False, ValueError), + # invalid scale + ("points", 1, 0.1, "quartic", 1, "invalid_scale", TypeError), + # all valid, gdf column "radius" as KDE radii + ("points", "radius", 0.1, "quartic", 1, False, None), + # gdf column of type object as radii + ("points", "radius", 0.1, "quartic", 1, False, TypeError), + # incomplete gdf column as radii + ("points", "radius", 0.1, "quartic", 1, False, ValueError), + # needs test for max radius lt resolution + ], +) +def test_kde(geodataframe, geom_type, radius, resolution, kernel, weight, scale, error): + gdf = geodataframe(geom_type) + if error == TypeError and isinstance(radius, str): + gdf.radius = gdf.radius.astype(str) + if error == ValueError and isinstance(radius, str): + gdf.at[0, "radius"] = None + if error: + with pytest.raises(error): + kde(gdf, radius, resolution, kernel, weight, scale) + else: + array, bounds = kde(gdf, radius, resolution, kernel, weight, scale) + assert isinstance(array, np.ndarray) # nosec + assert isinstance(bounds, list) # nosec + # TODO: more detailed assertions diff --git a/tests/test_kernels.py b/tests/test_kernels.py new file mode 100644 index 0000000..141c342 --- /dev/null +++ b/tests/test_kernels.py @@ -0,0 +1,27 @@ +import pytest + +from geokde import _kernels + +KERNEL_VFUNCS = [ + _kernels.epanechnikov_raw, + _kernels.epanechnikov_scaled, + _kernels.quartic_raw, + _kernels.quartic_scaled, + _kernels.triweight_raw, + _kernels.triweight_scaled, +] + + +@pytest.mark.parametrize( + ["distance", "radius", "weight", "expected"], + [ + (0, 10, 1, 1), + (0, 10, 2, 2), + (10, 10, 1, 0), + (20, 10, 1, 0), + ] +) +def test_kernel(distance, radius, weight, expected): + for kernel_vfunc in KERNEL_VFUNCS: + result = kernel_vfunc(distance, radius, weight) + assert result <= expected # nosec diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..5b292b6 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,31 @@ +import numpy as np + +from geokde._utils import ( + adjust_bounds, + create_array, + validate_transform, +) + + +def test_validate_transform(geodataframe): + points = geodataframe("points") + result = validate_transform("radius", "radius", points) + assert isinstance(result, np.ndarray) # nosec + assert len(result) == len(points) # nosec + + +def test_adjust_bounds(): + radius = 10 + bounds = [-10, -10, 10, 10] + expected = [-20, -20, 20, 20] + result = adjust_bounds(*bounds, radius) + assert result == expected # nosec + + +def test_create_array(): + resolution = 1 + bounds = [-10, -10, 10, 10] + array = create_array(*bounds, resolution) + assert array.shape == (20, 20) # nosec + +# TODO: final three _utils fn tests, though all covered by test_geokde