ssb-sgis 1.0.8__py3-none-any.whl → 1.0.9__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.
@@ -446,12 +446,13 @@ def to_gdf(
446
446
  geom_col: str = _find_geometry_column(obj, geometry) # type: ignore[no-redef]
447
447
  index = kwargs.pop("index", None)
448
448
 
449
- # get done with iterators that get consumed by 'all'
449
+ # get done with iterators that would get consumed by 'all' later
450
450
  if isinstance(obj, Iterator) and not isinstance(obj, Sized):
451
- obj = GeoSeries(
452
- (_make_one_shapely_geom(g) for g in obj if g is not None), index=index
453
- )
454
- return GeoDataFrame({geom_col: obj}, geometry=geom_col, crs=crs, **kwargs)
451
+ obj = list(obj)
452
+ # obj = GeoSeries(
453
+ # (_make_one_shapely_geom(g) for g in obj if g is not None), index=index
454
+ # )
455
+ # return GeoDataFrame({geom_col: obj}, geometry=geom_col, crs=crs, **kwargs)
455
456
 
456
457
  if hasattr(obj, "__len__") and not len(obj):
457
458
  return GeoDataFrame({"geometry": []}, crs=crs)
@@ -1,5 +1,7 @@
1
1
  """Functions for reading and writing GeoDataFrames in Statistics Norway's GCS Dapla."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import json
4
6
  import multiprocessing
5
7
  import os
sgis/io/opener.py CHANGED
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  from collections.abc import Generator
2
4
  from contextlib import contextmanager
3
5
  from typing import Any
sgis/maps/explore.py CHANGED
@@ -183,9 +183,11 @@ def to_tile(tile: str | xyzservices.TileProvider, max_zoom: int) -> folium.TileL
183
183
 
184
184
 
185
185
  def _single_band_to_arr(band, mask, name, raster_data_dict):
186
- try:
186
+ if band.has_array and mask is None:
187
187
  arr = band.values
188
- except (ValueError, AttributeError):
188
+ elif band.has_array:
189
+ arr = band.clip(mask).values
190
+ else:
189
191
  arr = band.load(indexes=1, bounds=mask).values
190
192
  bounds: tuple = (
191
193
  _any_to_bbox_crs4326(mask, band.crs)
@@ -533,8 +535,8 @@ class Explore(Map):
533
535
  arr = np.where(arr, 1, 0)
534
536
  try:
535
537
  arr = (arr - np.min(arr)) / (np.max(arr) - np.min(arr))
536
- except Exception:
537
- pass
538
+ except Exception as e:
539
+ warnings.warn(str(e), stacklevel=1)
538
540
 
539
541
  label = raster_data_dict["label"]
540
542
  bounds = raster_data_dict["bounds"]
@@ -1066,7 +1068,7 @@ class Explore(Map):
1066
1068
  name: str,
1067
1069
  max_images: int,
1068
1070
  n_added_images: int,
1069
- rbg_bands: list[str] = (["B02", "B03", "B04"], ["B2", "B3", "B4"]),
1071
+ rbg_bands: list[str] = (("B04", "B02", "B03"), ("B4", "B2", "B3")),
1070
1072
  ) -> tuple[list[dict], int]:
1071
1073
  out = []
1072
1074
 
@@ -1162,17 +1164,25 @@ class Explore(Map):
1162
1164
  n_added_images += 1
1163
1165
  continue
1164
1166
 
1167
+ def load(band_id: str) -> Band:
1168
+ band = image[band_id]
1169
+ if band.has_array and mask is not None:
1170
+ band = band.clip(mask, copy=True)
1171
+ elif not band.has_array:
1172
+ band = band.load(indexes=1, bounds=mask)
1173
+ return band
1174
+
1165
1175
  for red, blue, green in rbg_bands:
1166
1176
  try:
1167
- red_band = image[red].load(indexes=1, bounds=mask)
1177
+ red_band = load(red)
1168
1178
  except KeyError:
1169
1179
  continue
1170
1180
  try:
1171
- blue_band = image[blue].load(indexes=1, bounds=mask)
1181
+ blue_band = load(blue)
1172
1182
  except KeyError:
1173
1183
  continue
1174
1184
  try:
1175
- green_band = image[green].load(indexes=1, bounds=mask)
1185
+ green_band = load(green)
1176
1186
  except KeyError:
1177
1187
  continue
1178
1188
  break
sgis/maps/legend.py CHANGED
@@ -73,8 +73,10 @@ LOWERCASE_WORDS = {
73
73
 
74
74
  def prettify_label(label: str) -> str:
75
75
  """Replace underscores with spaces and capitalize words that are all lowecase."""
76
+ if len(label) == 1:
77
+ return label
76
78
  return " ".join(
77
- word.title() if word.islower() and word not in LOWERCASE_WORDS else word
79
+ (word.title() if word.islower() and word not in LOWERCASE_WORDS else word)
78
80
  for word in label.replace("_", " ").split()
79
81
  )
80
82
 
sgis/maps/map.py CHANGED
@@ -232,6 +232,10 @@ class Map:
232
232
  self._nan_idx = self._gdf[self._column].isna()
233
233
  self._get_unique_values()
234
234
 
235
+ def __getattr__(self, attr: str) -> Any:
236
+ """Search for attribute in kwargs."""
237
+ return self.kwargs.get(attr, super().__getattribute__(attr))
238
+
235
239
  def __bool__(self) -> bool:
236
240
  """True of any gdfs with more than 0 rows."""
237
241
  return bool(len(self._gdfs) + len(self._gdf))
sgis/maps/thematicmap.py CHANGED
@@ -10,6 +10,8 @@ import numpy as np
10
10
  import pandas as pd
11
11
  from geopandas import GeoDataFrame
12
12
 
13
+ from ..geopandas_tools.conversion import to_bbox
14
+ from ..helpers import is_property
13
15
  from .legend import LEGEND_KWARGS
14
16
  from .legend import ContinousLegend
15
17
  from .legend import Legend
@@ -37,9 +39,11 @@ MAP_KWARGS = {
37
39
  "facecolor",
38
40
  "labelcolor",
39
41
  "nan_color",
42
+ # "alpha",
40
43
  "title_kwargs",
41
44
  "bg_gdf_color",
42
45
  "title_position",
46
+ # "linewidth",
43
47
  }
44
48
 
45
49
 
@@ -49,6 +53,7 @@ class ThematicMap(Map):
49
53
  Args:
50
54
  *gdfs: One or more GeoDataFrames.
51
55
  column: The name of the column to plot.
56
+ bounds: Optional bounding box for the map.
52
57
  title: Title of the plot.
53
58
  title_position: Title position. Either "center" (default), "left" or "right".
54
59
  size: Width and height of the plot in inches. Fontsize of title and legend is
@@ -155,6 +160,7 @@ class ThematicMap(Map):
155
160
  self,
156
161
  *gdfs: GeoDataFrame,
157
162
  column: str | None = None,
163
+ bounds: tuple | None = None,
158
164
  title: str | None = None,
159
165
  title_position: tuple[float, float] | None = None,
160
166
  size: int = 25,
@@ -166,7 +172,7 @@ class ThematicMap(Map):
166
172
  nan_label: str = "Missing",
167
173
  legend_kwargs: dict | None = None,
168
174
  title_kwargs: dict | None = None,
169
- legend: bool = False,
175
+ legend: bool = True,
170
176
  **kwargs,
171
177
  ) -> None:
172
178
  """Initialiser."""
@@ -179,9 +185,6 @@ class ThematicMap(Map):
179
185
  nan_label=nan_label,
180
186
  )
181
187
 
182
- if not legend:
183
- self.legend = None
184
-
185
188
  self.title = title
186
189
  self._size = size
187
190
  self._dark = dark
@@ -222,21 +225,23 @@ class ThematicMap(Map):
222
225
  if not self.cmap and not self._is_categorical:
223
226
  self._choose_cmap()
224
227
 
228
+ if not legend:
229
+ self.legend = None
230
+ else:
231
+ self._create_legend()
232
+
225
233
  self._dark_or_light()
226
- self._create_legend()
227
234
 
228
235
  if cmap:
229
236
  self._cmap = cmap
230
237
 
231
238
  for key, value in kwargs.items():
232
239
  if key not in MAP_KWARGS:
233
- raise TypeError(
234
- f"{self.__class__.__name__} got an unexpected keyword argument {key}"
235
- )
236
- try:
237
- setattr(self, key, value)
238
- except Exception:
240
+ self.kwargs[key] = value
241
+ elif is_property(self, key):
239
242
  setattr(self, f"_{key}", value)
243
+ else:
244
+ setattr(self, key, value)
240
245
 
241
246
  for key, value in legend_kwargs.items():
242
247
  if key not in LEGEND_KWARGS:
@@ -249,6 +254,13 @@ class ThematicMap(Map):
249
254
  except Exception:
250
255
  setattr(self.legend, f"_{key}", value)
251
256
 
257
+ self.bounds = (
258
+ to_bbox(bounds) if bounds is not None else to_bbox(self._gdf.total_bounds)
259
+ )
260
+ self.minx, self.miny, self.maxx, self.maxy = self.bounds
261
+ self.diffx = self.maxx - self.minx
262
+ self.diffy = self.maxy - self.miny
263
+
252
264
  @property
253
265
  def valid_keywords(self) -> set[str]:
254
266
  """List all valid keywords for the class initialiser."""
@@ -285,16 +297,17 @@ class ThematicMap(Map):
285
297
  self._background_gdfs = pd.concat(
286
298
  [self._background_gdfs, gdf], ignore_index=True
287
299
  )
288
- self.minx, self.miny, self.maxx, self.maxy = self._gdf.total_bounds
289
- self.diffx = self.maxx - self.minx
290
- self.diffy = self.maxy - self.miny
300
+ if self.bounds is None:
301
+ self.bounds = to_bbox(self._gdf.total_bounds)
291
302
  return self
292
303
 
293
304
  def plot(self, **kwargs) -> None:
294
305
  """Creates the final plot.
295
306
 
296
307
  This method should be run after customising the map, but before saving.
308
+
297
309
  """
310
+ kwargs = kwargs | self.kwargs
298
311
  __test = kwargs.pop("__test", False)
299
312
  include_legend = bool(kwargs.pop("legend", self.legend))
300
313
 
@@ -379,6 +392,13 @@ class ThematicMap(Map):
379
392
 
380
393
  if hasattr(self, "_background_gdfs"):
381
394
  self._actually_add_background()
395
+ elif self.bounds is not None:
396
+ self.ax.set_xlim(
397
+ [self.minx - self.diffx * 0.03, self.maxx + self.diffx * 0.03]
398
+ )
399
+ self.ax.set_ylim(
400
+ [self.miny - self.diffy * 0.03, self.maxy + self.diffy * 0.03]
401
+ )
382
402
 
383
403
  if self.title:
384
404
  self.ax.set_title(
@@ -468,18 +488,10 @@ class ThematicMap(Map):
468
488
 
469
489
  def _create_legend(self) -> None:
470
490
  """Instantiate the Legend class."""
471
- if self.legend is None:
472
- return
473
- kwargs = {}
474
- if self._dark:
475
- kwargs["facecolor"] = "#0f0f0f"
476
- kwargs["labelcolor"] = "#fefefe"
477
- kwargs["title_color"] = "#fefefe"
478
-
479
491
  if self._is_categorical:
480
- self.legend = Legend(title=self._column, size=self._size, **kwargs)
492
+ self.legend = Legend(title=self._column, size=self._size)
481
493
  else:
482
- self.legend = ContinousLegend(title=self._column, size=self._size, **kwargs)
494
+ self.legend = ContinousLegend(title=self._column, size=self._size)
483
495
 
484
496
  def _choose_cmap(self) -> None:
485
497
  """Kwargs is to catch start and stop points for the cmap in __init__."""
@@ -524,16 +536,32 @@ class ThematicMap(Map):
524
536
  if not self._is_categorical:
525
537
  self.change_cmap("viridis")
526
538
 
539
+ if self.legend is not None:
540
+ for key, color in {
541
+ "facecolor": "#0f0f0f",
542
+ "labelcolor": "#fefefe",
543
+ "title_color": "#fefefe",
544
+ }.items():
545
+ setattr(self.legend, key, color)
546
+
527
547
  else:
528
548
  self.facecolor, self.title_color, self.bg_gdf_color = (
529
549
  "#fefefe",
530
550
  "#0f0f0f",
531
- "#dbdbdb",
551
+ "#e8e6e6",
532
552
  )
533
553
  self.nan_color = "#c2c2c2"
534
554
  if not self._is_categorical:
535
555
  self.change_cmap("RdPu", start=23)
536
556
 
557
+ if self.legend is not None:
558
+ for key, color in {
559
+ "facecolor": "#fefefe",
560
+ "labelcolor": "#0f0f0f",
561
+ "title_color": "#0f0f0f",
562
+ }.items():
563
+ setattr(self.legend, key, color)
564
+
537
565
  @property
538
566
  def dark(self) -> bool:
539
567
  """Whether to use dark background and light text colors."""
@@ -543,7 +571,6 @@ class ThematicMap(Map):
543
571
  def dark(self, new_value: bool):
544
572
  self._dark = new_value
545
573
  self._dark_or_light()
546
- self._create_legend()
547
574
 
548
575
  @property
549
576
  def title_fontsize(self) -> int:
sgis/raster/base.py CHANGED
@@ -9,6 +9,7 @@ import joblib
9
9
  import numpy as np
10
10
  import pandas as pd
11
11
  import rasterio
12
+ import shapely
12
13
  from affine import Affine
13
14
  from geopandas import GeoDataFrame
14
15
  from geopandas import GeoSeries
@@ -20,8 +21,21 @@ from shapely.geometry import shape
20
21
  from ..geopandas_tools.conversion import to_bbox
21
22
 
22
23
 
24
+ def _get_res_from_bounds(
25
+ obj: GeoDataFrame | GeoSeries | Geometry | tuple, shape: tuple[int, ...]
26
+ ) -> tuple[int, int] | None:
27
+ minx, miny, maxx, maxy = to_bbox(obj)
28
+ try:
29
+ height, width = shape[-2:]
30
+ except IndexError:
31
+ return None
32
+ resx = (maxx - minx) / width
33
+ resy = (maxy - miny) / height
34
+ return resx, resy
35
+
36
+
23
37
  def _get_transform_from_bounds(
24
- obj: GeoDataFrame | GeoSeries | Geometry | tuple, shape: tuple[float, ...]
38
+ obj: GeoDataFrame | GeoSeries | Geometry | tuple, shape: tuple[int, ...]
25
39
  ) -> Affine:
26
40
  minx, miny, maxx, maxy = to_bbox(obj)
27
41
  if len(shape) == 2:
@@ -34,12 +48,16 @@ def _get_transform_from_bounds(
34
48
  return rasterio.transform.from_bounds(minx, miny, maxx, maxy, width, height)
35
49
 
36
50
 
51
+ def _res_as_tuple(res: int | float | tuple[int | float]) -> tuple[int | float]:
52
+ return (res, res) if isinstance(res, numbers.Number) else res
53
+
54
+
37
55
  def _get_shape_from_bounds(
38
56
  obj: GeoDataFrame | GeoSeries | Geometry | tuple,
39
57
  res: int,
40
58
  indexes: int | tuple[int],
41
59
  ) -> tuple[int, int]:
42
- resx, resy = (res, res) if isinstance(res, numbers.Number) else res
60
+ resx, resy = _res_as_tuple(res)
43
61
 
44
62
  minx, miny, maxx, maxy = to_bbox(obj)
45
63
 
@@ -111,7 +129,9 @@ def _value_geom_pair(value, geom):
111
129
 
112
130
  def _gdf_to_arr(
113
131
  gdf: GeoDataFrame,
114
- res: int | float,
132
+ res: int | float | None = None,
133
+ out_shape: tuple[int, int] | None = None,
134
+ bounds: tuple[float] | None = None,
115
135
  fill: int = 0,
116
136
  all_touched: bool = False,
117
137
  merge_alg: Callable = MergeAlg.replace,
@@ -125,6 +145,7 @@ def _gdf_to_arr(
125
145
  Args:
126
146
  gdf: The GeoDataFrame to rasterize.
127
147
  res: Resolution of the raster in units of the GeoDataFrame's coordinate reference system.
148
+ bounds: Optional bounds to box 'gdf' into (so both clip and extend to).
128
149
  fill: Fill value for areas outside of input geometries (default is 0).
129
150
  all_touched: Whether to consider all pixels touched by geometries,
130
151
  not just those whose center is within the polygon (default is False).
@@ -134,6 +155,7 @@ def _gdf_to_arr(
134
155
  (default is 1).
135
156
  dtype: Data type of the output array. If None, it will be
136
157
  determined automatically.
158
+ out_shape: Optional 2 dimensional shape of the resulting array.
137
159
 
138
160
  Returns:
139
161
  A Raster instance based on the specified GeoDataFrame and parameters.
@@ -142,33 +164,48 @@ def _gdf_to_arr(
142
164
  TypeError: If 'transform' is provided in kwargs, as this is
143
165
  computed based on the GeoDataFrame bounds and resolution.
144
166
  """
167
+ if res is not None and out_shape is not None:
168
+ raise TypeError("Cannot specify both 'res' and 'out_shape'")
169
+ if res is None and out_shape is None:
170
+ raise TypeError("Must specify either 'res' or 'out_shape'")
171
+
145
172
  if isinstance(gdf, GeoSeries):
146
- values = gdf.index
147
173
  gdf = gdf.to_frame("geometry")
148
- elif isinstance(gdf, GeoDataFrame):
149
- if len(gdf.columns) > 2:
150
- raise ValueError(
151
- "gdf should have only a geometry column and one numeric column to "
152
- "use as array values. "
153
- "Alternatively only a geometry column and a numeric index."
154
- )
155
- elif len(gdf.columns) == 1:
156
- values = gdf.index
157
- else:
158
- col: str = next(
159
- iter([col for col in gdf if col != gdf._geometry_column_name])
160
- )
161
- values = gdf[col]
174
+ elif not isinstance(gdf, GeoDataFrame):
175
+ raise TypeError(type(gdf))
176
+
177
+ if bounds is not None:
178
+ gdf = gdf.clip(bounds)
179
+ bounds_gdf = GeoDataFrame({"geometry": [shapely.box(*bounds)]}, crs=gdf.crs)
180
+
181
+ if len(gdf.columns) > 2:
182
+ raise ValueError(
183
+ "gdf should have only a geometry column and one numeric column to "
184
+ "use as array values. "
185
+ "Alternatively only a geometry column and a numeric index."
186
+ )
187
+ elif len(gdf.columns) == 1:
188
+ values = np.full(len(gdf), default_value)
189
+ else:
190
+ col: str = next(iter(gdf.columns.difference({gdf.geometry.name})))
191
+ values = gdf[col].values
192
+
193
+ if bounds is not None:
194
+ gdf = pd.concat([bounds_gdf, gdf])
195
+ values = np.concatenate([np.array([fill]), values])
196
+
197
+ if out_shape is None:
198
+ assert res is not None
199
+ out_shape = _get_shape_from_bounds(gdf.total_bounds, res=res, indexes=1)
162
200
 
163
- if isinstance(values, pd.MultiIndex):
164
- raise ValueError("Index cannot be MultiIndex.")
201
+ if not len(gdf):
202
+ return np.full(out_shape, fill)
165
203
 
166
- shape = _get_shape_from_bounds(gdf.total_bounds, res=res, indexes=1)
167
- transform = _get_transform_from_bounds(gdf.total_bounds, shape)
204
+ transform = _get_transform_from_bounds(gdf.total_bounds, out_shape)
168
205
 
169
206
  return features.rasterize(
170
207
  _gdf_to_geojson_with_col(gdf, values),
171
- out_shape=shape,
208
+ out_shape=out_shape,
172
209
  transform=transform,
173
210
  fill=fill,
174
211
  all_touched=all_touched,