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.

Files changed (87) hide show
  1. {rastr-0.5.0 → rastr-0.6.0}/.github/workflows/ci.yml +20 -4
  2. rastr-0.6.0/.github/workflows/release.yml +35 -0
  3. {rastr-0.5.0 → rastr-0.6.0}/PKG-INFO +4 -4
  4. {rastr-0.5.0 → rastr-0.6.0}/README.md +2 -2
  5. {rastr-0.5.0 → rastr-0.6.0}/pyproject.toml +2 -1
  6. {rastr-0.5.0 → rastr-0.6.0}/src/rastr/_version.py +2 -2
  7. {rastr-0.5.0 → rastr-0.6.0}/src/rastr/arr/fill.py +3 -2
  8. {rastr-0.5.0 → rastr-0.6.0}/src/rastr/create.py +8 -4
  9. {rastr-0.5.0 → rastr-0.6.0}/src/rastr/gis/fishnet.py +14 -2
  10. {rastr-0.5.0 → rastr-0.6.0}/src/rastr/gis/smooth.py +4 -4
  11. {rastr-0.5.0 → rastr-0.6.0}/src/rastr/io.py +37 -11
  12. {rastr-0.5.0 → rastr-0.6.0}/src/rastr/raster.py +130 -26
  13. rastr-0.6.0/tests/conftest.py +54 -0
  14. {rastr-0.5.0 → rastr-0.6.0}/tests/rastr/test_create.py +74 -0
  15. {rastr-0.5.0 → rastr-0.6.0}/tests/rastr/test_io.py +87 -5
  16. {rastr-0.5.0 → rastr-0.6.0}/tests/rastr/test_raster.py +786 -0
  17. {rastr-0.5.0 → rastr-0.6.0}/uv.lock +20 -0
  18. rastr-0.5.0/.github/workflows/release.yml +0 -32
  19. rastr-0.5.0/tests/conftest.py +0 -12
  20. {rastr-0.5.0 → rastr-0.6.0}/.copier-answers.yml +0 -0
  21. {rastr-0.5.0 → rastr-0.6.0}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
  22. {rastr-0.5.0 → rastr-0.6.0}/.github/ISSUE_TEMPLATE/enhancement.md +0 -0
  23. {rastr-0.5.0 → rastr-0.6.0}/.github/copilot-instructions.md +0 -0
  24. {rastr-0.5.0 → rastr-0.6.0}/.gitignore +0 -0
  25. {rastr-0.5.0 → rastr-0.6.0}/.pre-commit-config.yaml +0 -0
  26. {rastr-0.5.0 → rastr-0.6.0}/.python-version +0 -0
  27. {rastr-0.5.0 → rastr-0.6.0}/CONTRIBUTING.md +0 -0
  28. {rastr-0.5.0 → rastr-0.6.0}/LICENSE +0 -0
  29. {rastr-0.5.0 → rastr-0.6.0}/docs/index.md +0 -0
  30. {rastr-0.5.0 → rastr-0.6.0}/docs/logo.svg +0 -0
  31. {rastr-0.5.0 → rastr-0.6.0}/mkdocs.yml +0 -0
  32. {rastr-0.5.0 → rastr-0.6.0}/pyrightconfig.json +0 -0
  33. {rastr-0.5.0 → rastr-0.6.0}/requirements.txt +0 -0
  34. {rastr-0.5.0 → rastr-0.6.0}/src/archive/.gitkeep +0 -0
  35. {rastr-0.5.0 → rastr-0.6.0}/src/notebooks/.gitkeep +0 -0
  36. {rastr-0.5.0 → rastr-0.6.0}/src/rastr/__init__.py +0 -0
  37. {rastr-0.5.0 → rastr-0.6.0}/src/rastr/arr/__init__.py +0 -0
  38. {rastr-0.5.0 → rastr-0.6.0}/src/rastr/gis/__init__.py +0 -0
  39. {rastr-0.5.0 → rastr-0.6.0}/src/rastr/meta.py +0 -0
  40. {rastr-0.5.0 → rastr-0.6.0}/src/scripts/.gitkeep +0 -0
  41. {rastr-0.5.0 → rastr-0.6.0}/src/scripts/demo_point_cloud.py +0 -0
  42. {rastr-0.5.0 → rastr-0.6.0}/src/scripts/demo_taper_border.py +0 -0
  43. {rastr-0.5.0 → rastr-0.6.0}/tasks/ABOUT_TASKS.md +0 -0
  44. {rastr-0.5.0 → rastr-0.6.0}/tasks/dev_sync.ps1 +0 -0
  45. {rastr-0.5.0 → rastr-0.6.0}/tasks/scripts/activate_venv.sh +0 -0
  46. {rastr-0.5.0 → rastr-0.6.0}/tasks/scripts/configure_project.sh +0 -0
  47. {rastr-0.5.0 → rastr-0.6.0}/tasks/scripts/dev_sync.sh +0 -0
  48. {rastr-0.5.0 → rastr-0.6.0}/tasks/scripts/install_backend.sh +0 -0
  49. {rastr-0.5.0 → rastr-0.6.0}/tasks/scripts/install_venv.sh +0 -0
  50. {rastr-0.5.0 → rastr-0.6.0}/tasks/scripts/recover_corrupt_venv.sh +0 -0
  51. {rastr-0.5.0 → rastr-0.6.0}/tasks/scripts/sh_runner.ps1 +0 -0
  52. {rastr-0.5.0 → rastr-0.6.0}/tasks/scripts/sync_requirements.sh +0 -0
  53. {rastr-0.5.0 → rastr-0.6.0}/tasks/scripts/sync_template.sh +0 -0
  54. {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/activate_venv +0 -0
  55. {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/activate_venv.cmd +0 -0
  56. {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/activate_venv.ps1 +0 -0
  57. {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/configure_project +0 -0
  58. {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/configure_project.cmd +0 -0
  59. {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/configure_project.ps1 +0 -0
  60. {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/dev_sync +0 -0
  61. {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/dev_sync.cmd +0 -0
  62. {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/dev_sync.ps1 +0 -0
  63. {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/install_backend +0 -0
  64. {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/install_backend.cmd +0 -0
  65. {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/install_backend.ps1 +0 -0
  66. {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/install_venv +0 -0
  67. {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/install_venv.cmd +0 -0
  68. {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/install_venv.ps1 +0 -0
  69. {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/recover_corrupt_venv +0 -0
  70. {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/recover_corrupt_venv.cmd +0 -0
  71. {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/recover_corrupt_venv.ps1 +0 -0
  72. {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/sync_requirements +0 -0
  73. {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/sync_requirements.cmd +0 -0
  74. {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/sync_requirements.ps1 +0 -0
  75. {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/sync_template +0 -0
  76. {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/sync_template.cmd +0 -0
  77. {rastr-0.5.0 → rastr-0.6.0}/tasks/shims/sync_template.ps1 +0 -0
  78. {rastr-0.5.0 → rastr-0.6.0}/tasks/sync_template.ps1 +0 -0
  79. {rastr-0.5.0 → rastr-0.6.0}/tests/assets/.gitkeep +0 -0
  80. {rastr-0.5.0 → rastr-0.6.0}/tests/assets/pga_g_clipped.grd +0 -0
  81. {rastr-0.5.0 → rastr-0.6.0}/tests/assets/pga_g_clipped.tif +0 -0
  82. {rastr-0.5.0 → rastr-0.6.0}/tests/rastr/.gitkeep +0 -0
  83. {rastr-0.5.0 → rastr-0.6.0}/tests/rastr/gis/test_fishnet.py +0 -0
  84. {rastr-0.5.0 → rastr-0.6.0}/tests/rastr/gis/test_smooth.py +0 -0
  85. {rastr-0.5.0 → rastr-0.6.0}/tests/rastr/regression_test_data/test_plot_raster.png +0 -0
  86. {rastr-0.5.0 → rastr-0.6.0}/tests/rastr/regression_test_data/test_write_raster_to_file.tif +0 -0
  87. {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 ${{ 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.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/fde05e3c098da7ff9e77a37332d55fb7dbacd873.zip
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
- # 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
@@ -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.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[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)
@@ -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: list[str],
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(gdf: gpd.GeoDataFrame, target_cols: list[str]) -> None:
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(gdf: gpd.GeoDataFrame, target_cols: list[str]) -> None:
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
- 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
 
@@ -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, TypeAlias
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: TypeAlias = LineString | Polygon
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 type(geometry)(coords_smoothed)
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 type(geometry)(coords_smoothed, holes=interior_coords_smoothed)
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, *, crs: CRS | str | None = None
20
- ) -> Raster:
21
- """Read raster data from a file and return an in-memory Raster object."""
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
- arr: NDArray[np.float64] = dst.read()
27
- arr = arr.squeeze().astype(np.float64)
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[arr == nodata] = np.nan
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 = Raster(arr=arr, raster_meta=raster_meta)
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
- if nodata is not None:
85
- arr[arr == nodata] = np.nan
104
+ raw_arr = arr.squeeze()
86
105
 
87
- arr = arr.squeeze().astype(np.float64)
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=np.nan,
718
+ nodata=nodata_value,
719
+ **kwargs,
693
720
  ) as dst:
694
721
  try:
695
- dst.write(self.arr, 1)
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: list[float] | NDArray, *, smoothing: bool = True
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 list or array of contour levels to generate. The contour lines
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
- blurred_array = gaussian_filter(self.arr, sigma=cell_sigma)
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 trim_nan(self) -> Self:
1179
- """Crop the raster by trimming away all-NaN slices at the edges.
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
- This effectively trims the raster to the smallest bounding box that contains all
1182
- of the non-NaN values. Note that this does not guarantee no NaN values at all
1183
- around the edges, only that there won't be entire edges which are all-NaN.
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 is NaN
1190
- if np.all(np.isnan(arr)):
1191
- msg = "Cannot crop raster: all values are NaN"
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 NaN
1195
- nan_row_mask = np.all(np.isnan(arr), axis=1)
1196
- nan_col_mask = np.all(np.isnan(arr), axis=0)
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(~nan_row_mask)
1200
- (col_indices,) = np.where(~nan_col_mask)
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