ssb-sgis 1.0.1__py3-none-any.whl → 1.0.2__py3-none-any.whl

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.
Files changed (59) hide show
  1. sgis/__init__.py +97 -115
  2. sgis/exceptions.py +3 -1
  3. sgis/geopandas_tools/__init__.py +1 -0
  4. sgis/geopandas_tools/bounds.py +75 -38
  5. sgis/geopandas_tools/buffer_dissolve_explode.py +38 -34
  6. sgis/geopandas_tools/centerlines.py +53 -44
  7. sgis/geopandas_tools/cleaning.py +87 -104
  8. sgis/geopandas_tools/conversion.py +149 -101
  9. sgis/geopandas_tools/duplicates.py +31 -17
  10. sgis/geopandas_tools/general.py +76 -48
  11. sgis/geopandas_tools/geometry_types.py +21 -7
  12. sgis/geopandas_tools/neighbors.py +20 -8
  13. sgis/geopandas_tools/overlay.py +136 -53
  14. sgis/geopandas_tools/point_operations.py +9 -8
  15. sgis/geopandas_tools/polygon_operations.py +48 -56
  16. sgis/geopandas_tools/polygons_as_rings.py +121 -78
  17. sgis/geopandas_tools/sfilter.py +14 -14
  18. sgis/helpers.py +114 -56
  19. sgis/io/dapla_functions.py +32 -23
  20. sgis/io/opener.py +13 -6
  21. sgis/io/read_parquet.py +1 -1
  22. sgis/maps/examine.py +39 -26
  23. sgis/maps/explore.py +112 -66
  24. sgis/maps/httpserver.py +12 -12
  25. sgis/maps/legend.py +124 -65
  26. sgis/maps/map.py +66 -41
  27. sgis/maps/maps.py +31 -29
  28. sgis/maps/thematicmap.py +46 -33
  29. sgis/maps/tilesources.py +3 -8
  30. sgis/networkanalysis/_get_route.py +5 -4
  31. sgis/networkanalysis/_od_cost_matrix.py +44 -1
  32. sgis/networkanalysis/_points.py +10 -4
  33. sgis/networkanalysis/_service_area.py +5 -2
  34. sgis/networkanalysis/closing_network_holes.py +20 -62
  35. sgis/networkanalysis/cutting_lines.py +55 -43
  36. sgis/networkanalysis/directednetwork.py +15 -7
  37. sgis/networkanalysis/finding_isolated_networks.py +4 -3
  38. sgis/networkanalysis/network.py +15 -13
  39. sgis/networkanalysis/networkanalysis.py +72 -54
  40. sgis/networkanalysis/networkanalysisrules.py +20 -16
  41. sgis/networkanalysis/nodes.py +2 -3
  42. sgis/networkanalysis/traveling_salesman.py +5 -2
  43. sgis/parallel/parallel.py +337 -127
  44. sgis/raster/__init__.py +6 -0
  45. sgis/raster/base.py +9 -3
  46. sgis/raster/cube.py +280 -208
  47. sgis/raster/cubebase.py +15 -29
  48. sgis/raster/indices.py +3 -7
  49. sgis/raster/methods_as_functions.py +0 -124
  50. sgis/raster/raster.py +313 -127
  51. sgis/raster/torchgeo.py +58 -37
  52. sgis/raster/zonal.py +38 -13
  53. {ssb_sgis-1.0.1.dist-info → ssb_sgis-1.0.2.dist-info}/LICENSE +1 -1
  54. {ssb_sgis-1.0.1.dist-info → ssb_sgis-1.0.2.dist-info}/METADATA +87 -16
  55. ssb_sgis-1.0.2.dist-info/RECORD +61 -0
  56. {ssb_sgis-1.0.1.dist-info → ssb_sgis-1.0.2.dist-info}/WHEEL +1 -1
  57. sgis/raster/bands.py +0 -48
  58. sgis/raster/gradient.py +0 -78
  59. ssb_sgis-1.0.1.dist-info/RECORD +0 -63
sgis/raster/raster.py CHANGED
@@ -1,11 +1,17 @@
1
1
  import functools
2
2
  import numbers
3
+ import os
3
4
  import re
4
5
  import warnings
5
- from collections.abc import Callable, Iterable
6
- from copy import copy, deepcopy
6
+ from collections.abc import Callable
7
+ from collections.abc import Iterable
8
+ from collections.abc import Iterator
9
+ from copy import copy
10
+ from copy import deepcopy
7
11
  from json import loads
8
12
  from pathlib import Path
13
+ from typing import Any
14
+ from typing import ClassVar
9
15
 
10
16
  import geopandas as gpd
11
17
  import matplotlib.pyplot as plt
@@ -13,17 +19,25 @@ import numpy as np
13
19
  import pandas as pd
14
20
  import pyproj
15
21
  import rasterio
22
+ import rasterio.windows
16
23
  import shapely
17
24
  from typing_extensions import Self # TODO: imperter fra typing når python 3.11
18
25
 
19
-
20
26
  try:
21
27
  import xarray as xr
22
28
  from xarray import DataArray
23
29
  except ImportError:
24
30
 
25
31
  class DataArray:
26
- pass
32
+ """Placeholder."""
33
+
34
+
35
+ try:
36
+ from dapla.gcs import GCSFileSystem
37
+ except ImportError:
38
+
39
+ class GCSFileSystem:
40
+ """Placeholder."""
27
41
 
28
42
 
29
43
  try:
@@ -31,7 +45,8 @@ try:
31
45
  except ImportError:
32
46
  pass
33
47
  from affine import Affine
34
- from geopandas import GeoDataFrame, GeoSeries
48
+ from geopandas import GeoDataFrame
49
+ from geopandas import GeoSeries
35
50
  from pandas.api.types import is_list_like
36
51
  from rasterio import features
37
52
  from rasterio.enums import MergeAlg
@@ -39,22 +54,26 @@ from rasterio.io import DatasetReader
39
54
  from rasterio.vrt import WarpedVRT
40
55
  from rasterio.warp import reproject
41
56
  from shapely import Geometry
42
- from shapely.geometry import Point, Polygon, shape
43
-
44
- from ..geopandas_tools.conversion import to_bbox, to_gdf, to_shapely
45
- from ..geopandas_tools.general import is_bbox_like, is_wkt
57
+ from shapely.geometry import Point
58
+ from shapely.geometry import Polygon
59
+ from shapely.geometry import shape
60
+
61
+ from ..geopandas_tools.conversion import to_bbox
62
+ from ..geopandas_tools.conversion import to_gdf
63
+ from ..geopandas_tools.conversion import to_shapely
64
+ from ..geopandas_tools.general import is_bbox_like
65
+ from ..geopandas_tools.general import is_wkt
46
66
  from ..helpers import is_property
47
67
  from ..io.opener import opener
48
- from .base import ALLOWED_KEYS, NESSECARY_META, get_index_mapper, memfile_from_array
49
- from .gradient import get_gradient
50
- from .zonal import (
51
- _aggregate,
52
- _no_overlap_df,
53
- make_geometry_iterrows,
54
- prepare_zonal,
55
- zonal_post,
56
- )
57
-
68
+ from .base import ALLOWED_KEYS
69
+ from .base import NESSECARY_META
70
+ from .base import get_index_mapper
71
+ from .base import memfile_from_array
72
+ from .zonal import _aggregate
73
+ from .zonal import _make_geometry_iterrows
74
+ from .zonal import _no_overlap_df
75
+ from .zonal import _prepare_zonal
76
+ from .zonal import _zonal_post
58
77
 
59
78
  numpy_func_message = (
60
79
  "aggfunc must be functions or strings of numpy functions or methods."
@@ -68,11 +87,11 @@ class Raster:
68
87
  'from_gdf'.
69
88
 
70
89
 
71
- Examples
90
+ Examples:
72
91
  --------
73
-
74
92
  Read tif file.
75
93
 
94
+ >>> import sgis as sg
76
95
  >>> path = 'https://media.githubusercontent.com/media/statisticsnorway/ssb-sgis/main/tests/testdata/raster/dtm_10.tif'
77
96
  >>> raster = sg.Raster.from_path(path)
78
97
  >>> raster
@@ -147,14 +166,8 @@ class Raster:
147
166
 
148
167
  """
149
168
 
150
- # attributes conserning file path
151
- filename_regex: str | None = None
152
- date_format: str | None = None
153
- contains: str | None = None
154
- endswith: str = ".tif"
155
-
156
- # attributes conserning rasterio metadata
157
- _profile = {
169
+ # attributes concerning rasterio metadata
170
+ _profile: ClassVar[dict[str, str | None]] = {
158
171
  "driver": "GTiff",
159
172
  "compress": "LZW",
160
173
  "nodata": None,
@@ -164,43 +177,53 @@ class Raster:
164
177
  "indexes": None,
165
178
  }
166
179
 
167
- # driver: str = "GTiff"
168
- # compress: str = "LZW"
169
- # _nodata: int | float | None = None
170
- # _dtype: type | None = None
171
-
172
180
  def __init__(
173
181
  self,
174
- raster=None,
182
+ data: Self | str | np.ndarray | None = None,
175
183
  *,
176
- path: str | None = None,
177
- # indexes: int | list[int] | None = None,
178
- array: np.ndarray | None = None,
179
- file_system=None,
184
+ file_system: GCSFileSystem | None = None,
185
+ filename_regex: str | None = None,
180
186
  **kwargs,
181
- ):
182
- if raster is not None:
183
- if not isinstance(raster, Raster):
184
- raise TypeError(
185
- "Raster should be constructed with the classmethods (from_...)."
186
- )
187
- for key, value in raster.__dict__.items():
188
- setattr(raster, key, value)
187
+ ) -> None:
188
+ """Note: use the classmethods from_path, from_array, from_gdf etc. instead of the initialiser.
189
+
190
+ Args:
191
+ data: A file path, an array or a Raster object.
192
+ file_system: Optional GCSFileSystem.
193
+ filename_regex: Regular expression to match file name attributes (date, band, tile, resolution).
194
+ **kwargs: Arguments concerning file metadata or
195
+ spatial properties of the image.
196
+ """
197
+ self.filename_regex = filename_regex
198
+
199
+ if isinstance(data, Raster):
200
+ for key, value in data.__dict__.items():
201
+ setattr(data, key, value)
189
202
  return
190
203
 
191
- if path is None and not any([kwargs.get("transform"), kwargs.get("bounds")]):
204
+ if isinstance(data, (str | Path | os.PathLike)):
205
+ self.path = data
206
+
207
+ else:
208
+ self.path = None
209
+
210
+ if isinstance(data, (np.ndarray)):
211
+ self.array = data
212
+ else:
213
+ self.array = None
214
+
215
+ if self.path is None and not any(
216
+ [kwargs.get("transform"), kwargs.get("bounds")]
217
+ ):
192
218
  raise TypeError(
193
219
  "Must specify either bounds or transform when constructing raster from array."
194
220
  )
195
221
 
196
- # add class profile first to override with args and kwargs
222
+ # add class profile first, then override with args and kwargs
197
223
  self.update(**self._profile)
198
224
 
199
225
  self._crs = kwargs.pop("crs", self._crs if hasattr(self, "_crs") else None)
200
226
  self._bounds = None
201
-
202
- self.path = path
203
- self.array = array
204
227
  self.file_system = file_system
205
228
  self._indexes = self._get_indexes(kwargs.pop("indexes", self.indexes))
206
229
 
@@ -220,21 +243,28 @@ class Raster:
220
243
  cls,
221
244
  path: str,
222
245
  res: int | None = None,
223
- file_system=None,
246
+ file_system: GCSFileSystem | None = None,
247
+ filename_regex: str | None = None,
224
248
  **kwargs,
225
- ):
249
+ ) -> Self:
226
250
  """Construct Raster from file path.
227
251
 
228
252
  Args:
229
253
  path: Path to a raster image file.
254
+ res: Spatial resolution when reading the image.
255
+ file_system: Optional file system.
256
+ filename_regex: Regular expression with optional match groups.
257
+ **kwargs: Arguments concerning file metadata or
258
+ spatial properties of the image.
230
259
 
231
260
  Returns:
232
261
  A Raster instance.
233
262
  """
234
263
  return cls(
235
- path=str(path),
264
+ str(path),
236
265
  file_system=file_system,
237
266
  res=res,
267
+ filename_regex=filename_regex,
238
268
  **kwargs,
239
269
  )
240
270
 
@@ -242,19 +272,18 @@ class Raster:
242
272
  def from_array(
243
273
  cls,
244
274
  array: np.ndarray,
245
- crs,
275
+ crs: Any,
246
276
  *,
247
277
  transform: Affine | None = None,
248
278
  bounds: tuple | Geometry | None = None,
249
279
  copy: bool = True,
250
280
  **kwargs,
251
- ):
281
+ ) -> Self:
252
282
  """Construct Raster from numpy array.
253
283
 
254
- Metadata must be specified, either individually or in a dictionary
255
- (hint: use the 'meta' attribute of an existing Raster object if applicable).
256
- The necessary metadata is 'crs' and either 'transform' (Affine object) or 'bounds',
257
- which transform will then be created from.
284
+ Must also specify nessecary spatial properties
285
+ The necessary metadata is 'crs' and either 'transform' (Affine object)
286
+ or 'bounds', which transform will then be created from.
258
287
 
259
288
  Args:
260
289
  array: 2d or 3d numpy ndarray.
@@ -263,7 +292,9 @@ class Raster:
263
292
  of bounds.
264
293
  bounds: Minimum and maximum x and y coordinates. Can be specified instead
265
294
  of transform.
266
- name: Optional name to give the raster.
295
+ copy: Whether to copy the array.
296
+ **kwargs: Arguments concerning file metadata or
297
+ spatial properties of the image.
267
298
 
268
299
  Returns:
269
300
  A Raster instance.
@@ -295,7 +326,7 @@ class Raster:
295
326
 
296
327
  crs = pyproj.CRS(crs) if crs else None
297
328
 
298
- return cls(array=array, crs=crs, transform=transform, bounds=bounds, **kwargs)
329
+ return cls(array, crs=crs, transform=transform, bounds=bounds, **kwargs)
299
330
 
300
331
  @classmethod
301
332
  def from_gdf(
@@ -303,21 +334,38 @@ class Raster:
303
334
  gdf: GeoDataFrame,
304
335
  columns: str | Iterable[str],
305
336
  res: int,
306
- fill=0,
307
- all_touched=False,
308
- merge_alg=MergeAlg.replace,
309
- default_value=1,
310
- dtype=None,
337
+ fill: int = 0,
338
+ all_touched: bool = False,
339
+ merge_alg: Callable = MergeAlg.replace,
340
+ default_value: int = 1,
341
+ dtype: Any | None = None,
311
342
  **kwargs,
312
- ):
343
+ ) -> Self:
313
344
  """Construct Raster from a GeoDataFrame.
314
345
 
315
346
  Args:
316
- gdf: The GeoDataFrame.
317
- column: The column to be used as values for the array.
318
- res: Resolution of the raster in units of gdf's coordinate
319
- reference system.
347
+ gdf: The GeoDataFrame to rasterize.
348
+ columns: Column(s) in the GeoDataFrame whose values are used to populate the raster.
349
+ This can be a single column name or a list of column names.
350
+ res: Resolution of the raster in units of the GeoDataFrame's coordinate reference system.
351
+ fill: Fill value for areas outside of input geometries (default is 0).
352
+ all_touched: Whether to consider all pixels touched by geometries,
353
+ not just those whose center is within the polygon (default is False).
354
+ merge_alg: Merge algorithm to use when combining geometries
355
+ (default is 'MergeAlg.replace').
356
+ default_value: Default value to use for the rasterized pixels
357
+ (default is 1).
358
+ dtype: Data type of the output array. If None, it will be
359
+ determined automatically.
360
+ **kwargs: Additional keyword arguments passed to the raster
361
+ creation process, e.g., custom CRS or transform settings.
320
362
 
363
+ Returns:
364
+ A Raster instance based on the specified GeoDataFrame and parameters.
365
+
366
+ Raises:
367
+ TypeError: If 'transform' is provided in kwargs, as this is
368
+ computed based on the GeoDataFrame bounds and resolution.
321
369
  """
322
370
  if not isinstance(gdf, GeoDataFrame):
323
371
  gdf = to_gdf(gdf)
@@ -362,10 +410,10 @@ class Raster:
362
410
  assert len(array.shape) == 3
363
411
  name = kwargs.get("name", None)
364
412
 
365
- return cls.from_array(array=array, name=name, **kwargs)
413
+ return cls.from_array(array, name=name, **kwargs)
366
414
 
367
415
  @classmethod
368
- def from_dict(cls, dictionary: dict):
416
+ def from_dict(cls, dictionary: dict) -> Self:
369
417
  """Construct Raster from metadata dict to fastpass the initializer.
370
418
 
371
419
  This is the fastest way to create a Raster since a metadata lookup is not
@@ -387,6 +435,7 @@ class Raster:
387
435
  return cls(**dictionary)
388
436
 
389
437
  def update(self, **kwargs) -> Self:
438
+ """Update attributes of the Raster."""
390
439
  for key, value in kwargs.items():
391
440
  self._validate_key(key)
392
441
  if is_property(self, key):
@@ -394,7 +443,9 @@ class Raster:
394
443
  setattr(self, key, value)
395
444
  return self
396
445
 
397
- def write(self, path: str, window=None, **kwargs) -> None:
446
+ def write(
447
+ self, path: str, window: rasterio.windows.Window | None = None, **kwargs
448
+ ) -> None:
398
449
  """Write the raster as a single file.
399
450
 
400
451
  Multiband arrays will result in a multiband image file.
@@ -402,8 +453,10 @@ class Raster:
402
453
  Args:
403
454
  path: File path to write to.
404
455
  window: Optional window to clip the image to.
456
+ **kwargs: Keyword arguments passed to rasterio.open.
457
+ Thise will override the items in the Raster's profile,
458
+ if overlapping.
405
459
  """
406
-
407
460
  if self.array is None:
408
461
  raise AttributeError("The image hasn't been loaded.")
409
462
 
@@ -436,7 +489,7 @@ class Raster:
436
489
 
437
490
  def clip(
438
491
  self,
439
- mask,
492
+ mask: Any,
440
493
  masked: bool = False,
441
494
  boundless: bool = True,
442
495
  **kwargs,
@@ -448,6 +501,13 @@ class Raster:
448
501
 
449
502
  Args:
450
503
  mask: Geometry-like object or bounding box.
504
+ masked: If 'masked' is True the return value will be a masked
505
+ array. Otherwise (default) the return value will be a
506
+ regular array. Masks will be exactly the inverse of the
507
+ GDAL RFC 15 conforming arrays returned by read_masks().
508
+ boundless: If True, windows that extend beyond the dataset's extent
509
+ are permitted and partially or completely filled arrays will
510
+ be returned as appropriate.
451
511
  **kwargs: Keyword arguments passed to the mask function
452
512
  from the rasterio.mask module.
453
513
 
@@ -462,17 +522,18 @@ class Raster:
462
522
  except ValueError:
463
523
  mask = mask.set_crs(self.crs)
464
524
 
465
- # if not self.crs.equals(pyproj.CRS(mask.crs)):
466
- # raise ValueError("crs mismatch.")
467
-
468
525
  self._read_with_mask(mask=mask, masked=masked, boundless=boundless, **kwargs)
469
526
 
470
527
  return self
471
528
 
472
- def intersects(self, other) -> bool:
529
+ def intersects(self, other: Any) -> bool:
530
+ """Returns True if the image bounds intersect with 'other'."""
473
531
  return self.unary_union.intersects(to_shapely(other))
474
532
 
475
- def sample(self, n=1, size=20, mask=None, copy=True, **kwargs) -> Self:
533
+ def sample(
534
+ self, n: int = 1, size: int = 20, mask: Any = None, copy: bool = True, **kwargs
535
+ ) -> Self:
536
+ """Take a random spatial sample of the image."""
476
537
  if mask is not None:
477
538
  points = GeoSeries(self.unary_union).clip(mask).sample_points(n)
478
539
  else:
@@ -508,8 +569,8 @@ class Raster:
508
569
  A GeoDataFrame with aggregated values per polygon.
509
570
  """
510
571
  idx_mapper, idx_name = get_index_mapper(polygons)
511
- polygons, aggfunc, func_names = prepare_zonal(polygons, aggfunc)
512
- poly_iter = make_geometry_iterrows(polygons)
572
+ polygons, aggfunc, func_names = _prepare_zonal(polygons, aggfunc)
573
+ poly_iter = _make_geometry_iterrows(polygons)
513
574
 
514
575
  aggregated = []
515
576
  for i, poly in poly_iter:
@@ -520,7 +581,7 @@ class Raster:
520
581
  _aggregate(clipped.array, array_func, aggfunc, func_names, self.date, i)
521
582
  )
522
583
 
523
- return zonal_post(
584
+ return _zonal_post(
524
585
  aggregated,
525
586
  polygons=polygons,
526
587
  idx_mapper=idx_mapper,
@@ -546,7 +607,7 @@ class Raster:
546
607
  Returns:
547
608
  The class instance with new array values, or a copy if copy is True.
548
609
 
549
- Examples
610
+ Examples:
550
611
  --------
551
612
  Making an array where the gradient to the center is always 10.
552
613
 
@@ -580,6 +641,7 @@ class Raster:
580
641
  return get_gradient(self, degrees=degrees, copy=copy)
581
642
 
582
643
  def to_xarray(self) -> DataArray:
644
+ """Convert the raster to an xarray.DataArray."""
583
645
  self._check_for_array()
584
646
  self.name = self.name or self.__class__.__name__.lower()
585
647
  coords = _generate_spatial_coords(self.transform, self.width, self.height)
@@ -602,6 +664,7 @@ class Raster:
602
664
  ) # .transpose("y", "x")
603
665
 
604
666
  def to_dict(self) -> dict:
667
+ """Get a dictionary of Raster attributes."""
605
668
  out = {}
606
669
  for col in self.ALL_ATTRS:
607
670
  try:
@@ -642,11 +705,11 @@ class Raster:
642
705
  column = [column] * len(array_list)
643
706
 
644
707
  gdfs = []
645
- for i, (column, array) in enumerate(zip(column, array_list, strict=True)):
708
+ for i, (col, array) in enumerate(zip(column, array_list, strict=True)):
646
709
  gdf = gpd.GeoDataFrame(
647
710
  pd.DataFrame(
648
711
  self._array_to_geojson(array, self.transform),
649
- columns=[column, "geometry"],
712
+ columns=[col, "geometry"],
650
713
  ),
651
714
  geometry="geometry",
652
715
  crs=self.crs,
@@ -658,7 +721,7 @@ class Raster:
658
721
 
659
722
  def set_crs(
660
723
  self,
661
- crs,
724
+ crs: pyproj.CRS | Any,
662
725
  allow_override: bool = False,
663
726
  ) -> Self:
664
727
  """Set coordinate reference system."""
@@ -671,7 +734,7 @@ class Raster:
671
734
  self._crs = pyproj.CRS(crs)
672
735
  return self
673
736
 
674
- def to_crs(self, crs, **kwargs) -> Self:
737
+ def to_crs(self, crs: pyproj.CRS | Any, **kwargs) -> Self:
675
738
  """Reproject the raster.
676
739
 
677
740
  Args:
@@ -739,9 +802,9 @@ class Raster:
739
802
 
740
803
  return self
741
804
 
742
- def plot(self, mask=None) -> None:
743
- self._check_for_array()
805
+ def plot(self, mask: Any | None = None) -> None:
744
806
  """Plot the images. One image per band."""
807
+ self._check_for_array()
745
808
  if mask is not None:
746
809
  raster = self.copy().clip(mask)
747
810
  else:
@@ -760,6 +823,7 @@ class Raster:
760
823
  plt.close()
761
824
 
762
825
  def astype(self, dtype: type) -> Self:
826
+ """Convert the datatype of the array."""
763
827
  if self.array is None:
764
828
  raise ValueError("Array is not loaded.")
765
829
  if not rasterio.dtypes.can_cast_dtype(self.array, dtype):
@@ -770,16 +834,19 @@ class Raster:
770
834
  return self
771
835
 
772
836
  def as_minimum_dtype(self) -> Self:
837
+ """Convert the array to the minimum dtype without overflow."""
773
838
  min_dtype = rasterio.dtypes.get_minimum_dtype(self.array)
774
839
  self.array = self.array.astype(min_dtype)
775
840
  return self
776
841
 
777
842
  def min(self) -> int | None:
843
+ """Minimum value in the array."""
778
844
  if np.size(self.array):
779
845
  return np.min(self.array)
780
846
  return None
781
847
 
782
848
  def max(self) -> int | None:
849
+ """Maximum value in the array."""
783
850
  if np.size(self.array):
784
851
  return np.max(self.array)
785
852
  return None
@@ -798,6 +865,7 @@ class Raster:
798
865
  return self
799
866
 
800
867
  def array_list(self) -> list[np.ndarray]:
868
+ """Get a list of 2D arrays."""
801
869
  self._check_for_array()
802
870
  if len(self.array.shape) == 2:
803
871
  return [self.array]
@@ -808,10 +876,12 @@ class Raster:
808
876
 
809
877
  @property
810
878
  def indexes(self) -> int | tuple[int] | None:
879
+ """Band indexes of the image."""
811
880
  return self._indexes
812
881
 
813
882
  @property
814
883
  def name(self) -> str | None:
884
+ """Name of the file in the file path, if any."""
815
885
  try:
816
886
  return self._name
817
887
  except AttributeError:
@@ -821,12 +891,12 @@ class Raster:
821
891
  return None
822
892
 
823
893
  @name.setter
824
- def name(self, value):
894
+ def name(self, value) -> None:
825
895
  self._name = value
826
- return self._name
827
896
 
828
897
  @property
829
- def date(self):
898
+ def date(self) -> str | None:
899
+ """Date in the image file name, if filename_regex is present."""
830
900
  try:
831
901
  pattern = re.compile(self.filename_regex, re.VERBOSE)
832
902
  return re.match(pattern, Path(self.path).name).group("date")
@@ -835,19 +905,16 @@ class Raster:
835
905
 
836
906
  @property
837
907
  def band(self) -> str | None:
908
+ """Band name of the image file name, if filename_regex is present."""
838
909
  try:
839
910
  pattern = re.compile(self.filename_regex, re.VERBOSE)
840
911
  return re.match(pattern, Path(self.path).name).group("band")
841
912
  except (AttributeError, TypeError):
842
913
  return None
843
914
 
844
- # @property
845
- # def band_color(self):
846
- # """To be implemented in subclasses."""
847
- # pass
848
-
849
915
  @property
850
- def dtype(self):
916
+ def dtype(self) -> Any:
917
+ """Data type of the array."""
851
918
  try:
852
919
  return self.array.dtype
853
920
  except AttributeError:
@@ -857,12 +924,12 @@ class Raster:
857
924
  return None
858
925
 
859
926
  @dtype.setter
860
- def dtype(self, new_dtype):
927
+ def dtype(self, new_dtype: Any) -> None:
861
928
  self.array = self.array.astype(new_dtype)
862
- return self.array.dtype
863
929
 
864
930
  @property
865
931
  def nodata(self) -> int | None:
932
+ """No data value."""
866
933
  try:
867
934
  return self._nodata
868
935
  except AttributeError:
@@ -870,12 +937,14 @@ class Raster:
870
937
 
871
938
  @property
872
939
  def tile(self) -> str | None:
940
+ """The lower left corner (minx, miny) of the image as a string."""
873
941
  if self.bounds is None:
874
942
  return None
875
943
  return f"{int(self.bounds[0])}_{int(self.bounds[1])}"
876
944
 
877
945
  @property
878
946
  def meta(self) -> dict:
947
+ """Metadata dict."""
879
948
  return {
880
949
  "path": self.path,
881
950
  "type": self.__class__.__name__,
@@ -886,6 +955,7 @@ class Raster:
886
955
 
887
956
  @property
888
957
  def profile(self) -> dict:
958
+ """Profile of the image file."""
889
959
  # TODO: .crs blir feil hvis warpa. Eller?
890
960
  return {
891
961
  "driver": self.driver,
@@ -902,6 +972,7 @@ class Raster:
902
972
 
903
973
  @property
904
974
  def read_kwargs(self) -> dict:
975
+ """Keywords passed to the read method of rasterio.io.DatasetReader."""
905
976
  return {
906
977
  "indexes": self.indexes,
907
978
  "fill_value": self.nodata,
@@ -910,6 +981,7 @@ class Raster:
910
981
 
911
982
  @property
912
983
  def res(self) -> float | None:
984
+ """Get the spatial resolution of the image."""
913
985
  if hasattr(self, "_res") and self._res is not None:
914
986
  return self._res
915
987
  if self.width is None:
@@ -919,6 +991,7 @@ class Raster:
919
991
 
920
992
  @property
921
993
  def height(self) -> int | None:
994
+ """Get the height of the image as number of pixels."""
922
995
  if self.array is None:
923
996
  try:
924
997
  return self._height
@@ -929,6 +1002,7 @@ class Raster:
929
1002
 
930
1003
  @property
931
1004
  def width(self) -> int | None:
1005
+ """Get the width of the image as number of pixels."""
932
1006
  if self.array is None:
933
1007
  try:
934
1008
  return self._width
@@ -945,6 +1019,7 @@ class Raster:
945
1019
 
946
1020
  @property
947
1021
  def count(self) -> int:
1022
+ """Get the number of bands in the image."""
948
1023
  if self.array is not None:
949
1024
  if len(self.array.shape) == 3:
950
1025
  return self.array.shape[0]
@@ -965,6 +1040,7 @@ class Raster:
965
1040
 
966
1041
  @property
967
1042
  def transform(self) -> Affine | None:
1043
+ """Get the Affine transform of the image."""
968
1044
  try:
969
1045
  return rasterio.transform.from_bounds(*self.bounds, self.width, self.height)
970
1046
  except (ZeroDivisionError, TypeError):
@@ -973,13 +1049,15 @@ class Raster:
973
1049
 
974
1050
  @property
975
1051
  def bounds(self) -> tuple[float, float, float, float] | None:
1052
+ """Get the bounds of the image."""
976
1053
  try:
977
1054
  return to_bbox(self._bounds)
978
1055
  except (AttributeError, TypeError):
979
1056
  return None
980
1057
 
981
1058
  @property
982
- def crs(self):
1059
+ def crs(self) -> pyproj.CRS | None:
1060
+ """Get the coordinate reference system of the image."""
983
1061
  try:
984
1062
  return self._warped_crs
985
1063
  except AttributeError:
@@ -990,24 +1068,29 @@ class Raster:
990
1068
 
991
1069
  @property
992
1070
  def area(self) -> float:
1071
+ """Get the area of the image."""
993
1072
  return shapely.area(self.unary_union)
994
1073
 
995
1074
  @property
996
1075
  def length(self) -> float:
1076
+ """Get the circumfence of the image."""
997
1077
  return shapely.length(self.unary_union)
998
1078
 
999
1079
  @property
1000
1080
  def unary_union(self) -> Polygon:
1081
+ """Get the image bounds as a Polygon."""
1001
1082
  return shapely.box(*self.bounds)
1002
1083
 
1003
1084
  @property
1004
1085
  def centroid(self) -> Point:
1086
+ """Get the centerpoint of the image."""
1005
1087
  x = (self.bounds[0] + self.bounds[2]) / 2
1006
1088
  y = (self.bounds[1] + self.bounds[3]) / 2
1007
1089
  return Point(x, y)
1008
1090
 
1009
1091
  @property
1010
1092
  def properties(self) -> list[str]:
1093
+ """List of all properties of the class."""
1011
1094
  out = []
1012
1095
  for attr in dir(self):
1013
1096
  try:
@@ -1018,11 +1101,12 @@ class Raster:
1018
1101
  return out
1019
1102
 
1020
1103
  def indexes_as_tuple(self) -> tuple[int, ...]:
1104
+ """Get the band index(es) as a tuple of integers."""
1021
1105
  if len(self.shape) == 2:
1022
1106
  return (1,)
1023
1107
  return tuple(i + 1 for i in range(self.shape[0]))
1024
1108
 
1025
- def copy(self, deep=True):
1109
+ def copy(self, deep: bool = True) -> "Raster":
1026
1110
  """Returns a (deep) copy of the class instance.
1027
1111
 
1028
1112
  Args:
@@ -1033,7 +1117,8 @@ class Raster:
1033
1117
  else:
1034
1118
  return copy(self)
1035
1119
 
1036
- def equals(self, other) -> bool:
1120
+ def equals(self, other: Any) -> bool:
1121
+ """Check if the Raster is equal to another Raster."""
1037
1122
  if not isinstance(other, Raster):
1038
1123
  raise NotImplementedError("other must be of type Raster")
1039
1124
  if type(other) != type(self):
@@ -1061,37 +1146,53 @@ class Raster:
1061
1146
  res = None
1062
1147
  return f"{self.__class__.__name__}(shape=({shp}), res={res}, name={self.name}, path={self.path})"
1063
1148
 
1064
- def __mul__(self, scalar):
1149
+ def __iter__(self) -> Iterator[np.ndarray]:
1150
+ """Iterate over the arrays."""
1151
+ if len(self.array.shape) == 2:
1152
+ return iter([self.array])
1153
+ if len(self.array.shape) == 3:
1154
+ return iter(self.array)
1155
+ raise ValueError(
1156
+ f"Array should have shape length 2 or 3. Got {len(self.array.shape)}"
1157
+ )
1158
+
1159
+ def __mul__(self, scalar: int | float) -> "Raster":
1160
+ """Multiply the array values with *."""
1065
1161
  self._check_for_array()
1066
1162
  self.array = self.array * scalar
1067
1163
  return self
1068
1164
 
1069
- def __add__(self, scalar):
1165
+ def __add__(self, scalar: int | float) -> "Raster":
1166
+ """Add to the array values with +."""
1070
1167
  self._check_for_array()
1071
1168
  self.array = self.array + scalar
1072
1169
  return self
1073
1170
 
1074
- def __sub__(self, scalar):
1171
+ def __sub__(self, scalar: int | float) -> "Raster":
1172
+ """Subtract the array values with -."""
1075
1173
  self._check_for_array()
1076
1174
  self.array = self.array - scalar
1077
1175
  return self
1078
1176
 
1079
- def __truediv__(self, scalar):
1177
+ def __truediv__(self, scalar: int | float) -> "Raster":
1178
+ """Divide the array values with /."""
1080
1179
  self._check_for_array()
1081
1180
  self.array = self.array / scalar
1082
1181
  return self
1083
1182
 
1084
- def __floordiv__(self, scalar):
1183
+ def __floordiv__(self, scalar: int | float) -> "Raster":
1184
+ """Floor divide the array values with //."""
1085
1185
  self._check_for_array()
1086
1186
  self.array = self.array // scalar
1087
1187
  return self
1088
1188
 
1089
- def __pow__(self, exponent):
1189
+ def __pow__(self, exponent: int | float) -> "Raster":
1190
+ """Exponentiate the array values with **."""
1090
1191
  self._check_for_array()
1091
1192
  self.array = self.array**exponent
1092
1193
  return self
1093
1194
 
1094
- def _has_nessecary_attrs(self, dict_like) -> bool:
1195
+ def _has_nessecary_attrs(self, dict_like: dict) -> bool:
1095
1196
  """Check if Raster init got enough kwargs to not need to read src."""
1096
1197
  try:
1097
1198
  self._validate_dict(dict_like)
@@ -1101,7 +1202,7 @@ class Raster:
1101
1202
  except AttributeError:
1102
1203
  return False
1103
1204
 
1104
- def _return_self_or_copy(self, array, copy: bool):
1205
+ def _return_self_or_copy(self, array: np.ndarray, copy: bool) -> "Raster":
1105
1206
  if not copy:
1106
1207
  self.array = array
1107
1208
  return self
@@ -1111,7 +1212,7 @@ class Raster:
1111
1212
  return copy
1112
1213
 
1113
1214
  @classmethod
1114
- def _validate_dict(cls, dict_like) -> None:
1215
+ def _validate_dict(cls, dict_like: dict) -> None:
1115
1216
  missing = []
1116
1217
  for attr in NESSECARY_META:
1117
1218
  if any(
@@ -1127,14 +1228,14 @@ class Raster:
1127
1228
  raise AttributeError(f"Missing nessecary key(s) {', '.join(missing)}")
1128
1229
 
1129
1230
  @classmethod
1130
- def _validate_key(cls, key) -> None:
1231
+ def _validate_key(cls, key: str) -> None:
1131
1232
  if key not in ALLOWED_KEYS:
1132
1233
  raise ValueError(
1133
1234
  f"Got an unexpected key {key!r}. Allowed keys are ",
1134
1235
  ", ".join(ALLOWED_KEYS),
1135
1236
  )
1136
1237
 
1137
- def _get_shape_from_res(self, res) -> tuple[int] | None:
1238
+ def _get_shape_from_res(self, res: int) -> tuple[int] | None:
1138
1239
  if res is None:
1139
1240
  return None
1140
1241
  if hasattr(res, "__iter__") and len(res) == 2:
@@ -1147,7 +1248,9 @@ class Raster:
1147
1248
  return len(self.indexes), width, height
1148
1249
  return width, height
1149
1250
 
1150
- def _write(self, dst, window):
1251
+ def _write(
1252
+ self, dst: rasterio.io.DatasetReader, window: rasterio.windows.Window
1253
+ ) -> None:
1151
1254
  if np.ma.is_masked(self.array):
1152
1255
  if len(self.array.shape) == 2:
1153
1256
  return dst.write(
@@ -1168,7 +1271,7 @@ class Raster:
1168
1271
  for i, idx in enumerate(self.indexes_as_tuple()):
1169
1272
  dst.write(self.array[i], indexes=idx, window=window)
1170
1273
 
1171
- def _get_indexes(self, indexes):
1274
+ def _get_indexes(self, indexes: int | tuple[int] | None) -> int | tuple[int] | None:
1172
1275
  if isinstance(indexes, numbers.Number):
1173
1276
  return int(indexes)
1174
1277
  if indexes is None:
@@ -1188,7 +1291,7 @@ class Raster:
1188
1291
  f"Got {type(indexes)}: {indexes}"
1189
1292
  ) from e
1190
1293
 
1191
- def _return_gdf(self, obj) -> GeoDataFrame:
1294
+ def _return_gdf(self, obj: Any) -> GeoDataFrame:
1192
1295
  if isinstance(obj, str) and not is_wkt(obj):
1193
1296
  return self._read_tif(obj)
1194
1297
  elif isinstance(obj, Raster):
@@ -1210,11 +1313,13 @@ class Raster:
1210
1313
  warnings.filterwarnings("ignore", category=UserWarning)
1211
1314
  return [
1212
1315
  (feature["geometry"], val)
1213
- for val, feature in zip(gdf[column], loads(gdf.to_json())["features"])
1316
+ for val, feature in zip(
1317
+ gdf[column], loads(gdf.to_json())["features"], strict=False
1318
+ )
1214
1319
  ]
1215
1320
 
1216
1321
  @staticmethod
1217
- def _array_to_geojson(array: np.ndarray, transform: Affine):
1322
+ def _array_to_geojson(array: np.ndarray, transform: Affine) -> list[tuple]:
1218
1323
  try:
1219
1324
  return [
1220
1325
  (value, shape(geom))
@@ -1227,7 +1332,7 @@ class Raster:
1227
1332
  for geom, value in features.shapes(array, transform=transform)
1228
1333
  ]
1229
1334
 
1230
- def _add_indexes_from_array(self, indexes):
1335
+ def _add_indexes_from_array(self, indexes: int | tuple[int]) -> int | tuple[int]:
1231
1336
  if indexes is not None:
1232
1337
  return indexes
1233
1338
  elif len(self.array.shape) == 3:
@@ -1237,7 +1342,7 @@ class Raster:
1237
1342
  else:
1238
1343
  raise ValueError
1239
1344
 
1240
- def _add_meta_from_src(self, src):
1345
+ def _add_meta_from_src(self, src: rasterio.io.DatasetReader) -> None:
1241
1346
  if not hasattr(self, "_bounds") or self._bounds is None:
1242
1347
  self._bounds = tuple(src.bounds)
1243
1348
 
@@ -1293,7 +1398,7 @@ class Raster:
1293
1398
  return self._read(self.path, **kwargs)
1294
1399
 
1295
1400
  @functools.lru_cache(maxsize=128)
1296
- def _read(self, path, **kwargs):
1401
+ def _read(self, path: str | Path, **kwargs) -> None:
1297
1402
  with opener(path, file_system=self.file_system) as file:
1298
1403
  with rasterio.open(file) as src:
1299
1404
  self._add_meta_from_src(src)
@@ -1311,7 +1416,9 @@ class Raster:
1311
1416
  else:
1312
1417
  self = self.as_minimum_dtype()
1313
1418
 
1314
- def _read_with_mask(self, mask, masked, boundless, **kwargs):
1419
+ def _read_with_mask(
1420
+ self, mask: Any, masked: bool, boundless: bool, **kwargs
1421
+ ) -> None:
1315
1422
  kwargs["mask"] = mask
1316
1423
 
1317
1424
  def _read(self, src, mask, **kwargs):
@@ -1392,3 +1499,82 @@ def get_shape_from_bounds(
1392
1499
  width = int(diffx / resx)
1393
1500
  heigth = int(diffy / resy)
1394
1501
  return heigth, width
1502
+
1503
+
1504
+ def get_gradient(raster: Raster, degrees: bool = False, copy: bool = False) -> Raster:
1505
+ """Get the slope of an elevation raster.
1506
+
1507
+ Calculates the absolute slope between the grid cells
1508
+ based on the image resolution.
1509
+
1510
+ For multiband images, the calculation is done for each band.
1511
+
1512
+ Args:
1513
+ raster: Raster instance.
1514
+ degrees: If False (default), the returned values will be in ratios,
1515
+ where a value of 1 means 1 meter up per 1 meter forward. If True,
1516
+ the values will be in degrees from 0 to 90.
1517
+ copy: Whether to copy or overwrite the original Raster.
1518
+ Defaults to False to save memory.
1519
+
1520
+ Returns:
1521
+ The class instance with new array values, or a copy if copy is True.
1522
+
1523
+ Examples:
1524
+ --------
1525
+ Making an array where the gradient to the center is always 10.
1526
+
1527
+ >>> import sgis as sg
1528
+ >>> import numpy as np
1529
+ >>> arr = np.array(
1530
+ ... [
1531
+ ... [100, 100, 100, 100, 100],
1532
+ ... [100, 110, 110, 110, 100],
1533
+ ... [100, 110, 120, 110, 100],
1534
+ ... [100, 110, 110, 110, 100],
1535
+ ... [100, 100, 100, 100, 100],
1536
+ ... ]
1537
+ ... )
1538
+
1539
+ Now let's create a Raster from this array with a resolution of 10.
1540
+
1541
+ >>> r = sg.Raster.from_array(arr, crs=None, bounds=(0, 0, 50, 50), res=10)
1542
+
1543
+ The gradient will be 1 (1 meter up for every meter forward).
1544
+ The calculation is by default done in place to save memory.
1545
+
1546
+ >>> r.gradient()
1547
+ >>> r.array
1548
+ array([[0., 1., 1., 1., 0.],
1549
+ [1., 1., 1., 1., 1.],
1550
+ [1., 1., 0., 1., 1.],
1551
+ [1., 1., 1., 1., 1.],
1552
+ [0., 1., 1., 1., 0.]])
1553
+ """
1554
+ out_array = []
1555
+ for array in raster:
1556
+ results = _slope_2d(array, raster.res, degrees=degrees)
1557
+ out_array.append(results)
1558
+
1559
+ if len(raster.shape) == 2:
1560
+ out_array = out_array[0]
1561
+ else:
1562
+ out_array = np.array(out_array)
1563
+
1564
+ return raster._return_self_or_copy(out_array, copy)
1565
+
1566
+
1567
+ def _slope_2d(array: np.ndarray, res: int, degrees: int) -> np.ndarray:
1568
+ gradient_x, gradient_y = np.gradient(array, res, res)
1569
+
1570
+ gradient = abs(gradient_x) + abs(gradient_y)
1571
+
1572
+ if not degrees:
1573
+ return gradient
1574
+
1575
+ radians = np.arctan(gradient)
1576
+ degrees = np.degrees(radians)
1577
+
1578
+ assert np.max(degrees) <= 90
1579
+
1580
+ return degrees