mapchete-eo 2025.10.1__py2.py3-none-any.whl → 2026.1.0__py2.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 (63) hide show
  1. mapchete_eo/__init__.py +1 -1
  2. mapchete_eo/base.py +94 -54
  3. mapchete_eo/cli/options_arguments.py +11 -27
  4. mapchete_eo/cli/s2_brdf.py +1 -1
  5. mapchete_eo/cli/s2_cat_results.py +4 -20
  6. mapchete_eo/cli/s2_find_broken_products.py +4 -20
  7. mapchete_eo/cli/s2_jp2_static_catalog.py +2 -2
  8. mapchete_eo/cli/static_catalog.py +4 -45
  9. mapchete_eo/eostac.py +1 -1
  10. mapchete_eo/io/assets.py +20 -16
  11. mapchete_eo/io/items.py +36 -23
  12. mapchete_eo/io/path.py +19 -8
  13. mapchete_eo/io/products.py +22 -24
  14. mapchete_eo/platforms/sentinel2/__init__.py +1 -1
  15. mapchete_eo/platforms/sentinel2/_mapper_registry.py +89 -0
  16. mapchete_eo/platforms/sentinel2/brdf/correction.py +1 -1
  17. mapchete_eo/platforms/sentinel2/brdf/hls.py +1 -1
  18. mapchete_eo/platforms/sentinel2/brdf/models.py +1 -1
  19. mapchete_eo/platforms/sentinel2/brdf/protocols.py +1 -1
  20. mapchete_eo/platforms/sentinel2/brdf/ross_thick.py +1 -1
  21. mapchete_eo/platforms/sentinel2/brdf/sun_angle_arrays.py +1 -1
  22. mapchete_eo/platforms/sentinel2/config.py +73 -13
  23. mapchete_eo/platforms/sentinel2/driver.py +0 -39
  24. mapchete_eo/platforms/sentinel2/metadata_parser/__init__.py +6 -0
  25. mapchete_eo/platforms/sentinel2/{path_mappers → metadata_parser}/base.py +1 -1
  26. mapchete_eo/platforms/sentinel2/{path_mappers/metadata_xml.py → metadata_parser/default_path_mapper.py} +2 -2
  27. mapchete_eo/platforms/sentinel2/metadata_parser/models.py +78 -0
  28. mapchete_eo/platforms/sentinel2/{metadata_parser.py → metadata_parser/s2metadata.py} +51 -144
  29. mapchete_eo/platforms/sentinel2/preconfigured_sources/__init__.py +57 -0
  30. mapchete_eo/platforms/sentinel2/preconfigured_sources/guessers.py +108 -0
  31. mapchete_eo/platforms/sentinel2/preconfigured_sources/item_mappers.py +171 -0
  32. mapchete_eo/platforms/sentinel2/preconfigured_sources/metadata_xml_mappers.py +217 -0
  33. mapchete_eo/platforms/sentinel2/preprocessing_tasks.py +22 -1
  34. mapchete_eo/platforms/sentinel2/processing_baseline.py +3 -0
  35. mapchete_eo/platforms/sentinel2/product.py +83 -18
  36. mapchete_eo/platforms/sentinel2/source.py +114 -0
  37. mapchete_eo/platforms/sentinel2/types.py +5 -0
  38. mapchete_eo/product.py +14 -8
  39. mapchete_eo/protocols.py +5 -0
  40. mapchete_eo/search/__init__.py +3 -3
  41. mapchete_eo/search/base.py +127 -99
  42. mapchete_eo/search/config.py +75 -4
  43. mapchete_eo/search/s2_mgrs.py +8 -9
  44. mapchete_eo/search/stac_search.py +99 -97
  45. mapchete_eo/search/stac_static.py +46 -102
  46. mapchete_eo/search/utm_search.py +54 -62
  47. mapchete_eo/settings.py +1 -0
  48. mapchete_eo/sort.py +4 -6
  49. mapchete_eo/source.py +107 -0
  50. {mapchete_eo-2025.10.1.dist-info → mapchete_eo-2026.1.0.dist-info}/METADATA +4 -3
  51. mapchete_eo-2026.1.0.dist-info/RECORD +89 -0
  52. {mapchete_eo-2025.10.1.dist-info → mapchete_eo-2026.1.0.dist-info}/WHEEL +1 -1
  53. {mapchete_eo-2025.10.1.dist-info → mapchete_eo-2026.1.0.dist-info}/entry_points.txt +1 -1
  54. {mapchete_eo-2025.10.1.dist-info → mapchete_eo-2026.1.0.dist-info}/licenses/LICENSE +1 -1
  55. mapchete_eo/archives/__init__.py +0 -0
  56. mapchete_eo/archives/base.py +0 -65
  57. mapchete_eo/geometry.py +0 -271
  58. mapchete_eo/known_catalogs.py +0 -42
  59. mapchete_eo/platforms/sentinel2/archives.py +0 -190
  60. mapchete_eo/platforms/sentinel2/path_mappers/__init__.py +0 -29
  61. mapchete_eo/platforms/sentinel2/path_mappers/earthsearch.py +0 -34
  62. mapchete_eo/platforms/sentinel2/path_mappers/sinergise.py +0 -105
  63. mapchete_eo-2025.10.1.dist-info/RECORD +0 -88
mapchete_eo/protocols.py CHANGED
@@ -15,6 +15,7 @@ from mapchete.io.raster import ReferencedRaster
15
15
 
16
16
 
17
17
  class EOProductProtocol(Protocol):
18
+ id: str
18
19
  bounds: Bounds
19
20
  crs: CRS
20
21
  __geo_interface__: Optional[Dict[str, Any]]
@@ -54,3 +55,7 @@ class EOProductProtocol(Protocol):
54
55
 
55
56
  class DateTimeProtocol(Protocol):
56
57
  datetime: DateTimeLike
58
+
59
+
60
+ class GetPropertyProtocol(Protocol):
61
+ def get_property(self, property: str) -> Any: ...
@@ -7,8 +7,8 @@ of product metadata.
7
7
  It helps the InputData class to find the input products and their metadata.
8
8
  """
9
9
 
10
- from mapchete_eo.search.stac_search import STACSearchCatalog
11
- from mapchete_eo.search.stac_static import STACStaticCatalog
10
+ from mapchete_eo.search.stac_search import STACSearchCollection
11
+ from mapchete_eo.search.stac_static import STACStaticCollection
12
12
  from mapchete_eo.search.utm_search import UTMSearchCatalog
13
13
 
14
- __all__ = ["STACSearchCatalog", "STACStaticCatalog", "UTMSearchCatalog"]
14
+ __all__ = ["STACSearchCollection", "STACStaticCollection", "UTMSearchCatalog"]
@@ -2,20 +2,24 @@ from functools import cached_property
2
2
  import json
3
3
  import logging
4
4
  from abc import ABC, abstractmethod
5
- from typing import Any, Callable, Dict, Generator, List, Optional, Type, Union
5
+ from typing import Any, Callable, Dict, Generator, List, Optional, Set, Type, Union
6
6
 
7
+ from pygeofilter.parsers.ecql import parse as parse_ecql
8
+ from pygeofilter.backends.native.evaluate import NativeEvaluator
7
9
  from pydantic import BaseModel
8
- from pystac import Item, Catalog, CatalogType, Extent
9
10
  from mapchete.path import MPath, MPathLike
10
11
  from mapchete.types import Bounds
12
+ from pystac import Catalog, Item, CatalogType, Extent
11
13
  from pystac.collection import Collection
12
14
  from pystac.stac_io import DefaultStacIO
13
- from pystac_client import Client
15
+ from pystac_client import CollectionClient
14
16
  from pystac_client.stac_api_io import StacApiIO
15
17
  from rasterio.profiles import Profile
16
18
  from shapely.geometry.base import BaseGeometry
17
19
 
18
20
  from mapchete_eo.io.assets import get_assets, get_metadata_assets
21
+ from mapchete_eo.product import blacklist_products
22
+ from mapchete_eo.settings import mapchete_eo_settings
19
23
  from mapchete_eo.types import TimeRange
20
24
 
21
25
  logger = logging.getLogger(__name__)
@@ -44,29 +48,51 @@ class FSSpecStacIO(StacApiIO):
44
48
  return dst.write(json.dumps(json_dict, indent=2))
45
49
 
46
50
 
47
- class CatalogSearcher(ABC):
51
+ class CollectionSearcher(ABC):
48
52
  """
49
53
  This class serves as a bridge between an Archive and a catalog implementation.
50
54
  """
51
55
 
52
- collections: List[str]
53
56
  config_cls: Type[BaseModel]
57
+ collection: str
58
+ stac_item_modifiers: Optional[List[Callable[[Item], Item]]] = None
59
+ blacklist: Set[str] = (
60
+ blacklist_products(mapchete_eo_settings.blacklist)
61
+ if mapchete_eo_settings.blacklist
62
+ else set()
63
+ )
64
+
65
+ def __init__(
66
+ self,
67
+ collection: str,
68
+ stac_item_modifiers: Optional[List[Callable[[Item], Item]]] = None,
69
+ ):
70
+ self.collection = collection
71
+ self.stac_item_modifiers = stac_item_modifiers
54
72
 
55
73
  @abstractmethod
56
74
  @cached_property
57
- def eo_bands(self) -> List[str]: ...
75
+ def client(self) -> CollectionClient: ...
58
76
 
59
77
  @abstractmethod
60
78
  @cached_property
61
- def id(self) -> str: ...
79
+ def eo_bands(self) -> List[str]: ...
80
+
81
+ @property
82
+ def config(self) -> BaseModel:
83
+ return self.config_cls()
62
84
 
63
- @abstractmethod
64
85
  @cached_property
65
- def description(self) -> str: ...
86
+ def id(self) -> str:
87
+ return self.client.id
88
+
89
+ @cached_property
90
+ def description(self) -> str:
91
+ return self.client.description
66
92
 
67
- @abstractmethod
68
93
  @cached_property
69
- def stac_extensions(self) -> List[str]: ...
94
+ def stac_extensions(self) -> List[str]:
95
+ return self.client.stac_extensions
70
96
 
71
97
  @abstractmethod
72
98
  def search(
@@ -74,20 +100,17 @@ class CatalogSearcher(ABC):
74
100
  time: Optional[Union[TimeRange, List[TimeRange]]] = None,
75
101
  bounds: Optional[Bounds] = None,
76
102
  area: Optional[BaseGeometry] = None,
103
+ query: Optional[str] = None,
77
104
  search_kwargs: Optional[Dict[str, Any]] = None,
78
105
  ) -> Generator[Item, None, None]: ...
79
106
 
80
107
 
81
- class StaticCatalogWriterMixin(CatalogSearcher):
108
+ class StaticCollectionWriterMixin(CollectionSearcher):
82
109
  # client: Client
83
110
  # id: str
84
111
  # description: str
85
112
  # stac_extensions: List[str]
86
113
 
87
- @abstractmethod
88
- def get_collections(self) -> List[Collection]: # pragma: no cover
89
- ...
90
-
91
114
  def write_static_catalog(
92
115
  self,
93
116
  output_path: MPathLike,
@@ -113,94 +136,93 @@ class StaticCatalogWriterMixin(CatalogSearcher):
113
136
  catalog_json = output_path / "catalog.json"
114
137
  if catalog_json.exists():
115
138
  logger.debug("open existing catalog %s", str(catalog_json))
116
- client = Client.from_file(catalog_json)
117
- # catalog = pystac.Catalog.from_file(catalog_json)
118
- existing_collections = list(client.get_collections())
139
+ catalog = Catalog.from_file(catalog_json)
140
+ # client = Client.from_file(catalog_json)
141
+ # existing_collection = client.get_collection(self.id)
119
142
  else:
120
- existing_collections = []
121
- catalog = Catalog(
122
- name or f"{self.id}",
123
- description or f"Static subset of {self.description}",
124
- stac_extensions=self.stac_extensions,
125
- href=str(catalog_json),
126
- catalog_type=CatalogType.SELF_CONTAINED,
127
- )
143
+ # existing_collections = []
144
+ catalog = Catalog(
145
+ name or f"{self.id}",
146
+ description or f"Static subset of {self.description}",
147
+ stac_extensions=self.stac_extensions,
148
+ href=str(catalog_json),
149
+ catalog_type=CatalogType.SELF_CONTAINED,
150
+ )
128
151
  src_items = list(
129
152
  self.search(
130
153
  time=time, bounds=bounds, area=area, search_kwargs=search_kwargs
131
154
  )
132
155
  )
133
- for collection in self.get_collections():
134
- # collect all items and download assets if required
135
- items: List[Item] = []
136
- item_ids = set()
137
- for n, item in enumerate(src_items, 1):
138
- logger.debug("found item %s", item)
139
- item = item.clone()
140
- if assets:
141
- logger.debug("get assets %s", assets)
142
- item = get_assets(
143
- item,
144
- assets,
145
- output_path / collection.id / item.id,
146
- resolution=assets_dst_resolution,
147
- convert_profile=assets_convert_profile,
148
- overwrite=overwrite,
149
- ignore_if_exists=True,
150
- )
151
- if copy_metadata:
152
- item = get_metadata_assets(
153
- item,
154
- output_path / collection.id / item.id,
155
- metadata_parser_classes=metadata_parser_classes,
156
- resolution=assets_dst_resolution,
157
- convert_profile=assets_convert_profile,
158
- overwrite=overwrite,
159
- )
160
- # this has to be set to None, otherwise pystac will mess up the asset paths
161
- # after normalizing
162
- item.set_self_href(None)
163
-
164
- items.append(item)
165
- item_ids.add(item.id)
166
-
167
- if progress_callback:
168
- progress_callback(n=n, total=len(src_items))
169
-
170
- for existing_collection in existing_collections:
171
- if existing_collection.id == collection.id:
172
- logger.debug("try to find unregistered items in collection")
173
- collection_root_path = MPath.from_inp(
174
- existing_collection.get_self_href()
175
- ).parent
176
- for subpath in collection_root_path.ls():
177
- if subpath.is_directory():
178
- try:
179
- item = Item.from_file(
180
- subpath / subpath.with_suffix(".json").name
181
- )
182
- if item.id not in item_ids:
183
- logger.debug(
184
- "add existing item with id %s", item.id
185
- )
186
- items.append(item)
187
- item_ids.add(item.id)
188
- except FileNotFoundError:
189
- pass
190
- break
156
+ # collect all items and download assets if required
157
+ items: List[Item] = []
158
+ item_ids = set()
159
+ for n, item in enumerate(src_items, 1):
160
+ logger.debug("found item %s", item)
161
+ item = item.clone()
162
+ if assets:
163
+ logger.debug("get assets %s", assets)
164
+ item = get_assets(
165
+ item,
166
+ assets,
167
+ output_path / self.id / item.id,
168
+ resolution=assets_dst_resolution,
169
+ convert_profile=assets_convert_profile,
170
+ overwrite=overwrite,
171
+ ignore_if_exists=True,
172
+ )
173
+ if copy_metadata:
174
+ item = get_metadata_assets(
175
+ item,
176
+ output_path / self.id / item.id,
177
+ metadata_parser_classes=metadata_parser_classes,
178
+ resolution=assets_dst_resolution,
179
+ convert_profile=assets_convert_profile,
180
+ overwrite=overwrite,
181
+ )
182
+ # this has to be set to None, otherwise pystac will mess up the asset paths
183
+ # after normalizing
184
+ item.set_self_href(None)
185
+
186
+ items.append(item)
187
+ item_ids.add(item.id)
188
+
189
+ if progress_callback:
190
+ progress_callback(n=n, total=len(src_items))
191
+
192
+ # for existing_collection in existing_collections:
193
+ # if existing_collection.id == collection.id:
194
+ # logger.debug("try to find unregistered items in collection")
195
+ # collection_root_path = MPath.from_inp(
196
+ # existing_collection.get_self_href()
197
+ # ).parent
198
+ # for subpath in collection_root_path.ls():
199
+ # if subpath.is_directory():
200
+ # try:
201
+ # item = Item.from_file(
202
+ # subpath / subpath.with_suffix(".json").name
203
+ # )
204
+ # if item.id not in item_ids:
205
+ # logger.debug(
206
+ # "add existing item with id %s", item.id
207
+ # )
208
+ # items.append(item)
209
+ # item_ids.add(item.id)
210
+ # except FileNotFoundError:
211
+ # pass
212
+ # break
191
213
  # create collection and copy metadata
192
214
  logger.debug("create new collection")
193
215
  out_collection = Collection(
194
- id=collection.id,
216
+ id=self.id,
195
217
  extent=Extent.from_items(items),
196
- description=collection.description,
197
- title=collection.title,
198
- stac_extensions=collection.stac_extensions,
199
- license=collection.license,
200
- keywords=collection.keywords,
201
- providers=collection.providers,
202
- summaries=collection.summaries,
203
- extra_fields=collection.extra_fields,
218
+ description=self.description,
219
+ title=self.client.title,
220
+ stac_extensions=self.stac_extensions,
221
+ license=self.client.license,
222
+ keywords=self.client.keywords,
223
+ providers=self.client.providers,
224
+ summaries=self.client.summaries,
225
+ extra_fields=self.client.extra_fields,
204
226
  catalog_type=CatalogType.SELF_CONTAINED,
205
227
  )
206
228
 
@@ -222,14 +244,20 @@ class StaticCatalogWriterMixin(CatalogSearcher):
222
244
 
223
245
  def filter_items(
224
246
  items: Generator[Item, None, None],
225
- cloud_cover_field: str = "eo:cloud_cover",
226
- max_cloud_cover: float = 100.0,
247
+ query: Optional[str] = None,
227
248
  ) -> Generator[Item, None, None]:
228
249
  """
229
250
  Only for cloudcover now, this can and should be adapted for filter field and value
230
251
  the field and value for the item filter would be defined in search.config.py corresponding configs
231
252
  and passed down to the individual search approaches via said config and this Function.
232
253
  """
233
- for item in items:
234
- if item.properties.get(cloud_cover_field, 0.0) <= max_cloud_cover:
235
- yield item
254
+ if query:
255
+ ast = parse_ecql(query)
256
+ evaluator = NativeEvaluator(use_getattr=False)
257
+ filter_func = evaluator.evaluate(ast)
258
+ for item in items:
259
+ # pystac items store metadata in 'properties'
260
+ if filter_func(item.properties):
261
+ yield item
262
+ else:
263
+ yield from items
@@ -1,23 +1,48 @@
1
- from typing import Optional
1
+ import logging
2
+
3
+
4
+ from contextlib import contextmanager
5
+ from typing import Optional, Dict, Any
2
6
 
3
7
  from mapchete.path import MPath, MPathLike
4
- from pydantic import BaseModel
8
+ from pydantic import BaseModel, model_validator
5
9
 
6
10
 
7
11
  class StacSearchConfig(BaseModel):
8
12
  max_cloud_cover: float = 100.0
13
+ query: Optional[str] = None
9
14
  catalog_chunk_threshold: int = 10_000
10
15
  catalog_chunk_zoom: int = 5
11
16
  catalog_pagesize: int = 100
12
17
  footprint_buffer: float = 0
13
18
 
19
+ @model_validator(mode="before")
20
+ def deprecate_max_cloud_cover(cls, values: Dict[str, Any]) -> Dict[str, Any]:
21
+ if "max_cloud_cover" in values: # pragma: no cover
22
+ raise DeprecationWarning(
23
+ "'max_cloud_cover' will be deprecated soon. Please use 'eo:cloud_cover<=...' in the source 'query' field.",
24
+ )
25
+ return values
26
+
14
27
 
15
28
  class StacStaticConfig(BaseModel):
16
- max_cloud_cover: float = 100.0
29
+ @model_validator(mode="before")
30
+ def deprecate_max_cloud_cover(cls, values: Dict[str, Any]) -> Dict[str, Any]:
31
+ if "max_cloud_cover" in values: # pragma: no cover
32
+ raise DeprecationWarning(
33
+ "'max_cloud_cover' will be deprecated soon. Please use 'eo:cloud_cover<=...' in the source 'query' field.",
34
+ )
35
+ return values
17
36
 
18
37
 
19
38
  class UTMSearchConfig(BaseModel):
20
- max_cloud_cover: float = 100.0
39
+ @model_validator(mode="before")
40
+ def deprecate_max_cloud_cover(cls, values: Dict[str, Any]) -> Dict[str, Any]:
41
+ if "max_cloud_cover" in values: # pragma: no cover
42
+ raise DeprecationWarning(
43
+ "'max_cloud_cover' will be deprecated soon. Please use 'eo:cloud_cover<=...' in the source 'query' field.",
44
+ )
45
+ return values
21
46
 
22
47
  sinergise_aws_collections: dict = dict(
23
48
  S2_L2A=dict(
@@ -25,18 +50,64 @@ class UTMSearchConfig(BaseModel):
25
50
  path=MPath(
26
51
  "https://sentinel-s2-l2a-stac.s3.amazonaws.com/sentinel-s2-l2a.json"
27
52
  ),
53
+ endpoint="s3://sentinel-s2-l2a-stac",
28
54
  ),
29
55
  S2_L1C=dict(
30
56
  id="sentinel-s2-l1c",
31
57
  path=MPath(
32
58
  "https://sentinel-s2-l1c-stac.s3.amazonaws.com/sentinel-s2-l1c.json"
33
59
  ),
60
+ endpoint="s3://sentinel-s2-l1c-stac",
34
61
  ),
35
62
  S1_GRD=dict(
36
63
  id="sentinel-s1-l1c",
37
64
  path=MPath(
38
65
  "https://sentinel-s1-l1c-stac.s3.amazonaws.com/sentinel-s1-l1c.json"
39
66
  ),
67
+ endpoint="s3://sentinel-s1-l1c-stac",
40
68
  ),
41
69
  )
42
70
  search_index: Optional[MPathLike] = None
71
+
72
+
73
+ @contextmanager
74
+ def patch_invalid_assets():
75
+ """
76
+ Context manager/decorator to fix pystac crash on malformed assets (strings instead of dicts).
77
+
78
+ """
79
+ try:
80
+ from pystac.extensions.file import FileExtensionHooks
81
+ except ImportError: # pragma: no cover
82
+ yield
83
+ return
84
+
85
+ logger = logging.getLogger(__name__)
86
+
87
+ _original_migrate = FileExtensionHooks.migrate
88
+
89
+ def _safe_migrate(self, obj, version, info):
90
+ if "assets" in obj and isinstance(obj["assets"], dict):
91
+ bad_keys = []
92
+ for key, asset in obj["assets"].items():
93
+ if not isinstance(asset, dict):
94
+ logger.debug(
95
+ "Removing malformed asset '%s' (type %s) from item %s",
96
+ key,
97
+ type(asset),
98
+ obj.get("id", "unknown"),
99
+ )
100
+ bad_keys.append(key)
101
+
102
+ for key in bad_keys:
103
+ del obj["assets"][key]
104
+
105
+ return _original_migrate(self, obj, version, info)
106
+
107
+ # Apply patch
108
+ FileExtensionHooks.migrate = _safe_migrate
109
+ try:
110
+ yield
111
+ finally:
112
+ # Restore original
113
+ FileExtensionHooks.migrate = _original_migrate
@@ -6,18 +6,17 @@ from functools import cached_property
6
6
  from itertools import product
7
7
  from typing import List, Literal, Optional, Tuple, Union
8
8
 
9
- from mapchete.geometry import reproject_geometry
9
+ from mapchete.geometry import (
10
+ reproject_geometry,
11
+ repair_antimeridian_geometry,
12
+ transform_to_latlon,
13
+ )
10
14
  from mapchete.types import Bounds
11
15
  from rasterio.crs import CRS
12
16
  from shapely import prepare
13
17
  from shapely.geometry import box, mapping, shape
14
18
  from shapely.geometry.base import BaseGeometry
15
19
 
16
- from mapchete_eo.geometry import (
17
- bounds_to_geom,
18
- repair_antimeridian_geometry,
19
- transform_to_latlon,
20
- )
21
20
 
22
21
  LATLON_LEFT = -180
23
22
  LATLON_RIGHT = 180
@@ -255,7 +254,7 @@ class S2Tile:
255
254
  grid_square = tile_id[3:]
256
255
  try:
257
256
  int(utm_zone)
258
- except Exception:
257
+ except Exception: # pragma: no cover
259
258
  raise ValueError(f"invalid UTM zone given: {utm_zone}")
260
259
 
261
260
  return MGRSCell(utm_zone, latitude_band).tile(grid_square)
@@ -268,7 +267,7 @@ class S2Tile:
268
267
  def s2_tiles_from_bounds(
269
268
  left: float, bottom: float, right: float, top: float
270
269
  ) -> List[S2Tile]:
271
- bounds = Bounds(left, bottom, right, top)
270
+ bounds = Bounds(left, bottom, right, top, crs="EPSG:4326")
272
271
 
273
272
  # determine zones in eastern-western direction
274
273
  min_zone_idx = math.floor((left + LATLON_WIDTH_OFFSET) / UTM_ZONE_WIDTH)
@@ -291,7 +290,7 @@ def s2_tiles_from_bounds(
291
290
  min_latitude_band_idx -= 1
292
291
  max_latitude_band_idx += 1
293
292
 
294
- aoi = bounds_to_geom(bounds)
293
+ aoi = bounds.latlon_geometry()
295
294
  prepare(aoi)
296
295
 
297
296
  def tiles_generator():