rastr 0.3.0__tar.gz → 0.4.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 (84) hide show
  1. {rastr-0.3.0 → rastr-0.4.0}/PKG-INFO +2 -2
  2. {rastr-0.3.0 → rastr-0.4.0}/src/rastr/_version.py +2 -2
  3. {rastr-0.3.0 → rastr-0.4.0}/src/rastr/raster.py +65 -10
  4. {rastr-0.3.0 → rastr-0.4.0}/tests/rastr/test_raster.py +145 -5
  5. {rastr-0.3.0 → rastr-0.4.0}/.copier-answers.yml +0 -0
  6. {rastr-0.3.0 → rastr-0.4.0}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
  7. {rastr-0.3.0 → rastr-0.4.0}/.github/ISSUE_TEMPLATE/enhancement.md +0 -0
  8. {rastr-0.3.0 → rastr-0.4.0}/.github/copilot-instructions.md +0 -0
  9. {rastr-0.3.0 → rastr-0.4.0}/.github/workflows/ci.yml +0 -0
  10. {rastr-0.3.0 → rastr-0.4.0}/.github/workflows/release.yml +0 -0
  11. {rastr-0.3.0 → rastr-0.4.0}/.gitignore +0 -0
  12. {rastr-0.3.0 → rastr-0.4.0}/.pre-commit-config.yaml +0 -0
  13. {rastr-0.3.0 → rastr-0.4.0}/.python-version +0 -0
  14. {rastr-0.3.0 → rastr-0.4.0}/CONTRIBUTING.md +0 -0
  15. {rastr-0.3.0 → rastr-0.4.0}/LICENSE +0 -0
  16. {rastr-0.3.0 → rastr-0.4.0}/README.md +0 -0
  17. {rastr-0.3.0 → rastr-0.4.0}/docs/index.md +0 -0
  18. {rastr-0.3.0 → rastr-0.4.0}/docs/logo.svg +0 -0
  19. {rastr-0.3.0 → rastr-0.4.0}/mkdocs.yml +0 -0
  20. {rastr-0.3.0 → rastr-0.4.0}/pyproject.toml +0 -0
  21. {rastr-0.3.0 → rastr-0.4.0}/pyrightconfig.json +0 -0
  22. {rastr-0.3.0 → rastr-0.4.0}/requirements.txt +0 -0
  23. {rastr-0.3.0 → rastr-0.4.0}/src/archive/.gitkeep +0 -0
  24. {rastr-0.3.0 → rastr-0.4.0}/src/notebooks/.gitkeep +0 -0
  25. {rastr-0.3.0 → rastr-0.4.0}/src/rastr/__init__.py +0 -0
  26. {rastr-0.3.0 → rastr-0.4.0}/src/rastr/arr/__init__.py +0 -0
  27. {rastr-0.3.0 → rastr-0.4.0}/src/rastr/arr/fill.py +0 -0
  28. {rastr-0.3.0 → rastr-0.4.0}/src/rastr/create.py +0 -0
  29. {rastr-0.3.0 → rastr-0.4.0}/src/rastr/gis/__init__.py +0 -0
  30. {rastr-0.3.0 → rastr-0.4.0}/src/rastr/gis/fishnet.py +0 -0
  31. {rastr-0.3.0 → rastr-0.4.0}/src/rastr/gis/smooth.py +0 -0
  32. {rastr-0.3.0 → rastr-0.4.0}/src/rastr/io.py +0 -0
  33. {rastr-0.3.0 → rastr-0.4.0}/src/rastr/meta.py +0 -0
  34. {rastr-0.3.0 → rastr-0.4.0}/src/scripts/.gitkeep +0 -0
  35. {rastr-0.3.0 → rastr-0.4.0}/src/scripts/demo_point_cloud.py +0 -0
  36. {rastr-0.3.0 → rastr-0.4.0}/tasks/ABOUT_TASKS.md +0 -0
  37. {rastr-0.3.0 → rastr-0.4.0}/tasks/dev_sync.ps1 +0 -0
  38. {rastr-0.3.0 → rastr-0.4.0}/tasks/scripts/activate_venv.sh +0 -0
  39. {rastr-0.3.0 → rastr-0.4.0}/tasks/scripts/configure_project.sh +0 -0
  40. {rastr-0.3.0 → rastr-0.4.0}/tasks/scripts/dev_sync.sh +0 -0
  41. {rastr-0.3.0 → rastr-0.4.0}/tasks/scripts/install_backend.sh +0 -0
  42. {rastr-0.3.0 → rastr-0.4.0}/tasks/scripts/install_venv.sh +0 -0
  43. {rastr-0.3.0 → rastr-0.4.0}/tasks/scripts/recover_corrupt_venv.sh +0 -0
  44. {rastr-0.3.0 → rastr-0.4.0}/tasks/scripts/sh_runner.ps1 +0 -0
  45. {rastr-0.3.0 → rastr-0.4.0}/tasks/scripts/sync_requirements.sh +0 -0
  46. {rastr-0.3.0 → rastr-0.4.0}/tasks/scripts/sync_template.sh +0 -0
  47. {rastr-0.3.0 → rastr-0.4.0}/tasks/shims/activate_venv +0 -0
  48. {rastr-0.3.0 → rastr-0.4.0}/tasks/shims/activate_venv.cmd +0 -0
  49. {rastr-0.3.0 → rastr-0.4.0}/tasks/shims/activate_venv.ps1 +0 -0
  50. {rastr-0.3.0 → rastr-0.4.0}/tasks/shims/configure_project +0 -0
  51. {rastr-0.3.0 → rastr-0.4.0}/tasks/shims/configure_project.cmd +0 -0
  52. {rastr-0.3.0 → rastr-0.4.0}/tasks/shims/configure_project.ps1 +0 -0
  53. {rastr-0.3.0 → rastr-0.4.0}/tasks/shims/dev_sync +0 -0
  54. {rastr-0.3.0 → rastr-0.4.0}/tasks/shims/dev_sync.cmd +0 -0
  55. {rastr-0.3.0 → rastr-0.4.0}/tasks/shims/dev_sync.ps1 +0 -0
  56. {rastr-0.3.0 → rastr-0.4.0}/tasks/shims/install_backend +0 -0
  57. {rastr-0.3.0 → rastr-0.4.0}/tasks/shims/install_backend.cmd +0 -0
  58. {rastr-0.3.0 → rastr-0.4.0}/tasks/shims/install_backend.ps1 +0 -0
  59. {rastr-0.3.0 → rastr-0.4.0}/tasks/shims/install_venv +0 -0
  60. {rastr-0.3.0 → rastr-0.4.0}/tasks/shims/install_venv.cmd +0 -0
  61. {rastr-0.3.0 → rastr-0.4.0}/tasks/shims/install_venv.ps1 +0 -0
  62. {rastr-0.3.0 → rastr-0.4.0}/tasks/shims/recover_corrupt_venv +0 -0
  63. {rastr-0.3.0 → rastr-0.4.0}/tasks/shims/recover_corrupt_venv.cmd +0 -0
  64. {rastr-0.3.0 → rastr-0.4.0}/tasks/shims/recover_corrupt_venv.ps1 +0 -0
  65. {rastr-0.3.0 → rastr-0.4.0}/tasks/shims/sync_requirements +0 -0
  66. {rastr-0.3.0 → rastr-0.4.0}/tasks/shims/sync_requirements.cmd +0 -0
  67. {rastr-0.3.0 → rastr-0.4.0}/tasks/shims/sync_requirements.ps1 +0 -0
  68. {rastr-0.3.0 → rastr-0.4.0}/tasks/shims/sync_template +0 -0
  69. {rastr-0.3.0 → rastr-0.4.0}/tasks/shims/sync_template.cmd +0 -0
  70. {rastr-0.3.0 → rastr-0.4.0}/tasks/shims/sync_template.ps1 +0 -0
  71. {rastr-0.3.0 → rastr-0.4.0}/tasks/sync_template.ps1 +0 -0
  72. {rastr-0.3.0 → rastr-0.4.0}/tests/assets/.gitkeep +0 -0
  73. {rastr-0.3.0 → rastr-0.4.0}/tests/assets/pga_g_clipped.grd +0 -0
  74. {rastr-0.3.0 → rastr-0.4.0}/tests/assets/pga_g_clipped.tif +0 -0
  75. {rastr-0.3.0 → rastr-0.4.0}/tests/conftest.py +0 -0
  76. {rastr-0.3.0 → rastr-0.4.0}/tests/rastr/.gitkeep +0 -0
  77. {rastr-0.3.0 → rastr-0.4.0}/tests/rastr/gis/test_fishnet.py +0 -0
  78. {rastr-0.3.0 → rastr-0.4.0}/tests/rastr/gis/test_smooth.py +0 -0
  79. {rastr-0.3.0 → rastr-0.4.0}/tests/rastr/regression_test_data/test_plot_raster.png +0 -0
  80. {rastr-0.3.0 → rastr-0.4.0}/tests/rastr/regression_test_data/test_write_raster_to_file.tif +0 -0
  81. {rastr-0.3.0 → rastr-0.4.0}/tests/rastr/test_create.py +0 -0
  82. {rastr-0.3.0 → rastr-0.4.0}/tests/rastr/test_io.py +0 -0
  83. {rastr-0.3.0 → rastr-0.4.0}/tests/rastr/test_meta.py +0 -0
  84. {rastr-0.3.0 → rastr-0.4.0}/uv.lock +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rastr
3
- Version: 0.3.0
3
+ Version: 0.4.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/46f802e7abd275eff61c73c5edc147d92966c886.zip
8
+ Project-URL: Source Archive, https://github.com/tonkintaylor/rastr/archive/336916af169603534dd0728c10401667f263d98a.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
@@ -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.3.0'
32
- __version_tuple__ = version_tuple = (0, 3, 0)
31
+ __version__ = version = '0.4.0'
32
+ __version_tuple__ = version_tuple = (0, 4, 0)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -6,7 +6,7 @@ import importlib.util
6
6
  import warnings
7
7
  from contextlib import contextmanager
8
8
  from pathlib import Path
9
- from typing import TYPE_CHECKING, Any, Literal
9
+ from typing import TYPE_CHECKING, Any, Literal, overload
10
10
 
11
11
  import numpy as np
12
12
  import numpy.ma
@@ -65,6 +65,21 @@ class RasterModel(BaseModel):
65
65
  def meta(self, value: RasterMeta) -> None:
66
66
  self.raster_meta = value
67
67
 
68
+ @property
69
+ def shape(self) -> tuple[int, ...]:
70
+ """Shape of the raster array."""
71
+ return self.arr.shape
72
+
73
+ @property
74
+ def crs(self) -> CRS:
75
+ """Convenience property to access the CRS via meta."""
76
+ return self.meta.crs
77
+
78
+ @crs.setter
79
+ def crs(self, value: CRS) -> None:
80
+ """Set the CRS via meta."""
81
+ self.meta.crs = value
82
+
68
83
  def __init__(
69
84
  self,
70
85
  *,
@@ -193,12 +208,12 @@ class RasterModel(BaseModel):
193
208
  @property
194
209
  def cell_x_coords(self) -> NDArray[np.float64]:
195
210
  """Get the x coordinates of the cell centres in the raster."""
196
- return self.raster_meta.get_cell_x_coords(self.arr.shape[0])
211
+ return self.raster_meta.get_cell_x_coords(self.arr.shape[1])
197
212
 
198
213
  @property
199
214
  def cell_y_coords(self) -> NDArray[np.float64]:
200
215
  """Get the y coordinates of the cell centres in the raster."""
201
- return self.raster_meta.get_cell_y_coords(self.arr.shape[1])
216
+ return self.raster_meta.get_cell_y_coords(self.arr.shape[0])
202
217
 
203
218
  @contextmanager
204
219
  def to_rasterio_dataset(
@@ -573,6 +588,43 @@ class RasterModel(BaseModel):
573
588
  raster_meta = RasterMeta.example()
574
589
  return cls(arr=arr, raster_meta=raster_meta)
575
590
 
591
+ @overload
592
+ def apply(
593
+ self,
594
+ func: Callable[[np.ndarray], np.ndarray],
595
+ *,
596
+ raw: Literal[True],
597
+ ) -> Self: ...
598
+ @overload
599
+ def apply(
600
+ self,
601
+ func: Callable[[float], float] | Callable[[np.ndarray], np.ndarray],
602
+ *,
603
+ raw: Literal[False] = False,
604
+ ) -> Self: ...
605
+ def apply(self, func, *, raw=False) -> Self:
606
+ """Apply a function element-wise to the raster array.
607
+
608
+ Creates a new raster instance with the same metadata (CRS, transform, etc.)
609
+ but with the data array transformed by the provided function. The original
610
+ raster is not modified.
611
+
612
+ Args:
613
+ func: The function to apply to the raster array. If `raw` is True, this
614
+ function should accept and return a NumPy array. If `raw` is False,
615
+ this function should accept and return a single float value.
616
+ raw: If True, the function is applied directly to the entire array at
617
+ once. If False, the function is applied element-wise to each cell
618
+ in the array using `np.vectorize()`. Default is False.
619
+ """
620
+ new_raster = self.model_copy()
621
+ if raw:
622
+ new_arr = func(self.arr)
623
+ else:
624
+ new_arr = np.vectorize(func)(self.arr)
625
+ new_raster.arr = np.asarray(new_arr)
626
+ return new_raster
627
+
576
628
  def fillna(self, value: float) -> Self:
577
629
  """Fill NaN values in the raster with a specified value.
578
630
 
@@ -599,12 +651,14 @@ class RasterModel(BaseModel):
599
651
  return coords[:, :, 0], coords[:, :, 1]
600
652
 
601
653
  def contour(
602
- self, *, levels: list[float] | NDArray, smoothing: bool = True
654
+ self, levels: list[float] | NDArray, *, smoothing: bool = True
603
655
  ) -> gpd.GeoDataFrame:
604
656
  """Create contour lines from the raster data, optionally with smoothing.
605
657
 
606
- The contour lines are returned as a GeoDataFrame with the contours as linestring
607
- geometries and the contour levels as attributes in a column named 'level'.
658
+ The contour lines are returned as a GeoDataFrame with the contours dissolved
659
+ by level, resulting in one row per contour level. Each row contains a
660
+ (Multi)LineString geometry representing all contour lines for that level,
661
+ and the contour level value in a column named 'level'.
608
662
 
609
663
  Consider calling `blur()` before this method to smooth the raster data before
610
664
  contouring, to denoise the contours.
@@ -627,7 +681,7 @@ class RasterModel(BaseModel):
627
681
  level=level,
628
682
  )
629
683
 
630
- # Constructg shapely LineString objects
684
+ # Construct shapely LineString objects
631
685
  # Convert to CRS from array index coordinates to raster CRS
632
686
  geoms = [
633
687
  LineString(
@@ -656,7 +710,8 @@ class RasterModel(BaseModel):
656
710
  crs=self.raster_meta.crs,
657
711
  )
658
712
 
659
- return contour_gdf
713
+ # Dissolve contours by level to merge all contour lines of the same level
714
+ return contour_gdf.dissolve(by="level", as_index=False)
660
715
 
661
716
  def blur(self, sigma: float) -> Self:
662
717
  """Apply a Gaussian blur to the raster data.
@@ -701,7 +756,7 @@ class RasterModel(BaseModel):
701
756
  bounds: tuple[float, float, float, float],
702
757
  strategy: Literal["underflow", "overflow"] = "underflow",
703
758
  ) -> Self:
704
- """Crop the raster to the specified bounds.
759
+ """Crop the raster to the specified bounds as (minx, miny, maxx, maxy).
705
760
 
706
761
  Args:
707
762
  bounds: A tuple of (minx, miny, maxx, maxy) defining the bounds to crop to.
@@ -746,7 +801,7 @@ class RasterModel(BaseModel):
746
801
  raise NotImplementedError(msg)
747
802
 
748
803
  # Crop the array
749
- cropped_arr = arr[np.ix_(x_idx, y_idx)]
804
+ cropped_arr = arr[np.ix_(y_idx, x_idx)]
750
805
 
751
806
  # Check the shape of the cropped array
752
807
  if cropped_arr.size == 0:
@@ -12,7 +12,7 @@ from affine import Affine
12
12
  from branca.colormap import LinearColormap
13
13
  from pydantic import ValidationError
14
14
  from pyproj.crs.crs import CRS
15
- from shapely.geometry import Point, Polygon
15
+ from shapely.geometry import LineString, MultiLineString, Point, Polygon
16
16
 
17
17
  from rastr.meta import RasterMeta
18
18
  from rastr.raster import RasterModel
@@ -116,6 +116,43 @@ class TestRasterModel:
116
116
  assert example_raster.meta is new_meta
117
117
  assert example_raster.raster_meta != original_meta
118
118
 
119
+ class TestShape:
120
+ def test_shape_property(self, example_raster: RasterModel):
121
+ # Act
122
+ shape = example_raster.shape
123
+
124
+ # Assert
125
+ assert shape == (2, 2)
126
+ assert shape == example_raster.arr.shape
127
+
128
+ class TestCRS:
129
+ def test_crs_getter(self, example_raster: RasterModel):
130
+ # Act
131
+ crs_via_property = example_raster.crs
132
+ crs_via_meta = example_raster.meta.crs
133
+ crs_via_raster_meta = example_raster.raster_meta.crs
134
+
135
+ # Assert
136
+ assert crs_via_property is crs_via_meta
137
+ assert crs_via_property is crs_via_raster_meta
138
+ assert crs_via_property == crs_via_meta
139
+ assert crs_via_property == crs_via_raster_meta
140
+ assert isinstance(crs_via_property, CRS)
141
+
142
+ def test_crs_setter(self, example_raster: RasterModel):
143
+ # Arrange
144
+ new_crs = CRS.from_epsg(4326)
145
+ original_crs = example_raster.crs
146
+
147
+ # Act
148
+ example_raster.crs = new_crs
149
+
150
+ # Assert
151
+ assert example_raster.crs is new_crs
152
+ assert example_raster.meta.crs is new_crs
153
+ assert example_raster.raster_meta.crs is new_crs
154
+ assert example_raster.crs != original_crs
155
+
119
156
  class TestSample:
120
157
  def test_sample_nan_raise(self, example_raster: RasterModel):
121
158
  with pytest.raises(
@@ -600,6 +637,14 @@ class TestRasterModel:
600
637
  # Assert
601
638
  np.testing.assert_array_equal(result.arr, np.array([[0, -1], [-2, -3]]))
602
639
 
640
+ class TestApply:
641
+ def test_sine(self, example_raster: RasterModel):
642
+ # Act
643
+ result = example_raster.apply(np.sin)
644
+
645
+ # Assert
646
+ np.testing.assert_array_equal(result.arr, np.sin(example_raster.arr))
647
+
603
648
  class TestToFile:
604
649
  def test_saving_gtiff(self, tmp_path: Path, example_raster: RasterModel):
605
650
  # Arrange
@@ -818,6 +863,61 @@ class TestRasterModel:
818
863
  assert len(contour_gdf_list) == len(contour_gdf_array)
819
864
  assert list(contour_gdf_list["level"]) == list(contour_gdf_array["level"])
820
865
 
866
+ def test_contour_positional_levels(self):
867
+ # Arrange
868
+ raster = RasterModel.example()
869
+ levels = [0.0, 0.5]
870
+
871
+ # Act - should pass without error when using positional levels arg
872
+ contour_gdf = raster.contour(levels) # noqa: F841
873
+
874
+ def test_contour_returns_gdf_with_correct_columns(self):
875
+ raster = RasterModel.example()
876
+ gdf = raster.contour(levels=[0.0, 0.5])
877
+
878
+ assert isinstance(gdf, gpd.GeoDataFrame)
879
+ assert list(gdf.columns) == ["level", "geometry"]
880
+ assert "level" in gdf.columns
881
+ assert "geometry" in gdf.columns
882
+
883
+ def test_contour_levels_in_result(self):
884
+ raster = RasterModel.example()
885
+ levels = [0.0, 0.5]
886
+ gdf = raster.contour(levels=levels)
887
+
888
+ result_levels = set(gdf["level"].unique())
889
+ expected_levels = set(levels)
890
+ assert result_levels == expected_levels
891
+
892
+ def test_contour_dissolve_behavior_one_row_per_level(self):
893
+ raster = RasterModel.example()
894
+ levels = [0.0, 0.5]
895
+ gdf = raster.contour(levels=levels)
896
+
897
+ # After dissolving, should have exactly one row per level
898
+ assert len(gdf) == len(levels)
899
+ assert set(gdf["level"]) == set(levels)
900
+
901
+ # Geometries should be MultiLineString (dissolved from multiple LineStrings)
902
+ for geom in gdf.geometry:
903
+ assert isinstance(
904
+ geom, (MultiLineString, LineString)
905
+ ) # Can be either depending on dissolve result
906
+
907
+ def test_contour_with_smoothing(self):
908
+ raster = RasterModel.example()
909
+ gdf = raster.contour(levels=[0.0], smoothing=True)
910
+
911
+ assert len(gdf) > 0
912
+ assert all(gdf["level"] == 0.0)
913
+
914
+ def test_contour_without_smoothing(self):
915
+ raster = RasterModel.example()
916
+ gdf = raster.contour(levels=[0.0], smoothing=False)
917
+
918
+ assert len(gdf) > 0
919
+ assert all(gdf["level"] == 0.0)
920
+
821
921
 
822
922
  @pytest.fixture
823
923
  def base_raster():
@@ -868,13 +968,13 @@ class TestCrop:
868
968
  minx, miny, maxx, maxy = base_raster.bounds
869
969
  cell_size = base_raster.raster_meta.cell_size
870
970
  bounds = (minx, miny + cell_size, maxx, maxy - cell_size)
871
- expected_transform = Affine(20.0, 0.0, 0.0, 0.0, -5.0, 100.0 - cell_size)
971
+ expected_transform = Affine(10.0, 0.0, 0.0, 0.0, -10.0, 100.0 - cell_size)
872
972
 
873
973
  # Act
874
974
  cropped = base_raster.crop(bounds)
875
975
 
876
976
  # Assert
877
- assert cropped.arr.shape == (4, 2)
977
+ assert cropped.arr.shape == (2, 4) # Y-crop reduces rows, keeps columns
878
978
  assert cropped.bounds == bounds
879
979
  assert cropped.raster_meta.cell_size == base_raster.raster_meta.cell_size
880
980
  assert cropped.raster_meta.crs == base_raster.raster_meta.crs
@@ -885,13 +985,13 @@ class TestCrop:
885
985
  minx, miny, maxx, maxy = base_raster.bounds
886
986
  cell_size = base_raster.raster_meta.cell_size
887
987
  bounds = (minx + cell_size, miny, maxx - cell_size, maxy)
888
- expected_transform = Affine(5.0, 0.0, minx + cell_size, 0.0, -20.0, 100.0)
988
+ expected_transform = Affine(10.0, 0.0, minx + cell_size, 0.0, -10.0, 100.0)
889
989
 
890
990
  # Act
891
991
  cropped = base_raster.crop(bounds)
892
992
 
893
993
  # Assert
894
- assert cropped.arr.shape == (2, 4)
994
+ assert cropped.arr.shape == (4, 2) # X-crop reduces columns, keeps rows
895
995
  assert cropped.bounds == bounds
896
996
  assert cropped.raster_meta.cell_size == base_raster.raster_meta.cell_size
897
997
  assert cropped.raster_meta.crs == base_raster.raster_meta.crs
@@ -980,6 +1080,46 @@ class TestCrop:
980
1080
  ):
981
1081
  base_raster.crop(bounds)
982
1082
 
1083
+ def test_crop_non_square_raster_indexing(self):
1084
+ """Test that crop method correctly indexes non-square rasters.
1085
+
1086
+ This tests the fix for issue #140 where array indexing was backwards,
1087
+ causing spatial misalignment in cropped rasters.
1088
+ """
1089
+ # Arrange: Create a non-square raster with distinctive values
1090
+ meta = RasterMeta(
1091
+ cell_size=1.0,
1092
+ crs=CRS.from_epsg(2193),
1093
+ transform=Affine(1.0, 0.0, 0.0, 0.0, -1.0, 3.0),
1094
+ )
1095
+ arr = np.array(
1096
+ [
1097
+ [1, 2, 3, 4, 5], # row 0
1098
+ [6, 7, 8, 9, 10], # row 1
1099
+ [11, 12, 13, 14, 15], # row 2
1100
+ ],
1101
+ dtype=float,
1102
+ )
1103
+ raster = RasterModel(arr=arr, raster_meta=meta)
1104
+
1105
+ # Act: Crop to select middle 3 columns (keeping all rows)
1106
+ bounds = (1.0, 0.0, 4.0, 3.0) # Should select columns at x=1.5, 2.5, 3.5
1107
+ cropped = raster.crop(bounds)
1108
+
1109
+ # Assert: Result should have all 3 rows but only 3 columns
1110
+ expected_shape = (3, 3)
1111
+ expected_array = np.array(
1112
+ [
1113
+ [2, 3, 4], # row 0, columns 1,2,3 (0-indexed)
1114
+ [7, 8, 9], # row 1, columns 1,2,3
1115
+ [12, 13, 14], # row 2, columns 1,2,3
1116
+ ],
1117
+ dtype=float,
1118
+ )
1119
+
1120
+ assert cropped.arr.shape == expected_shape
1121
+ np.testing.assert_array_equal(cropped.arr, expected_array)
1122
+
983
1123
  def test_unsupported_crop_strategy(self, base_raster: RasterModel):
984
1124
  # Arrange
985
1125
  bounds = base_raster.bounds
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes