rastr 0.5.0__tar.gz → 0.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of rastr might be problematic. Click here for more details.
- {rastr-0.5.0 → rastr-0.7.0}/.github/copilot-instructions.md +3 -1
- {rastr-0.5.0 → rastr-0.7.0}/.github/workflows/ci.yml +20 -4
- rastr-0.7.0/.github/workflows/release.yml +35 -0
- {rastr-0.5.0 → rastr-0.7.0}/PKG-INFO +4 -4
- {rastr-0.5.0 → rastr-0.7.0}/README.md +2 -2
- {rastr-0.5.0 → rastr-0.7.0}/pyproject.toml +2 -1
- rastr-0.7.0/src/rastr/__init__.py +7 -0
- {rastr-0.5.0 → rastr-0.7.0}/src/rastr/_version.py +2 -2
- {rastr-0.5.0 → rastr-0.7.0}/src/rastr/arr/fill.py +3 -2
- {rastr-0.5.0 → rastr-0.7.0}/src/rastr/create.py +42 -53
- rastr-0.7.0/src/rastr/gis/crs.py +67 -0
- {rastr-0.5.0 → rastr-0.7.0}/src/rastr/gis/fishnet.py +14 -2
- rastr-0.7.0/src/rastr/gis/interpolate.py +50 -0
- rastr-0.7.0/src/rastr/gis/smooth.py +161 -0
- {rastr-0.5.0 → rastr-0.7.0}/src/rastr/io.py +37 -11
- rastr-0.7.0/src/rastr/meta.py +185 -0
- {rastr-0.5.0 → rastr-0.7.0}/src/rastr/raster.py +193 -27
- rastr-0.7.0/tests/conftest.py +62 -0
- rastr-0.7.0/tests/rastr/gis/test_crs.py +53 -0
- {rastr-0.5.0 → rastr-0.7.0}/tests/rastr/gis/test_smooth.py +5 -3
- {rastr-0.5.0 → rastr-0.7.0}/tests/rastr/test_create.py +74 -0
- {rastr-0.5.0 → rastr-0.7.0}/tests/rastr/test_io.py +87 -5
- {rastr-0.5.0 → rastr-0.7.0}/tests/rastr/test_raster.py +992 -22
- {rastr-0.5.0 → rastr-0.7.0}/uv.lock +20 -0
- rastr-0.5.0/.github/workflows/release.yml +0 -32
- rastr-0.5.0/src/rastr/gis/__init__.py +0 -0
- rastr-0.5.0/src/rastr/gis/smooth.py +0 -142
- rastr-0.5.0/src/rastr/meta.py +0 -87
- rastr-0.5.0/tests/conftest.py +0 -12
- {rastr-0.5.0 → rastr-0.7.0}/.copier-answers.yml +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/.github/ISSUE_TEMPLATE/enhancement.md +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/.gitignore +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/.pre-commit-config.yaml +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/.python-version +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/CONTRIBUTING.md +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/LICENSE +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/docs/index.md +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/docs/logo.svg +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/mkdocs.yml +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/pyrightconfig.json +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/requirements.txt +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/src/archive/.gitkeep +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/src/notebooks/.gitkeep +0 -0
- {rastr-0.5.0/src/rastr → rastr-0.7.0/src/rastr/arr}/__init__.py +0 -0
- {rastr-0.5.0/src/rastr/arr → rastr-0.7.0/src/rastr/gis}/__init__.py +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/src/scripts/.gitkeep +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/src/scripts/demo_point_cloud.py +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/src/scripts/demo_taper_border.py +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/ABOUT_TASKS.md +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/dev_sync.ps1 +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/scripts/activate_venv.sh +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/scripts/configure_project.sh +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/scripts/dev_sync.sh +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/scripts/install_backend.sh +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/scripts/install_venv.sh +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/scripts/recover_corrupt_venv.sh +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/scripts/sh_runner.ps1 +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/scripts/sync_requirements.sh +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/scripts/sync_template.sh +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/activate_venv +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/activate_venv.cmd +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/activate_venv.ps1 +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/configure_project +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/configure_project.cmd +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/configure_project.ps1 +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/dev_sync +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/dev_sync.cmd +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/dev_sync.ps1 +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/install_backend +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/install_backend.cmd +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/install_backend.ps1 +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/install_venv +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/install_venv.cmd +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/install_venv.ps1 +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/recover_corrupt_venv +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/recover_corrupt_venv.cmd +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/recover_corrupt_venv.ps1 +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/sync_requirements +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/sync_requirements.cmd +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/sync_requirements.ps1 +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/sync_template +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/sync_template.cmd +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/sync_template.ps1 +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tasks/sync_template.ps1 +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tests/assets/.gitkeep +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tests/assets/pga_g_clipped.grd +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tests/assets/pga_g_clipped.tif +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tests/rastr/.gitkeep +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tests/rastr/gis/test_fishnet.py +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tests/rastr/regression_test_data/test_plot_raster.png +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tests/rastr/regression_test_data/test_write_raster_to_file.tif +0 -0
- {rastr-0.5.0 → rastr-0.7.0}/tests/rastr/test_meta.py +0 -0
|
@@ -69,7 +69,9 @@
|
|
|
69
69
|
|
|
70
70
|
- Use `uv run pytest` for testing, and ensure all tests are passing before committing changes.
|
|
71
71
|
- Do not perform equality checks with floating point values; instead, use `pytest.approx`.
|
|
72
|
-
-
|
|
72
|
+
- Follow an arrange-act-assert structure in test functions with explicit comments separating each section where possible (context managers can force the assert and act sections to be unified).
|
|
73
|
+
- In the assert block of each test, use only one contiguous block of assert statements to ensure clarity and simplicity.
|
|
74
|
+
|
|
73
75
|
|
|
74
76
|
## Documentation & Workflow Management
|
|
75
77
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
name: CI
|
|
2
2
|
permissions:
|
|
3
3
|
contents: read
|
|
4
|
-
pull-requests: write
|
|
5
4
|
on:
|
|
6
5
|
workflow_dispatch:
|
|
7
6
|
push:
|
|
@@ -21,12 +20,15 @@ concurrency:
|
|
|
21
20
|
cancel-in-progress: true
|
|
22
21
|
jobs:
|
|
23
22
|
tests:
|
|
23
|
+
name: Run Tests and Checks
|
|
24
24
|
runs-on: ${{ matrix.os }}
|
|
25
25
|
env:
|
|
26
26
|
PYTHONIOENCODING: utf-8
|
|
27
27
|
steps:
|
|
28
28
|
- name: Checkout code
|
|
29
29
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
30
|
+
with:
|
|
31
|
+
persist-credentials: false
|
|
30
32
|
|
|
31
33
|
- name: Setup git user config
|
|
32
34
|
run: |
|
|
@@ -41,8 +43,10 @@ jobs:
|
|
|
41
43
|
|
|
42
44
|
- name: Setup Python
|
|
43
45
|
run: |
|
|
44
|
-
uv python pin ${
|
|
46
|
+
uv python pin ${PYTHON_VERSION}
|
|
45
47
|
uv python install
|
|
48
|
+
env:
|
|
49
|
+
PYTHON_VERSION: ${{ matrix.python-version }}
|
|
46
50
|
|
|
47
51
|
- name: Run pre-commit
|
|
48
52
|
if: matrix.checks
|
|
@@ -54,6 +58,13 @@ jobs:
|
|
|
54
58
|
run: |
|
|
55
59
|
uv run pyright
|
|
56
60
|
|
|
61
|
+
- name: Run zizmor
|
|
62
|
+
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13'
|
|
63
|
+
run: |
|
|
64
|
+
uv run zizmor --no-progress --pedantic .github/
|
|
65
|
+
env:
|
|
66
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
67
|
+
|
|
57
68
|
- name: Run pytest
|
|
58
69
|
uses: pavelzw/pytest-action@510c5e90c360a185039bea56ce8b3e7e51a16507 # v2.2.0
|
|
59
70
|
if: matrix.pytest
|
|
@@ -92,6 +103,8 @@ jobs:
|
|
|
92
103
|
name: Analyse Code Quality
|
|
93
104
|
runs-on: ubuntu-latest
|
|
94
105
|
needs: tests
|
|
106
|
+
permissions:
|
|
107
|
+
pull-requests: write # SonarQube needs to post comments on PRs
|
|
95
108
|
if: always() && needs.tests.result == 'success'
|
|
96
109
|
|
|
97
110
|
steps:
|
|
@@ -99,6 +112,7 @@ jobs:
|
|
|
99
112
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
100
113
|
with:
|
|
101
114
|
fetch-depth: 0 # Shallow clones should be disabled for better relevancy of analysis
|
|
115
|
+
persist-credentials: false
|
|
102
116
|
|
|
103
117
|
- name: Download coverage reports
|
|
104
118
|
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
|
|
@@ -110,7 +124,7 @@ jobs:
|
|
|
110
124
|
- name: Create SonarQube properties
|
|
111
125
|
run: |
|
|
112
126
|
cat > sonar-project.properties << EOF
|
|
113
|
-
sonar.projectKey=${
|
|
127
|
+
sonar.projectKey=${SONAR_PROJECT_KEY}
|
|
114
128
|
sonar.language=py
|
|
115
129
|
sonar.python.version=3.13
|
|
116
130
|
sonar.sources=./src
|
|
@@ -119,9 +133,11 @@ jobs:
|
|
|
119
133
|
sonar.exclusions=**/Dockerfile,**/notebooks/**,**/scripts/**
|
|
120
134
|
sonar.verbose=false
|
|
121
135
|
EOF
|
|
136
|
+
env:
|
|
137
|
+
SONAR_PROJECT_KEY: ${{ vars.SONAR_PROJECT_KEY }}
|
|
122
138
|
|
|
123
139
|
- name: Run SonarQube analysis
|
|
124
|
-
uses: SonarSource/sonarqube-scan-action@
|
|
140
|
+
uses: SonarSource/sonarqube-scan-action@fd88b7d7ccbaefd23d8f36f73b59db7a3d246602 # v6.0.0
|
|
125
141
|
env:
|
|
126
142
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
|
127
143
|
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: Release to PyPI
|
|
2
|
+
permissions:
|
|
3
|
+
contents: read
|
|
4
|
+
on:
|
|
5
|
+
push:
|
|
6
|
+
tags:
|
|
7
|
+
- "v*"
|
|
8
|
+
jobs:
|
|
9
|
+
deploy:
|
|
10
|
+
name: Release on PyPI
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
environment: release
|
|
13
|
+
permissions:
|
|
14
|
+
id-token: write # This is necessary to use trusted publishing with uv
|
|
15
|
+
steps:
|
|
16
|
+
- name: Checkout code
|
|
17
|
+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
18
|
+
with:
|
|
19
|
+
persist-credentials: false
|
|
20
|
+
|
|
21
|
+
- name: Set up uv
|
|
22
|
+
uses: astral-sh/setup-uv@3b9817b1bf26186f03ab8277bab9b827ea5cc254 # v3.2.0
|
|
23
|
+
with:
|
|
24
|
+
version: "0.7.13" # Sync with pyproject.toml
|
|
25
|
+
enable-cache: false
|
|
26
|
+
|
|
27
|
+
- name: "Set up Python"
|
|
28
|
+
uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
|
29
|
+
with:
|
|
30
|
+
python-version: 3.13
|
|
31
|
+
|
|
32
|
+
- name: Release
|
|
33
|
+
run: |
|
|
34
|
+
uv build
|
|
35
|
+
uv publish --trusted-publishing always
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rastr
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Geospatial Raster datatype library for Python.
|
|
5
5
|
Project-URL: Source Code, https://github.com/tonkintaylor/rastr
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/tonkintaylor/rastr/issues
|
|
7
7
|
Project-URL: Releases, https://github.com/tonkintaylor/rastr/releases
|
|
8
|
-
Project-URL: Source Archive, https://github.com/tonkintaylor/rastr/archive/
|
|
8
|
+
Project-URL: Source Archive, https://github.com/tonkintaylor/rastr/archive/2b485cc676121c82f468dca7733e444c3033abbe.zip
|
|
9
9
|
Author-email: Tonkin & Taylor Limited <Sub-DisciplineData+AnalyticsStaff@tonkintaylor.co.nz>, Nathan McDougall <nmcdougall@tonkintaylor.co.nz>, Ben Karl <bkarl@tonkintaylor.co.nz>
|
|
10
10
|
License-Expression: MIT
|
|
11
11
|
License-File: LICENSE
|
|
@@ -77,8 +77,8 @@ from rastr.create import full_raster
|
|
|
77
77
|
from rastr.meta import RasterMeta
|
|
78
78
|
from rastr.raster import Raster
|
|
79
79
|
|
|
80
|
-
#
|
|
81
|
-
raster = Raster.
|
|
80
|
+
# Read a raster from a file
|
|
81
|
+
raster = Raster.read_file("path/to/raster.tif")
|
|
82
82
|
|
|
83
83
|
# Basic arithmetic operations
|
|
84
84
|
doubled = raster * 2
|
|
@@ -43,8 +43,8 @@ from rastr.create import full_raster
|
|
|
43
43
|
from rastr.meta import RasterMeta
|
|
44
44
|
from rastr.raster import Raster
|
|
45
45
|
|
|
46
|
-
#
|
|
47
|
-
raster = Raster.
|
|
46
|
+
# Read a raster from a file
|
|
47
|
+
raster = Raster.read_file("path/to/raster.tif")
|
|
48
48
|
|
|
49
49
|
# Basic arithmetic operations
|
|
50
50
|
doubled = raster * 2
|
|
@@ -66,6 +66,7 @@ dev = [
|
|
|
66
66
|
"ruff>=0.13.2",
|
|
67
67
|
"tqdm>=4.67.1",
|
|
68
68
|
"usethis>=0.15.2",
|
|
69
|
+
"zizmor>=1.14.2",
|
|
69
70
|
]
|
|
70
71
|
test = [
|
|
71
72
|
"coverage[toml]>=7.10.1",
|
|
@@ -263,6 +264,6 @@ root_packages = [ "rastr" ]
|
|
|
263
264
|
[[tool.importlinter.contracts]]
|
|
264
265
|
name = "rastr"
|
|
265
266
|
type = "layers"
|
|
266
|
-
layers = [ "create
|
|
267
|
+
layers = [ "create : io : raster", "meta", "arr | gis", "_version" ]
|
|
267
268
|
containers = [ "rastr" ]
|
|
268
269
|
exhaustive = true
|
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '0.
|
|
32
|
-
__version_tuple__ = version_tuple = (0,
|
|
31
|
+
__version__ = version = '0.7.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 7, 0)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
|
@@ -8,7 +8,7 @@ if TYPE_CHECKING:
|
|
|
8
8
|
from numpy.typing import NDArray
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
def fillna_nearest_neighbours(arr: NDArray
|
|
11
|
+
def fillna_nearest_neighbours(arr: NDArray) -> NDArray:
|
|
12
12
|
"""Fill NaN values in an N-dimensional array with their nearest neighbours' values.
|
|
13
13
|
|
|
14
14
|
The nearest neighbour is determined using the Euclidean distance between array
|
|
@@ -28,4 +28,5 @@ def fillna_nearest_neighbours(arr: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
|
28
28
|
# Interpolate at the array indices
|
|
29
29
|
interp = NearestNDInterpolator(nonnan_idxs, arr[nonnan_mask])
|
|
30
30
|
filled_arr = interp(*np.indices(arr.shape))
|
|
31
|
-
|
|
31
|
+
# Preserve the original dtype
|
|
32
|
+
return filled_arr.astype(arr.dtype)
|
|
@@ -11,12 +11,14 @@ from affine import Affine
|
|
|
11
11
|
from pyproj import CRS
|
|
12
12
|
from shapely.geometry import Point
|
|
13
13
|
|
|
14
|
+
from rastr.gis.crs import get_affine_sign
|
|
14
15
|
from rastr.gis.fishnet import create_point_grid, get_point_grid_shape
|
|
16
|
+
from rastr.gis.interpolate import interpn_kernel
|
|
15
17
|
from rastr.meta import RasterMeta
|
|
16
18
|
from rastr.raster import Raster
|
|
17
19
|
|
|
18
20
|
if TYPE_CHECKING:
|
|
19
|
-
from collections.abc import Iterable
|
|
21
|
+
from collections.abc import Collection, Iterable
|
|
20
22
|
|
|
21
23
|
import geopandas as gpd
|
|
22
24
|
from numpy.typing import ArrayLike
|
|
@@ -141,7 +143,7 @@ def rasterize_gdf(
|
|
|
141
143
|
gdf: gpd.GeoDataFrame,
|
|
142
144
|
*,
|
|
143
145
|
raster_meta: RasterMeta,
|
|
144
|
-
target_cols:
|
|
146
|
+
target_cols: Collection[str],
|
|
145
147
|
) -> list[Raster]:
|
|
146
148
|
"""Rasterize geometries from a GeoDataFrame.
|
|
147
149
|
|
|
@@ -183,9 +185,10 @@ def rasterize_gdf(
|
|
|
183
185
|
shape = get_point_grid_shape(bounds=expanded_bounds, cell_size=cell_size)
|
|
184
186
|
|
|
185
187
|
# Create the affine transform for rasterization
|
|
188
|
+
xs, ys = get_affine_sign(raster_meta.crs)
|
|
186
189
|
transform = Affine.translation(
|
|
187
190
|
expanded_bounds[0], expanded_bounds[3]
|
|
188
|
-
) * Affine.scale(cell_size,
|
|
191
|
+
) * Affine.scale(xs * cell_size, ys * cell_size)
|
|
189
192
|
|
|
190
193
|
# Create rasters for each target column using rasterio.features.rasterize
|
|
191
194
|
rasters = []
|
|
@@ -212,7 +215,9 @@ def rasterize_gdf(
|
|
|
212
215
|
return rasters
|
|
213
216
|
|
|
214
217
|
|
|
215
|
-
def _validate_columns_exist(
|
|
218
|
+
def _validate_columns_exist(
|
|
219
|
+
gdf: gpd.GeoDataFrame, target_cols: Collection[str]
|
|
220
|
+
) -> None:
|
|
216
221
|
"""Validate that all target columns exist in the GeoDataFrame.
|
|
217
222
|
|
|
218
223
|
Args:
|
|
@@ -228,7 +233,9 @@ def _validate_columns_exist(gdf: gpd.GeoDataFrame, target_cols: list[str]) -> No
|
|
|
228
233
|
raise MissingColumnsError(msg)
|
|
229
234
|
|
|
230
235
|
|
|
231
|
-
def _validate_columns_numeric(
|
|
236
|
+
def _validate_columns_numeric(
|
|
237
|
+
gdf: gpd.GeoDataFrame, target_cols: Collection[str]
|
|
238
|
+
) -> None:
|
|
232
239
|
"""Validate that all target columns contain numeric data.
|
|
233
240
|
|
|
234
241
|
Args:
|
|
@@ -308,14 +315,31 @@ def raster_from_point_cloud(
|
|
|
308
315
|
Raises:
|
|
309
316
|
ValueError: If any (x, y) points are duplicated, or if they are all collinear.
|
|
310
317
|
"""
|
|
311
|
-
from scipy.interpolate import LinearNDInterpolator
|
|
312
|
-
from scipy.spatial import KDTree, QhullError
|
|
313
|
-
|
|
314
|
-
x = np.asarray(x).ravel()
|
|
315
|
-
y = np.asarray(y).ravel()
|
|
316
|
-
z = np.asarray(z).ravel()
|
|
317
318
|
crs = CRS.from_user_input(crs)
|
|
319
|
+
x, y, z = _validate_xyz(
|
|
320
|
+
np.asarray(x).ravel(), np.asarray(y).ravel(), np.asarray(z).ravel()
|
|
321
|
+
)
|
|
318
322
|
|
|
323
|
+
raster_meta, shape = RasterMeta.infer(x, y, cell_size=cell_size, crs=crs)
|
|
324
|
+
arr = interpn_kernel(
|
|
325
|
+
points=np.column_stack((x, y)),
|
|
326
|
+
values=z,
|
|
327
|
+
xi=np.column_stack(_get_grid(raster_meta, shape=shape)),
|
|
328
|
+
).reshape(shape)
|
|
329
|
+
|
|
330
|
+
# We only support float rasters for now; we should preserve the input dtype if
|
|
331
|
+
# possible
|
|
332
|
+
if z.dtype in (np.float16, np.float32, np.float64):
|
|
333
|
+
arr = arr.astype(z.dtype)
|
|
334
|
+
else:
|
|
335
|
+
arr = arr.astype(np.float64)
|
|
336
|
+
|
|
337
|
+
return Raster(arr=arr, raster_meta=raster_meta)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _validate_xyz(
|
|
341
|
+
x: np.ndarray, y: np.ndarray, z: np.ndarray
|
|
342
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
319
343
|
# Validate input arrays
|
|
320
344
|
if len(x) != len(y) or len(x) != len(z):
|
|
321
345
|
msg = "Length of x, y, and z must be equal."
|
|
@@ -339,53 +363,18 @@ def raster_from_point_cloud(
|
|
|
339
363
|
msg = "Duplicate (x, y) points found. Each (x, y) point must be unique."
|
|
340
364
|
raise ValueError(msg)
|
|
341
365
|
|
|
342
|
-
|
|
343
|
-
if cell_size is None:
|
|
344
|
-
# Half the 5th percentile of nearest neighbor distances between the (x,y) points
|
|
345
|
-
tree = KDTree(xy_points)
|
|
346
|
-
distances, _ = tree.query(xy_points, k=2)
|
|
347
|
-
distances: np.ndarray
|
|
348
|
-
cell_size = float(np.percentile(distances[distances > 0], 5)) / 2
|
|
349
|
-
|
|
350
|
-
# Compute bounds from data
|
|
351
|
-
minx, miny, maxx, maxy = np.min(x), np.min(y), np.max(x), np.max(y)
|
|
352
|
-
|
|
353
|
-
# Compute grid shape
|
|
354
|
-
width = int(np.ceil((maxx - minx) / cell_size))
|
|
355
|
-
height = int(np.ceil((maxy - miny) / cell_size))
|
|
356
|
-
shape = (height, width)
|
|
366
|
+
return x, y, z
|
|
357
367
|
|
|
358
|
-
# Compute transform: upper left corner is (minx, maxy)
|
|
359
|
-
transform = Affine.translation(minx, maxy) * Affine.scale(cell_size, -cell_size)
|
|
360
368
|
|
|
361
|
-
|
|
369
|
+
def _get_grid(
|
|
370
|
+
raster_meta: RasterMeta, *, shape: tuple[int, int]
|
|
371
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
372
|
+
"""Get coordinates for raster cell centres based on raster metadata and shape."""
|
|
362
373
|
rows, cols = np.indices(shape)
|
|
363
374
|
xs, ys = rasterio.transform.xy(
|
|
364
|
-
transform=transform, rows=rows, cols=cols, offset="center"
|
|
375
|
+
transform=raster_meta.transform, rows=rows, cols=cols, offset="center"
|
|
365
376
|
)
|
|
366
377
|
grid_x = np.array(xs).ravel()
|
|
367
378
|
grid_y = np.array(ys).ravel()
|
|
368
379
|
|
|
369
|
-
|
|
370
|
-
try:
|
|
371
|
-
interpolator = LinearNDInterpolator(
|
|
372
|
-
points=xy_points, values=z, fill_value=np.nan
|
|
373
|
-
)
|
|
374
|
-
except QhullError as err:
|
|
375
|
-
msg = (
|
|
376
|
-
"Failed to interpolate. This may be due to insufficient or "
|
|
377
|
-
"degenerate input points. Ensure that the (x, y) points are not all "
|
|
378
|
-
"collinear (i.e. that the convex hull is non-degenerate)."
|
|
379
|
-
)
|
|
380
|
-
raise ValueError(msg) from err
|
|
381
|
-
|
|
382
|
-
grid_values = np.array(interpolator(np.column_stack((grid_x, grid_y))))
|
|
383
|
-
|
|
384
|
-
arr = grid_values.reshape(shape).astype(np.float32)
|
|
385
|
-
|
|
386
|
-
raster_meta = RasterMeta(
|
|
387
|
-
cell_size=cell_size,
|
|
388
|
-
crs=crs,
|
|
389
|
-
transform=transform,
|
|
390
|
-
)
|
|
391
|
-
return Raster(arr=arr, raster_meta=raster_meta)
|
|
380
|
+
return grid_x, grid_y
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from pyproj import CRS
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_affine_sign(crs: CRS | str) -> tuple[Literal[+1, -1], Literal[+1, -1]]:
|
|
10
|
+
"""Return (x_sign, y_sign) for an Affine scale, given a CRS.
|
|
11
|
+
|
|
12
|
+
Some coordinate systems may use unconventional axis directions, in which case
|
|
13
|
+
the correct direction may not be possible to infer correctly. In these cases,
|
|
14
|
+
the assumption is that x increases to the right, and y increases upwards.
|
|
15
|
+
"""
|
|
16
|
+
crs = CRS.from_user_input(crs)
|
|
17
|
+
|
|
18
|
+
# Try to detect horizontal axis directions from CRS metadata
|
|
19
|
+
dir_x, dir_y, *_ = [(a.direction or "").lower() for a in crs.axis_info]
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
if _is_conventional_direction(dir_x):
|
|
23
|
+
x_sign = +1
|
|
24
|
+
else:
|
|
25
|
+
x_sign = -1
|
|
26
|
+
except NotImplementedError:
|
|
27
|
+
msg = (
|
|
28
|
+
f"Could not determine x-axis direction from CRS axis info '{dir_x}'. "
|
|
29
|
+
"Falling back to +1 (increasing to the right)."
|
|
30
|
+
)
|
|
31
|
+
warnings.warn(msg, stacklevel=2)
|
|
32
|
+
x_sign = +1
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
if _is_conventional_direction(dir_y):
|
|
36
|
+
y_sign = -1
|
|
37
|
+
else:
|
|
38
|
+
y_sign = +1
|
|
39
|
+
except NotImplementedError:
|
|
40
|
+
msg = (
|
|
41
|
+
f"Could not determine y-axis direction from CRS axis info '{dir_y}'. "
|
|
42
|
+
"Falling back to -1 (increasing upwards)."
|
|
43
|
+
)
|
|
44
|
+
warnings.warn(msg, stacklevel=2)
|
|
45
|
+
y_sign = -1
|
|
46
|
+
|
|
47
|
+
return x_sign, y_sign
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_conventional_direction(direction: str) -> bool:
|
|
51
|
+
"""Return True if the axis direction indicates positive increase."""
|
|
52
|
+
if (
|
|
53
|
+
"north" in direction
|
|
54
|
+
or "up" in direction
|
|
55
|
+
or "east" in direction
|
|
56
|
+
or "right" in direction
|
|
57
|
+
):
|
|
58
|
+
return True
|
|
59
|
+
elif (
|
|
60
|
+
"south" in direction
|
|
61
|
+
or "down" in direction
|
|
62
|
+
or "west" in direction
|
|
63
|
+
or "left" in direction
|
|
64
|
+
):
|
|
65
|
+
return False
|
|
66
|
+
else:
|
|
67
|
+
raise NotImplementedError
|
|
@@ -41,8 +41,20 @@ def get_point_grid_shape(
|
|
|
41
41
|
"""Calculate the shape of the point grid based on bounds and cell size."""
|
|
42
42
|
|
|
43
43
|
xmin, ymin, xmax, ymax = bounds
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
ncols_exact = (xmax - xmin) / cell_size
|
|
45
|
+
nrows_exact = (ymax - ymin) / cell_size
|
|
46
|
+
|
|
47
|
+
# Use round for values very close to integers to avoid floating-point
|
|
48
|
+
# sensitivity while maintaining ceil behavior for truly fractional values
|
|
49
|
+
if np.isclose(ncols_exact, np.round(ncols_exact)):
|
|
50
|
+
ncols = int(np.round(ncols_exact))
|
|
51
|
+
else:
|
|
52
|
+
ncols = int(np.ceil(ncols_exact))
|
|
53
|
+
|
|
54
|
+
if np.isclose(nrows_exact, np.round(nrows_exact)):
|
|
55
|
+
nrows = int(np.round(nrows_exact))
|
|
56
|
+
else:
|
|
57
|
+
nrows = int(np.ceil(nrows_exact))
|
|
46
58
|
|
|
47
59
|
return nrows, ncols
|
|
48
60
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def interpn_kernel(
|
|
12
|
+
points: np.ndarray,
|
|
13
|
+
values: np.ndarray,
|
|
14
|
+
*,
|
|
15
|
+
xi: np.ndarray,
|
|
16
|
+
kernel: Callable[[np.ndarray], np.ndarray] | None = None,
|
|
17
|
+
) -> np.ndarray:
|
|
18
|
+
"""Interpolate scattered data to new points, with optional kernel transformation.
|
|
19
|
+
|
|
20
|
+
For example, you could provide a kernel to transform cartesian coordinate points
|
|
21
|
+
to polar coordinates before interpolation, giving interpolation which follows the
|
|
22
|
+
circular pattern of the data.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
points: Array of shape (n_points, n_dimensions) representing the input points.
|
|
26
|
+
values: Array of shape (n_points,) representing the values at each input point.
|
|
27
|
+
xi: Array of shape (m_points, n_dimensions) representing the points to
|
|
28
|
+
interpolate to.
|
|
29
|
+
kernel: Optional function to transform points (and xi) before interpolation.
|
|
30
|
+
"""
|
|
31
|
+
from scipy.interpolate import LinearNDInterpolator
|
|
32
|
+
from scipy.spatial import QhullError
|
|
33
|
+
|
|
34
|
+
if kernel is not None:
|
|
35
|
+
xi = kernel(xi)
|
|
36
|
+
points = kernel(points)
|
|
37
|
+
try:
|
|
38
|
+
interpolator = LinearNDInterpolator(
|
|
39
|
+
points=points, values=values, fill_value=np.nan
|
|
40
|
+
)
|
|
41
|
+
except QhullError as err:
|
|
42
|
+
msg = (
|
|
43
|
+
"Failed to interpolate. This may be due to insufficient or "
|
|
44
|
+
"degenerate input points. Ensure that the (x, y) points are not all "
|
|
45
|
+
"collinear (i.e. that the convex hull is non-degenerate)."
|
|
46
|
+
)
|
|
47
|
+
raise ValueError(msg) from err
|
|
48
|
+
|
|
49
|
+
grid_values = np.array(interpolator(xi))
|
|
50
|
+
return grid_values
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Utilities for smoothing geometries.
|
|
2
|
+
|
|
3
|
+
Fork + Port of <https://github.com/philipschall/shapelysmooth> (Public domain)
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from numpy.lib.stride_tricks import sliding_window_view
|
|
12
|
+
from shapely.geometry import LineString, Polygon
|
|
13
|
+
from typing_extensions import assert_never
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from numpy.typing import NDArray
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T", bound=LineString | Polygon)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InputeTypeError(TypeError):
|
|
22
|
+
"""Raised when the input geometry is of the incorrect type."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def catmull_rom_smooth(geometry: T, alpha: float = 0.5, subdivs: int = 10) -> T:
|
|
26
|
+
"""Polyline smoothing using Catmull-Rom splines.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
geometry: The geometry to smooth
|
|
30
|
+
alpha: The tension parameter, between 0 and 1 inclusive. Defaults to 0.5.
|
|
31
|
+
- For uniform Catmull-Rom splines, alpha = 0.
|
|
32
|
+
- For centripetal Catmull-Rom splines, alpha = 0.5.
|
|
33
|
+
- For chordal Catmull-Rom splines, alpha = 1.0.
|
|
34
|
+
subdivs:
|
|
35
|
+
Number of subdivisions of each polyline segment. Default value: 10.
|
|
36
|
+
|
|
37
|
+
Returns: The smoothed geometry.
|
|
38
|
+
"""
|
|
39
|
+
coords, interior_coords = _get_coords(geometry)
|
|
40
|
+
coords_smoothed = _catmull_rom(coords, alpha=alpha, subdivs=subdivs)
|
|
41
|
+
if isinstance(geometry, LineString):
|
|
42
|
+
return geometry.__class__(coords_smoothed)
|
|
43
|
+
elif isinstance(geometry, Polygon):
|
|
44
|
+
interior_coords_smoothed = [
|
|
45
|
+
_catmull_rom(c, alpha=alpha, subdivs=subdivs) for c in interior_coords
|
|
46
|
+
]
|
|
47
|
+
return geometry.__class__(coords_smoothed, holes=interior_coords_smoothed)
|
|
48
|
+
else:
|
|
49
|
+
assert_never(geometry)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _catmull_rom(
|
|
53
|
+
coords: NDArray,
|
|
54
|
+
*,
|
|
55
|
+
alpha: float = 0.5,
|
|
56
|
+
subdivs: int = 8,
|
|
57
|
+
) -> list[tuple[float, float]]:
|
|
58
|
+
arr = np.asarray(coords, dtype=float)
|
|
59
|
+
n = arr.shape[0]
|
|
60
|
+
if n < 2:
|
|
61
|
+
return arr.tolist()
|
|
62
|
+
|
|
63
|
+
is_closed = np.allclose(arr[0], arr[-1])
|
|
64
|
+
if is_closed:
|
|
65
|
+
arr = np.vstack([arr[-2], arr, arr[2]])
|
|
66
|
+
else:
|
|
67
|
+
arr = np.vstack(
|
|
68
|
+
[
|
|
69
|
+
2.0 * arr[0] + 1.0 * arr[1],
|
|
70
|
+
arr,
|
|
71
|
+
2.0 * arr[-1] + 0.0 * arr[-2],
|
|
72
|
+
]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Shape of (segments, 4, D)
|
|
76
|
+
segments = sliding_window_view(arr, (4, arr.shape[1]))[:, 0, :]
|
|
77
|
+
|
|
78
|
+
# Distances and tangent values
|
|
79
|
+
diffs = np.diff(segments, axis=1)
|
|
80
|
+
dists = np.linalg.norm(diffs, axis=2)
|
|
81
|
+
tangents = np.concatenate(
|
|
82
|
+
[np.zeros((len(dists), 1)), np.cumsum(dists**alpha, axis=1)], axis=1
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Build ts per segment
|
|
86
|
+
if subdivs > 1:
|
|
87
|
+
seg_lens = (tangents[:, 2] - tangents[:, 1]) / subdivs
|
|
88
|
+
u = np.linspace(1, subdivs - 1, subdivs - 1)
|
|
89
|
+
ts = tangents[:, [1]] + seg_lens[:, None] * u # (N-3, subdivs-1)
|
|
90
|
+
else:
|
|
91
|
+
ts = np.empty((len(segments), 0))
|
|
92
|
+
|
|
93
|
+
# Vectorize over segments
|
|
94
|
+
out_segments = []
|
|
95
|
+
for seg, tang, tvals in zip(segments, tangents, ts, strict=True):
|
|
96
|
+
if tvals.size:
|
|
97
|
+
out_segments.append(
|
|
98
|
+
_recursive_eval(seg, np.asarray(tang), np.asarray(tvals))
|
|
99
|
+
)
|
|
100
|
+
if out_segments:
|
|
101
|
+
all_midpoints = np.vstack(out_segments)
|
|
102
|
+
else:
|
|
103
|
+
all_midpoints = np.empty((0, arr.shape[1]))
|
|
104
|
+
|
|
105
|
+
# Gather final output in order
|
|
106
|
+
result = [tuple(arr[1])]
|
|
107
|
+
idx = 0
|
|
108
|
+
for k in range(len(segments)):
|
|
109
|
+
block = all_midpoints[idx : idx + max(subdivs - 1, 0)]
|
|
110
|
+
result.extend(map(tuple, block))
|
|
111
|
+
result.append(tuple(segments[k, 2]))
|
|
112
|
+
idx += max(subdivs - 1, 0)
|
|
113
|
+
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _recursive_eval(slice4: NDArray, tangents: NDArray, ts: NDArray) -> NDArray:
|
|
118
|
+
"""De Boor/De Casteljau-style recursive linear interpolation over 4 control points.
|
|
119
|
+
|
|
120
|
+
Parameterized by the non-uniform 'tangents' values.
|
|
121
|
+
"""
|
|
122
|
+
slice4 = np.asarray(slice4, dtype=float)
|
|
123
|
+
tangents = np.asarray(tangents, dtype=float)
|
|
124
|
+
ts = np.asarray(ts, dtype=float)
|
|
125
|
+
bigm = ts.shape[0]
|
|
126
|
+
bigd = slice4.shape[1]
|
|
127
|
+
|
|
128
|
+
# Initialize points for all ts, shape (M, 4, D)
|
|
129
|
+
points = np.broadcast_to(slice4, (bigm, 4, bigd)).copy()
|
|
130
|
+
|
|
131
|
+
# Recursive interpolation, but vectorized across all ts
|
|
132
|
+
for r in range(1, 4):
|
|
133
|
+
idx = max(r - 2, 0)
|
|
134
|
+
denom = tangents[r - idx : 4 - idx] - tangents[idx : 4 - r + idx]
|
|
135
|
+
denom = np.where(denom == 0, np.finfo(float).eps, denom) # avoid div 0
|
|
136
|
+
|
|
137
|
+
# Compute weights for all parameter values at once
|
|
138
|
+
left_w = (tangents[r - idx : 4 - idx][None, :] - ts[:, None]) / denom
|
|
139
|
+
right_w = 1 - left_w
|
|
140
|
+
|
|
141
|
+
# Weighted sums between consecutive points
|
|
142
|
+
points = (
|
|
143
|
+
left_w[..., None] * points[:, 0 : 4 - r, :]
|
|
144
|
+
+ right_w[..., None] * points[:, 1 : 5 - r, :]
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Result is first (and only) point at this level
|
|
148
|
+
return points[:, 0, :]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _get_coords(
|
|
152
|
+
geometry: LineString | Polygon,
|
|
153
|
+
) -> tuple[NDArray, list[NDArray]]:
|
|
154
|
+
if isinstance(geometry, LineString):
|
|
155
|
+
return np.array(geometry.coords), []
|
|
156
|
+
elif isinstance(geometry, Polygon):
|
|
157
|
+
return np.array(geometry.exterior.coords), [
|
|
158
|
+
np.array(hole.coords) for hole in geometry.interiors
|
|
159
|
+
]
|
|
160
|
+
else:
|
|
161
|
+
assert_never(geometry)
|