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.

Files changed (93) hide show
  1. {rastr-0.5.0 → rastr-0.7.0}/.github/copilot-instructions.md +3 -1
  2. {rastr-0.5.0 → rastr-0.7.0}/.github/workflows/ci.yml +20 -4
  3. rastr-0.7.0/.github/workflows/release.yml +35 -0
  4. {rastr-0.5.0 → rastr-0.7.0}/PKG-INFO +4 -4
  5. {rastr-0.5.0 → rastr-0.7.0}/README.md +2 -2
  6. {rastr-0.5.0 → rastr-0.7.0}/pyproject.toml +2 -1
  7. rastr-0.7.0/src/rastr/__init__.py +7 -0
  8. {rastr-0.5.0 → rastr-0.7.0}/src/rastr/_version.py +2 -2
  9. {rastr-0.5.0 → rastr-0.7.0}/src/rastr/arr/fill.py +3 -2
  10. {rastr-0.5.0 → rastr-0.7.0}/src/rastr/create.py +42 -53
  11. rastr-0.7.0/src/rastr/gis/crs.py +67 -0
  12. {rastr-0.5.0 → rastr-0.7.0}/src/rastr/gis/fishnet.py +14 -2
  13. rastr-0.7.0/src/rastr/gis/interpolate.py +50 -0
  14. rastr-0.7.0/src/rastr/gis/smooth.py +161 -0
  15. {rastr-0.5.0 → rastr-0.7.0}/src/rastr/io.py +37 -11
  16. rastr-0.7.0/src/rastr/meta.py +185 -0
  17. {rastr-0.5.0 → rastr-0.7.0}/src/rastr/raster.py +193 -27
  18. rastr-0.7.0/tests/conftest.py +62 -0
  19. rastr-0.7.0/tests/rastr/gis/test_crs.py +53 -0
  20. {rastr-0.5.0 → rastr-0.7.0}/tests/rastr/gis/test_smooth.py +5 -3
  21. {rastr-0.5.0 → rastr-0.7.0}/tests/rastr/test_create.py +74 -0
  22. {rastr-0.5.0 → rastr-0.7.0}/tests/rastr/test_io.py +87 -5
  23. {rastr-0.5.0 → rastr-0.7.0}/tests/rastr/test_raster.py +992 -22
  24. {rastr-0.5.0 → rastr-0.7.0}/uv.lock +20 -0
  25. rastr-0.5.0/.github/workflows/release.yml +0 -32
  26. rastr-0.5.0/src/rastr/gis/__init__.py +0 -0
  27. rastr-0.5.0/src/rastr/gis/smooth.py +0 -142
  28. rastr-0.5.0/src/rastr/meta.py +0 -87
  29. rastr-0.5.0/tests/conftest.py +0 -12
  30. {rastr-0.5.0 → rastr-0.7.0}/.copier-answers.yml +0 -0
  31. {rastr-0.5.0 → rastr-0.7.0}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
  32. {rastr-0.5.0 → rastr-0.7.0}/.github/ISSUE_TEMPLATE/enhancement.md +0 -0
  33. {rastr-0.5.0 → rastr-0.7.0}/.gitignore +0 -0
  34. {rastr-0.5.0 → rastr-0.7.0}/.pre-commit-config.yaml +0 -0
  35. {rastr-0.5.0 → rastr-0.7.0}/.python-version +0 -0
  36. {rastr-0.5.0 → rastr-0.7.0}/CONTRIBUTING.md +0 -0
  37. {rastr-0.5.0 → rastr-0.7.0}/LICENSE +0 -0
  38. {rastr-0.5.0 → rastr-0.7.0}/docs/index.md +0 -0
  39. {rastr-0.5.0 → rastr-0.7.0}/docs/logo.svg +0 -0
  40. {rastr-0.5.0 → rastr-0.7.0}/mkdocs.yml +0 -0
  41. {rastr-0.5.0 → rastr-0.7.0}/pyrightconfig.json +0 -0
  42. {rastr-0.5.0 → rastr-0.7.0}/requirements.txt +0 -0
  43. {rastr-0.5.0 → rastr-0.7.0}/src/archive/.gitkeep +0 -0
  44. {rastr-0.5.0 → rastr-0.7.0}/src/notebooks/.gitkeep +0 -0
  45. {rastr-0.5.0/src/rastr → rastr-0.7.0/src/rastr/arr}/__init__.py +0 -0
  46. {rastr-0.5.0/src/rastr/arr → rastr-0.7.0/src/rastr/gis}/__init__.py +0 -0
  47. {rastr-0.5.0 → rastr-0.7.0}/src/scripts/.gitkeep +0 -0
  48. {rastr-0.5.0 → rastr-0.7.0}/src/scripts/demo_point_cloud.py +0 -0
  49. {rastr-0.5.0 → rastr-0.7.0}/src/scripts/demo_taper_border.py +0 -0
  50. {rastr-0.5.0 → rastr-0.7.0}/tasks/ABOUT_TASKS.md +0 -0
  51. {rastr-0.5.0 → rastr-0.7.0}/tasks/dev_sync.ps1 +0 -0
  52. {rastr-0.5.0 → rastr-0.7.0}/tasks/scripts/activate_venv.sh +0 -0
  53. {rastr-0.5.0 → rastr-0.7.0}/tasks/scripts/configure_project.sh +0 -0
  54. {rastr-0.5.0 → rastr-0.7.0}/tasks/scripts/dev_sync.sh +0 -0
  55. {rastr-0.5.0 → rastr-0.7.0}/tasks/scripts/install_backend.sh +0 -0
  56. {rastr-0.5.0 → rastr-0.7.0}/tasks/scripts/install_venv.sh +0 -0
  57. {rastr-0.5.0 → rastr-0.7.0}/tasks/scripts/recover_corrupt_venv.sh +0 -0
  58. {rastr-0.5.0 → rastr-0.7.0}/tasks/scripts/sh_runner.ps1 +0 -0
  59. {rastr-0.5.0 → rastr-0.7.0}/tasks/scripts/sync_requirements.sh +0 -0
  60. {rastr-0.5.0 → rastr-0.7.0}/tasks/scripts/sync_template.sh +0 -0
  61. {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/activate_venv +0 -0
  62. {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/activate_venv.cmd +0 -0
  63. {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/activate_venv.ps1 +0 -0
  64. {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/configure_project +0 -0
  65. {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/configure_project.cmd +0 -0
  66. {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/configure_project.ps1 +0 -0
  67. {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/dev_sync +0 -0
  68. {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/dev_sync.cmd +0 -0
  69. {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/dev_sync.ps1 +0 -0
  70. {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/install_backend +0 -0
  71. {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/install_backend.cmd +0 -0
  72. {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/install_backend.ps1 +0 -0
  73. {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/install_venv +0 -0
  74. {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/install_venv.cmd +0 -0
  75. {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/install_venv.ps1 +0 -0
  76. {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/recover_corrupt_venv +0 -0
  77. {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/recover_corrupt_venv.cmd +0 -0
  78. {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/recover_corrupt_venv.ps1 +0 -0
  79. {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/sync_requirements +0 -0
  80. {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/sync_requirements.cmd +0 -0
  81. {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/sync_requirements.ps1 +0 -0
  82. {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/sync_template +0 -0
  83. {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/sync_template.cmd +0 -0
  84. {rastr-0.5.0 → rastr-0.7.0}/tasks/shims/sync_template.ps1 +0 -0
  85. {rastr-0.5.0 → rastr-0.7.0}/tasks/sync_template.ps1 +0 -0
  86. {rastr-0.5.0 → rastr-0.7.0}/tests/assets/.gitkeep +0 -0
  87. {rastr-0.5.0 → rastr-0.7.0}/tests/assets/pga_g_clipped.grd +0 -0
  88. {rastr-0.5.0 → rastr-0.7.0}/tests/assets/pga_g_clipped.tif +0 -0
  89. {rastr-0.5.0 → rastr-0.7.0}/tests/rastr/.gitkeep +0 -0
  90. {rastr-0.5.0 → rastr-0.7.0}/tests/rastr/gis/test_fishnet.py +0 -0
  91. {rastr-0.5.0 → rastr-0.7.0}/tests/rastr/regression_test_data/test_plot_raster.png +0 -0
  92. {rastr-0.5.0 → rastr-0.7.0}/tests/rastr/regression_test_data/test_write_raster_to_file.tif +0 -0
  93. {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
- - Use only one assert statement per test function to ensure clarity and simplicity.
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 ${{ matrix.python-version }}
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=${{ vars.SONAR_PROJECT_KEY }}
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@884b79409bbd464b2a59edc326a4b77dc56b2195 # v3.1.0
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.5.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/fde05e3c098da7ff9e77a37332d55fb7dbacd873.zip
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
- # Create an example raster
81
- raster = Raster.example()
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
- # Create an example raster
47
- raster = Raster.example()
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 | io", "raster", "meta", "arr | gis", "_version" ]
267
+ layers = [ "create : io : raster", "meta", "arr | gis", "_version" ]
267
268
  containers = [ "rastr" ]
268
269
  exhaustive = true
@@ -0,0 +1,7 @@
1
+ from rastr.meta import RasterMeta
2
+ from rastr.raster import Raster
3
+
4
+ __all__ = [
5
+ "Raster",
6
+ "RasterMeta",
7
+ ]
@@ -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.5.0'
32
- __version_tuple__ = version_tuple = (0, 5, 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[np.float64]) -> NDArray[np.float64]:
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
- return filled_arr
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: list[str],
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, -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(gdf: gpd.GeoDataFrame, target_cols: list[str]) -> None:
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(gdf: gpd.GeoDataFrame, target_cols: list[str]) -> None:
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
- # Heuristic for cell size if not provided
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
- # Create grid coordinates for raster cells
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
- # Perform interpolation
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
- ncols = int(np.ceil((xmax - xmin) / cell_size))
45
- nrows = int(np.ceil((ymax - ymin) / cell_size))
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)