rastr 0.5.0__tar.gz → 0.6.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.6.0}/.github/workflows/ci.yml +20 -4
- rastr-0.6.0/.github/workflows/release.yml +35 -0
- {rastr-0.5.0 → rastr-0.6.0}/PKG-INFO +4 -4
- {rastr-0.5.0 → rastr-0.6.0}/README.md +2 -2
- {rastr-0.5.0 → rastr-0.6.0}/pyproject.toml +2 -1
- {rastr-0.5.0 → rastr-0.6.0}/src/rastr/_version.py +2 -2
- {rastr-0.5.0 → rastr-0.6.0}/src/rastr/arr/fill.py +3 -2
- {rastr-0.5.0 → rastr-0.6.0}/src/rastr/create.py +8 -4
- {rastr-0.5.0 → rastr-0.6.0}/src/rastr/gis/fishnet.py +14 -2
- {rastr-0.5.0 → rastr-0.6.0}/src/rastr/gis/smooth.py +4 -4
- {rastr-0.5.0 → rastr-0.6.0}/src/rastr/io.py +37 -11
- {rastr-0.5.0 → rastr-0.6.0}/src/rastr/raster.py +130 -26
- rastr-0.6.0/tests/conftest.py +54 -0
- {rastr-0.5.0 → rastr-0.6.0}/tests/rastr/test_create.py +74 -0
- {rastr-0.5.0 → rastr-0.6.0}/tests/rastr/test_io.py +87 -5
- {rastr-0.5.0 → rastr-0.6.0}/tests/rastr/test_raster.py +786 -0
- {rastr-0.5.0 → rastr-0.6.0}/uv.lock +20 -0
- rastr-0.5.0/.github/workflows/release.yml +0 -32
- rastr-0.5.0/tests/conftest.py +0 -12
- {rastr-0.5.0 → rastr-0.6.0}/.copier-answers.yml +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/.github/ISSUE_TEMPLATE/enhancement.md +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/.github/copilot-instructions.md +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/.gitignore +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/.pre-commit-config.yaml +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/.python-version +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/CONTRIBUTING.md +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/LICENSE +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/docs/index.md +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/docs/logo.svg +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/mkdocs.yml +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/pyrightconfig.json +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/requirements.txt +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/src/archive/.gitkeep +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/src/notebooks/.gitkeep +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/src/rastr/__init__.py +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/src/rastr/arr/__init__.py +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/src/rastr/gis/__init__.py +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/src/rastr/meta.py +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/src/scripts/.gitkeep +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/src/scripts/demo_point_cloud.py +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/src/scripts/demo_taper_border.py +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/ABOUT_TASKS.md +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/dev_sync.ps1 +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/scripts/activate_venv.sh +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/scripts/configure_project.sh +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/scripts/dev_sync.sh +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/scripts/install_backend.sh +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/scripts/install_venv.sh +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/scripts/recover_corrupt_venv.sh +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/scripts/sh_runner.ps1 +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/scripts/sync_requirements.sh +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/scripts/sync_template.sh +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/activate_venv +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/activate_venv.cmd +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/activate_venv.ps1 +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/configure_project +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/configure_project.cmd +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/configure_project.ps1 +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/dev_sync +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/dev_sync.cmd +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/dev_sync.ps1 +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/install_backend +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/install_backend.cmd +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/install_backend.ps1 +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/install_venv +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/install_venv.cmd +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/install_venv.ps1 +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/recover_corrupt_venv +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/recover_corrupt_venv.cmd +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/recover_corrupt_venv.ps1 +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/sync_requirements +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/sync_requirements.cmd +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/sync_requirements.ps1 +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/sync_template +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/sync_template.cmd +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/sync_template.ps1 +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tasks/sync_template.ps1 +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tests/assets/.gitkeep +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tests/assets/pga_g_clipped.grd +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tests/assets/pga_g_clipped.tif +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tests/rastr/.gitkeep +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tests/rastr/gis/test_fishnet.py +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tests/rastr/gis/test_smooth.py +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tests/rastr/regression_test_data/test_plot_raster.png +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tests/rastr/regression_test_data/test_write_raster_to_file.tif +0 -0
- {rastr-0.5.0 → rastr-0.6.0}/tests/rastr/test_meta.py +0 -0
|
@@ -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.6.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/a1b755ea9cde7f81b2963dcb12917090019f2b47.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.6.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 6, 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)
|
|
@@ -16,7 +16,7 @@ from rastr.meta import RasterMeta
|
|
|
16
16
|
from rastr.raster import Raster
|
|
17
17
|
|
|
18
18
|
if TYPE_CHECKING:
|
|
19
|
-
from collections.abc import Iterable
|
|
19
|
+
from collections.abc import Collection, Iterable
|
|
20
20
|
|
|
21
21
|
import geopandas as gpd
|
|
22
22
|
from numpy.typing import ArrayLike
|
|
@@ -141,7 +141,7 @@ def rasterize_gdf(
|
|
|
141
141
|
gdf: gpd.GeoDataFrame,
|
|
142
142
|
*,
|
|
143
143
|
raster_meta: RasterMeta,
|
|
144
|
-
target_cols:
|
|
144
|
+
target_cols: Collection[str],
|
|
145
145
|
) -> list[Raster]:
|
|
146
146
|
"""Rasterize geometries from a GeoDataFrame.
|
|
147
147
|
|
|
@@ -212,7 +212,9 @@ def rasterize_gdf(
|
|
|
212
212
|
return rasters
|
|
213
213
|
|
|
214
214
|
|
|
215
|
-
def _validate_columns_exist(
|
|
215
|
+
def _validate_columns_exist(
|
|
216
|
+
gdf: gpd.GeoDataFrame, target_cols: Collection[str]
|
|
217
|
+
) -> None:
|
|
216
218
|
"""Validate that all target columns exist in the GeoDataFrame.
|
|
217
219
|
|
|
218
220
|
Args:
|
|
@@ -228,7 +230,9 @@ def _validate_columns_exist(gdf: gpd.GeoDataFrame, target_cols: list[str]) -> No
|
|
|
228
230
|
raise MissingColumnsError(msg)
|
|
229
231
|
|
|
230
232
|
|
|
231
|
-
def _validate_columns_numeric(
|
|
233
|
+
def _validate_columns_numeric(
|
|
234
|
+
gdf: gpd.GeoDataFrame, target_cols: Collection[str]
|
|
235
|
+
) -> None:
|
|
232
236
|
"""Validate that all target columns contain numeric data.
|
|
233
237
|
|
|
234
238
|
Args:
|
|
@@ -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
|
|
|
@@ -5,7 +5,7 @@ Fork + Port of <https://github.com/philipschall/shapelysmooth> (Public domain)
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
-
from typing import TYPE_CHECKING,
|
|
8
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
9
9
|
|
|
10
10
|
import numpy as np
|
|
11
11
|
from shapely.geometry import LineString, Polygon
|
|
@@ -14,7 +14,7 @@ from typing_extensions import assert_never
|
|
|
14
14
|
if TYPE_CHECKING:
|
|
15
15
|
from numpy.typing import NDArray
|
|
16
16
|
|
|
17
|
-
T
|
|
17
|
+
T = TypeVar("T", bound=LineString | Polygon)
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
class InputeTypeError(TypeError):
|
|
@@ -38,12 +38,12 @@ def catmull_rom_smooth(geometry: T, alpha: float = 0.5, subdivs: int = 10) -> T:
|
|
|
38
38
|
coords, interior_coords = _get_coords(geometry)
|
|
39
39
|
coords_smoothed = _catmull_rom(coords, alpha=alpha, subdivs=subdivs)
|
|
40
40
|
if isinstance(geometry, LineString):
|
|
41
|
-
return
|
|
41
|
+
return geometry.__class__(coords_smoothed)
|
|
42
42
|
elif isinstance(geometry, Polygon):
|
|
43
43
|
interior_coords_smoothed = [
|
|
44
44
|
_catmull_rom(c, alpha=alpha, subdivs=subdivs) for c in interior_coords
|
|
45
45
|
]
|
|
46
|
-
return
|
|
46
|
+
return geometry.__class__(coords_smoothed, holes=interior_coords_smoothed)
|
|
47
47
|
else:
|
|
48
48
|
assert_never(geometry)
|
|
49
49
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from pathlib import Path
|
|
4
|
-
from typing import TYPE_CHECKING
|
|
4
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
5
5
|
|
|
6
6
|
import numpy as np
|
|
7
7
|
import rasterio
|
|
@@ -14,28 +14,48 @@ from rastr.raster import Raster
|
|
|
14
14
|
if TYPE_CHECKING:
|
|
15
15
|
from numpy.typing import NDArray
|
|
16
16
|
|
|
17
|
+
R = TypeVar("R", bound=Raster)
|
|
18
|
+
|
|
17
19
|
|
|
18
20
|
def read_raster_inmem(
|
|
19
|
-
raster_path: Path | str,
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
raster_path: Path | str,
|
|
22
|
+
*,
|
|
23
|
+
crs: CRS | str | None = None,
|
|
24
|
+
cls: type[R] = Raster,
|
|
25
|
+
) -> R:
|
|
26
|
+
"""Read raster data from a file and return an in-memory Raster object.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
raster_path: Path to the raster file.
|
|
30
|
+
crs: Optional CRS to override the raster's native CRS.
|
|
31
|
+
cls: The Raster subclass to instantiate. This is mostly for internal use,
|
|
32
|
+
but can be useful if you have a custom `Raster` subclass.
|
|
33
|
+
"""
|
|
22
34
|
crs = CRS.from_user_input(crs) if crs is not None else None
|
|
23
35
|
|
|
24
36
|
with rasterio.open(raster_path, mode="r") as dst:
|
|
25
37
|
# Read the entire array
|
|
26
|
-
|
|
27
|
-
|
|
38
|
+
raw_arr: NDArray = dst.read()
|
|
39
|
+
raw_arr = raw_arr.squeeze()
|
|
40
|
+
|
|
28
41
|
# Extract metadata
|
|
29
42
|
cell_size = dst.res[0]
|
|
30
43
|
if crs is None:
|
|
31
44
|
crs = CRS.from_user_input(dst.crs)
|
|
32
45
|
transform = dst.transform
|
|
33
46
|
nodata = dst.nodata
|
|
47
|
+
|
|
48
|
+
# Cast integers to float16 to handle NaN values
|
|
49
|
+
if np.issubdtype(raw_arr.dtype, np.integer):
|
|
50
|
+
arr = raw_arr.astype(np.float16)
|
|
51
|
+
else:
|
|
52
|
+
arr = raw_arr
|
|
53
|
+
|
|
34
54
|
if nodata is not None:
|
|
35
|
-
arr[
|
|
55
|
+
arr[raw_arr == nodata] = np.nan
|
|
36
56
|
|
|
37
57
|
raster_meta = RasterMeta(cell_size=cell_size, crs=crs, transform=transform)
|
|
38
|
-
raster_obj =
|
|
58
|
+
raster_obj = cls(arr=arr, raster_meta=raster_meta)
|
|
39
59
|
return raster_obj
|
|
40
60
|
|
|
41
61
|
|
|
@@ -81,10 +101,16 @@ def read_raster_mosaic_inmem(
|
|
|
81
101
|
crs = CRS.from_user_input(sources[0].crs)
|
|
82
102
|
|
|
83
103
|
nodata = sources[0].nodata
|
|
84
|
-
|
|
85
|
-
arr[arr == nodata] = np.nan
|
|
104
|
+
raw_arr = arr.squeeze()
|
|
86
105
|
|
|
87
|
-
|
|
106
|
+
# Cast integers to float16 to handle NaN values
|
|
107
|
+
if np.issubdtype(raw_arr.dtype, np.integer):
|
|
108
|
+
arr = raw_arr.astype(np.float16)
|
|
109
|
+
else:
|
|
110
|
+
arr = raw_arr
|
|
111
|
+
|
|
112
|
+
if nodata is not None:
|
|
113
|
+
arr[raw_arr == nodata] = np.nan
|
|
88
114
|
|
|
89
115
|
raster_meta = RasterMeta(cell_size=cell_size, crs=crs, transform=transform)
|
|
90
116
|
raster_obj = Raster(arr=arr, raster_meta=raster_meta)
|
|
@@ -108,6 +108,16 @@ class Raster(BaseModel):
|
|
|
108
108
|
"""Set the transform via meta."""
|
|
109
109
|
self.meta.transform = value
|
|
110
110
|
|
|
111
|
+
@property
|
|
112
|
+
def cell_size(self) -> float:
|
|
113
|
+
"""Convenience property to access the cell size via meta."""
|
|
114
|
+
return self.meta.cell_size
|
|
115
|
+
|
|
116
|
+
@cell_size.setter
|
|
117
|
+
def cell_size(self, value: float) -> None:
|
|
118
|
+
"""Set the cell size via meta."""
|
|
119
|
+
self.meta.cell_size = value
|
|
120
|
+
|
|
111
121
|
def __init__(
|
|
112
122
|
self,
|
|
113
123
|
*,
|
|
@@ -663,8 +673,15 @@ class Raster(BaseModel):
|
|
|
663
673
|
|
|
664
674
|
return raster_gdf
|
|
665
675
|
|
|
666
|
-
def to_file(self, path: Path | str) -> None:
|
|
667
|
-
"""Write the raster to a GeoTIFF file.
|
|
676
|
+
def to_file(self, path: Path | str, **kwargs: Any) -> None:
|
|
677
|
+
"""Write the raster to a GeoTIFF file.
|
|
678
|
+
|
|
679
|
+
Args:
|
|
680
|
+
path: Path to output file.
|
|
681
|
+
**kwargs: Additional keyword arguments to pass to `rasterio.open()`. If
|
|
682
|
+
`nodata` is provided, NaN values in the raster will be replaced
|
|
683
|
+
with the nodata value.
|
|
684
|
+
"""
|
|
668
685
|
|
|
669
686
|
path = Path(path)
|
|
670
687
|
|
|
@@ -679,6 +696,15 @@ class Raster(BaseModel):
|
|
|
679
696
|
msg = f"Unsupported file extension: {suffix}"
|
|
680
697
|
raise ValueError(msg)
|
|
681
698
|
|
|
699
|
+
# Handle nodata: use provided value or default to np.nan
|
|
700
|
+
if "nodata" in kwargs:
|
|
701
|
+
# Replace NaN values with the nodata value
|
|
702
|
+
nodata_value = kwargs.pop("nodata")
|
|
703
|
+
arr_to_write = np.where(np.isnan(self.arr), nodata_value, self.arr)
|
|
704
|
+
else:
|
|
705
|
+
nodata_value = np.nan
|
|
706
|
+
arr_to_write = self.arr
|
|
707
|
+
|
|
682
708
|
with rasterio.open(
|
|
683
709
|
path,
|
|
684
710
|
"w",
|
|
@@ -689,10 +715,11 @@ class Raster(BaseModel):
|
|
|
689
715
|
dtype=self.arr.dtype,
|
|
690
716
|
crs=self.raster_meta.crs,
|
|
691
717
|
transform=self.raster_meta.transform,
|
|
692
|
-
nodata=
|
|
718
|
+
nodata=nodata_value,
|
|
719
|
+
**kwargs,
|
|
693
720
|
) as dst:
|
|
694
721
|
try:
|
|
695
|
-
dst.write(
|
|
722
|
+
dst.write(arr_to_write, 1)
|
|
696
723
|
except CPLE_BaseError as err:
|
|
697
724
|
msg = f"Failed to write raster to file: {err}"
|
|
698
725
|
raise OSError(msg) from err
|
|
@@ -719,6 +746,34 @@ class Raster(BaseModel):
|
|
|
719
746
|
raster_meta = RasterMeta.example()
|
|
720
747
|
return cls(arr=arr, raster_meta=raster_meta)
|
|
721
748
|
|
|
749
|
+
@classmethod
|
|
750
|
+
def full_like(cls, other: Raster, *, fill_value: float) -> Self:
|
|
751
|
+
"""Create a raster with the same metadata as another but filled with a constant.
|
|
752
|
+
|
|
753
|
+
Args:
|
|
754
|
+
other: The raster to copy metadata from.
|
|
755
|
+
fill_value: The constant value to fill all cells with.
|
|
756
|
+
|
|
757
|
+
Returns:
|
|
758
|
+
A new raster with the same shape and metadata as `other`, but with all cells
|
|
759
|
+
set to `fill_value`.
|
|
760
|
+
"""
|
|
761
|
+
arr = np.full(other.shape, fill_value, dtype=np.float32)
|
|
762
|
+
return cls(arr=arr, raster_meta=other.raster_meta)
|
|
763
|
+
|
|
764
|
+
@classmethod
|
|
765
|
+
def read_file(cls, filename: Path | str, crs: CRS | str | None = None) -> Self:
|
|
766
|
+
"""Read raster data from a file and return an in-memory Raster object.
|
|
767
|
+
|
|
768
|
+
Args:
|
|
769
|
+
filename: Path to the raster file.
|
|
770
|
+
crs: Optional coordinate reference system to override the file's CRS.
|
|
771
|
+
"""
|
|
772
|
+
# Import here to avoid circular import (rastr.io imports Raster)
|
|
773
|
+
from rastr.io import read_raster_inmem # noqa: PLC0415
|
|
774
|
+
|
|
775
|
+
return read_raster_inmem(filename, crs=crs, cls=cls)
|
|
776
|
+
|
|
722
777
|
@overload
|
|
723
778
|
def apply(
|
|
724
779
|
self,
|
|
@@ -819,6 +874,16 @@ class Raster(BaseModel):
|
|
|
819
874
|
new_raster.arr = filled_arr
|
|
820
875
|
return new_raster
|
|
821
876
|
|
|
877
|
+
def copy(self) -> Self: # type: ignore[override]
|
|
878
|
+
"""Create a copy of the raster.
|
|
879
|
+
|
|
880
|
+
This method wraps `model_copy()` for convenience.
|
|
881
|
+
|
|
882
|
+
Returns:
|
|
883
|
+
A new Raster instance.
|
|
884
|
+
"""
|
|
885
|
+
return self.model_copy(deep=True)
|
|
886
|
+
|
|
822
887
|
def get_xy(self) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
|
|
823
888
|
"""Get the x and y coordinates of the raster cell centres in meshgrid format.
|
|
824
889
|
|
|
@@ -835,7 +900,7 @@ class Raster(BaseModel):
|
|
|
835
900
|
return coords[:, :, 0], coords[:, :, 1]
|
|
836
901
|
|
|
837
902
|
def contour(
|
|
838
|
-
self, levels:
|
|
903
|
+
self, levels: Collection[float] | NDArray, *, smoothing: bool = True
|
|
839
904
|
) -> gpd.GeoDataFrame:
|
|
840
905
|
"""Create contour lines from the raster data, optionally with smoothing.
|
|
841
906
|
|
|
@@ -848,8 +913,8 @@ class Raster(BaseModel):
|
|
|
848
913
|
contouring, to denoise the contours.
|
|
849
914
|
|
|
850
915
|
Args:
|
|
851
|
-
levels: A
|
|
852
|
-
will be generated for each level in this sequence.
|
|
916
|
+
levels: A collection or array of contour levels to generate. The contour
|
|
917
|
+
lines will be generated for each level in this sequence.
|
|
853
918
|
smoothing: Defaults to true, which corresponds to applying a smoothing
|
|
854
919
|
algorithm to the contour lines. At the moment, this is the
|
|
855
920
|
Catmull-Rom spline algorithm. If set to False, the raw
|
|
@@ -905,19 +970,40 @@ class Raster(BaseModel):
|
|
|
905
970
|
# Dissolve contours by level to merge all contour lines of the same level
|
|
906
971
|
return contour_gdf.dissolve(by="level", as_index=False)
|
|
907
972
|
|
|
908
|
-
def blur(self, sigma: float) -> Self:
|
|
973
|
+
def blur(self, sigma: float, *, preserve_nan: bool = True) -> Self:
|
|
909
974
|
"""Apply a Gaussian blur to the raster data.
|
|
910
975
|
|
|
911
976
|
Args:
|
|
912
977
|
sigma: Standard deviation for Gaussian kernel, in units of geographic
|
|
913
978
|
coordinate distance (e.g. meters). A larger sigma results in a more
|
|
914
979
|
blurred image.
|
|
980
|
+
preserve_nan: If True, applies NaN-safe blurring by extrapolating NaN values
|
|
981
|
+
before blurring and restoring them afterwards. This prevents
|
|
982
|
+
NaNs from spreading into valid data during the blur operation.
|
|
915
983
|
"""
|
|
916
984
|
from scipy.ndimage import gaussian_filter
|
|
917
985
|
|
|
918
986
|
cell_sigma = sigma / self.raster_meta.cell_size
|
|
919
987
|
|
|
920
|
-
|
|
988
|
+
if preserve_nan:
|
|
989
|
+
# Save the original NaN mask
|
|
990
|
+
nan_mask = np.isnan(self.arr)
|
|
991
|
+
|
|
992
|
+
# If there are no NaNs, just apply regular blur
|
|
993
|
+
if not np.any(nan_mask):
|
|
994
|
+
blurred_array = gaussian_filter(self.arr, sigma=cell_sigma)
|
|
995
|
+
else:
|
|
996
|
+
# Extrapolate to fill NaN values temporarily
|
|
997
|
+
extrapolated_arr = fillna_nearest_neighbours(arr=self.arr)
|
|
998
|
+
|
|
999
|
+
# Apply blur to the extrapolated array
|
|
1000
|
+
blurred_array = gaussian_filter(extrapolated_arr, sigma=cell_sigma)
|
|
1001
|
+
|
|
1002
|
+
# Restore original NaN values
|
|
1003
|
+
blurred_array = np.where(nan_mask, np.nan, blurred_array)
|
|
1004
|
+
else:
|
|
1005
|
+
blurred_array = gaussian_filter(self.arr, sigma=cell_sigma)
|
|
1006
|
+
|
|
921
1007
|
new_raster = self.model_copy()
|
|
922
1008
|
new_raster.arr = blurred_array
|
|
923
1009
|
return new_raster
|
|
@@ -1175,29 +1261,27 @@ class Raster(BaseModel):
|
|
|
1175
1261
|
|
|
1176
1262
|
return raster
|
|
1177
1263
|
|
|
1178
|
-
def
|
|
1179
|
-
"""Crop the raster by trimming away
|
|
1264
|
+
def _trim_value(self, *, value_mask: NDArray[np.bool_], value_name: str) -> Self:
|
|
1265
|
+
"""Crop the raster by trimming away slices matching the mask at the edges.
|
|
1180
1266
|
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
Consider using `.extrapolate()` for further cleanup of NaN values.
|
|
1267
|
+
Args:
|
|
1268
|
+
value_mask: Boolean mask where True indicates values to trim
|
|
1269
|
+
value_name: Name of the value type for error messages (e.g., 'NaN', 'zero')
|
|
1186
1270
|
"""
|
|
1187
1271
|
arr = self.arr
|
|
1188
1272
|
|
|
1189
|
-
# Check if the entire array
|
|
1190
|
-
if np.all(
|
|
1191
|
-
msg = "Cannot crop raster: all values are
|
|
1273
|
+
# Check if the entire array matches the mask
|
|
1274
|
+
if np.all(value_mask):
|
|
1275
|
+
msg = f"Cannot crop raster: all values are {value_name}"
|
|
1192
1276
|
raise ValueError(msg)
|
|
1193
1277
|
|
|
1194
|
-
# Find rows and columns that are not all
|
|
1195
|
-
|
|
1196
|
-
|
|
1278
|
+
# Find rows and columns that are not all matching the mask
|
|
1279
|
+
row_mask = np.all(value_mask, axis=1)
|
|
1280
|
+
col_mask = np.all(value_mask, axis=0)
|
|
1197
1281
|
|
|
1198
1282
|
# Find the bounding indices
|
|
1199
|
-
(row_indices,) = np.where(~
|
|
1200
|
-
(col_indices,) = np.where(~
|
|
1283
|
+
(row_indices,) = np.where(~row_mask)
|
|
1284
|
+
(col_indices,) = np.where(~col_mask)
|
|
1201
1285
|
|
|
1202
1286
|
min_row, max_row = row_indices[0], row_indices[-1]
|
|
1203
1287
|
min_col, max_col = col_indices[0], col_indices[-1]
|
|
@@ -1220,6 +1304,26 @@ class Raster(BaseModel):
|
|
|
1220
1304
|
|
|
1221
1305
|
return self.__class__(arr=cropped_arr, raster_meta=new_meta)
|
|
1222
1306
|
|
|
1307
|
+
def trim_nan(self) -> Self:
|
|
1308
|
+
"""Crop the raster by trimming away all-NaN slices at the edges.
|
|
1309
|
+
|
|
1310
|
+
This effectively trims the raster to the smallest bounding box that contains all
|
|
1311
|
+
of the non-NaN values. Note that this does not guarantee no NaN values at all
|
|
1312
|
+
around the edges, only that there won't be entire edges which are all-NaN.
|
|
1313
|
+
|
|
1314
|
+
Consider using `.extrapolate()` for further cleanup of NaN values.
|
|
1315
|
+
"""
|
|
1316
|
+
return self._trim_value(value_mask=np.isnan(self.arr), value_name="NaN")
|
|
1317
|
+
|
|
1318
|
+
def trim_zeros(self) -> Self:
|
|
1319
|
+
"""Crop the raster by trimming away all-zero slices at the edges.
|
|
1320
|
+
|
|
1321
|
+
This effectively trims the raster to the smallest bounding box that contains all
|
|
1322
|
+
of the non-zero values. Note that this does not guarantee no zero values at all
|
|
1323
|
+
around the edges, only that there won't be entire edges which are all-zero.
|
|
1324
|
+
"""
|
|
1325
|
+
return self._trim_value(value_mask=(self.arr == 0), value_name="zero")
|
|
1326
|
+
|
|
1223
1327
|
def resample(
|
|
1224
1328
|
self, new_cell_size: float, *, method: Literal["bilinear"] = "bilinear"
|
|
1225
1329
|
) -> Self:
|
|
@@ -1307,11 +1411,11 @@ def _get_vmin_vmax(
|
|
|
1307
1411
|
category=RuntimeWarning,
|
|
1308
1412
|
)
|
|
1309
1413
|
if vmin is None:
|
|
1310
|
-
_vmin = raster.min()
|
|
1414
|
+
_vmin = float(raster.min())
|
|
1311
1415
|
else:
|
|
1312
1416
|
_vmin = vmin
|
|
1313
1417
|
if vmax is None:
|
|
1314
|
-
_vmax = raster.max()
|
|
1418
|
+
_vmax = float(raster.max())
|
|
1315
1419
|
else:
|
|
1316
1420
|
_vmax = vmax
|
|
1317
1421
|
|