mapchete-eo 2025.10.0__py2.py3-none-any.whl → 2025.11.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 (66) hide show
  1. mapchete_eo/__init__.py +1 -1
  2. mapchete_eo/array/convert.py +7 -1
  3. mapchete_eo/base.py +123 -55
  4. mapchete_eo/cli/options_arguments.py +11 -27
  5. mapchete_eo/cli/s2_brdf.py +1 -1
  6. mapchete_eo/cli/s2_cat_results.py +4 -20
  7. mapchete_eo/cli/s2_find_broken_products.py +4 -20
  8. mapchete_eo/cli/s2_jp2_static_catalog.py +2 -2
  9. mapchete_eo/cli/static_catalog.py +4 -45
  10. mapchete_eo/eostac.py +1 -1
  11. mapchete_eo/io/assets.py +7 -7
  12. mapchete_eo/io/items.py +37 -22
  13. mapchete_eo/io/levelled_cubes.py +66 -35
  14. mapchete_eo/io/path.py +19 -8
  15. mapchete_eo/io/products.py +37 -27
  16. mapchete_eo/platforms/sentinel2/__init__.py +1 -1
  17. mapchete_eo/platforms/sentinel2/_mapper_registry.py +89 -0
  18. mapchete_eo/platforms/sentinel2/brdf/correction.py +1 -1
  19. mapchete_eo/platforms/sentinel2/brdf/hls.py +1 -1
  20. mapchete_eo/platforms/sentinel2/brdf/models.py +1 -1
  21. mapchete_eo/platforms/sentinel2/brdf/protocols.py +1 -1
  22. mapchete_eo/platforms/sentinel2/brdf/ross_thick.py +1 -1
  23. mapchete_eo/platforms/sentinel2/brdf/sun_angle_arrays.py +1 -1
  24. mapchete_eo/platforms/sentinel2/config.py +73 -13
  25. mapchete_eo/platforms/sentinel2/driver.py +0 -39
  26. mapchete_eo/platforms/sentinel2/metadata_parser/__init__.py +6 -0
  27. mapchete_eo/platforms/sentinel2/{path_mappers → metadata_parser}/base.py +1 -1
  28. mapchete_eo/platforms/sentinel2/{path_mappers/metadata_xml.py → metadata_parser/default_path_mapper.py} +2 -2
  29. mapchete_eo/platforms/sentinel2/metadata_parser/models.py +78 -0
  30. mapchete_eo/platforms/sentinel2/{metadata_parser.py → metadata_parser/s2metadata.py} +51 -146
  31. mapchete_eo/platforms/sentinel2/preconfigured_sources/__init__.py +57 -0
  32. mapchete_eo/platforms/sentinel2/preconfigured_sources/guessers.py +108 -0
  33. mapchete_eo/platforms/sentinel2/preconfigured_sources/item_mappers.py +171 -0
  34. mapchete_eo/platforms/sentinel2/preconfigured_sources/metadata_xml_mappers.py +217 -0
  35. mapchete_eo/platforms/sentinel2/preprocessing_tasks.py +22 -1
  36. mapchete_eo/platforms/sentinel2/processing_baseline.py +3 -0
  37. mapchete_eo/platforms/sentinel2/product.py +88 -23
  38. mapchete_eo/platforms/sentinel2/source.py +114 -0
  39. mapchete_eo/platforms/sentinel2/types.py +5 -0
  40. mapchete_eo/processes/merge_rasters.py +7 -3
  41. mapchete_eo/product.py +14 -9
  42. mapchete_eo/protocols.py +5 -0
  43. mapchete_eo/search/__init__.py +3 -3
  44. mapchete_eo/search/base.py +126 -100
  45. mapchete_eo/search/config.py +25 -4
  46. mapchete_eo/search/s2_mgrs.py +8 -9
  47. mapchete_eo/search/stac_search.py +111 -75
  48. mapchete_eo/search/stac_static.py +63 -94
  49. mapchete_eo/search/utm_search.py +39 -48
  50. mapchete_eo/settings.py +1 -0
  51. mapchete_eo/sort.py +16 -2
  52. mapchete_eo/source.py +107 -0
  53. {mapchete_eo-2025.10.0.dist-info → mapchete_eo-2025.11.0.dist-info}/METADATA +2 -1
  54. mapchete_eo-2025.11.0.dist-info/RECORD +89 -0
  55. {mapchete_eo-2025.10.0.dist-info → mapchete_eo-2025.11.0.dist-info}/entry_points.txt +1 -1
  56. mapchete_eo/archives/__init__.py +0 -0
  57. mapchete_eo/archives/base.py +0 -65
  58. mapchete_eo/geometry.py +0 -271
  59. mapchete_eo/known_catalogs.py +0 -42
  60. mapchete_eo/platforms/sentinel2/archives.py +0 -190
  61. mapchete_eo/platforms/sentinel2/path_mappers/__init__.py +0 -29
  62. mapchete_eo/platforms/sentinel2/path_mappers/earthsearch.py +0 -34
  63. mapchete_eo/platforms/sentinel2/path_mappers/sinergise.py +0 -105
  64. mapchete_eo-2025.10.0.dist-info/RECORD +0 -88
  65. {mapchete_eo-2025.10.0.dist-info → mapchete_eo-2025.11.0.dist-info}/WHEEL +0 -0
  66. {mapchete_eo-2025.10.0.dist-info → mapchete_eo-2025.11.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,89 @@
1
+ from typing import List, Callable, Dict, Any, Optional
2
+
3
+ from pystac import Item
4
+
5
+ from mapchete_eo.platforms.sentinel2.metadata_parser.s2metadata import S2Metadata
6
+ from mapchete_eo.platforms.sentinel2.types import DataArchive, MetadataArchive
7
+
8
+
9
+ # decorators for mapper functions using the registry pattern #
10
+ ##############################################################
11
+ ID_MAPPER_REGISTRY: Dict[Any, Callable[[Item], Item]] = {}
12
+ STAC_METADATA_MAPPER_REGISTRY: Dict[Any, Callable[[Item], Item]] = {}
13
+ S2METADATA_MAPPER_REGISTRY: Dict[Any, Callable[[Item], S2Metadata]] = {}
14
+
15
+ MAPPER_REGISTRIES: Dict[str, Any] = {
16
+ "ID": ID_MAPPER_REGISTRY,
17
+ "STAC metadata": STAC_METADATA_MAPPER_REGISTRY,
18
+ "S2Metadata": S2METADATA_MAPPER_REGISTRY,
19
+ }
20
+
21
+
22
+ def _register_func(registry: Dict[str, Callable], key: Any, func: Callable):
23
+ if key in registry:
24
+ raise ValueError(f"{key} already registered in {registry}")
25
+ registry[key] = func
26
+
27
+
28
+ def maps_item_id(from_collections: List[str]):
29
+ """
30
+ Decorator registering mapper to common ID.
31
+ """
32
+
33
+ def decorator(func):
34
+ # Use a tuple of the metadata as the key
35
+ # key = (path_type, version)
36
+ for collection in from_collections:
37
+ _register_func(registry=ID_MAPPER_REGISTRY, key=collection, func=func)
38
+ return func
39
+
40
+ return decorator
41
+
42
+
43
+ def maps_stac_metadata(
44
+ from_collections: List[str], to_data_archives: Optional[List[DataArchive]] = None
45
+ ):
46
+ """
47
+ Decorator registering STAC metadata mapper.
48
+ """
49
+
50
+ def decorator(func):
51
+ # Use a tuple of the metadata as the key
52
+ for collection in from_collections:
53
+ if to_data_archives:
54
+ for data_archive in to_data_archives:
55
+ _register_func(
56
+ registry=STAC_METADATA_MAPPER_REGISTRY,
57
+ key=(collection, data_archive),
58
+ func=func,
59
+ )
60
+ else:
61
+ _register_func(
62
+ registry=STAC_METADATA_MAPPER_REGISTRY,
63
+ key=collection,
64
+ func=func,
65
+ )
66
+ return func
67
+
68
+ return decorator
69
+
70
+
71
+ def creates_s2metadata(
72
+ from_collections: List[str], to_metadata_archives: List[MetadataArchive]
73
+ ):
74
+ """
75
+ Decorator registering S2Metadata creator.
76
+ """
77
+
78
+ def decorator(func):
79
+ # Use a tuple of the metadata as the key
80
+ for collection in from_collections:
81
+ for metadata_archive in to_metadata_archives:
82
+ _register_func(
83
+ registry=S2METADATA_MAPPER_REGISTRY,
84
+ key=(collection, metadata_archive),
85
+ func=func,
86
+ )
87
+ return func
88
+
89
+ return decorator
@@ -13,7 +13,7 @@ from rasterio.fill import fillnodata
13
13
 
14
14
  from mapchete_eo.exceptions import BRDFError
15
15
  from mapchete_eo.platforms.sentinel2.brdf.models import BRDFModels, get_model
16
- from mapchete_eo.platforms.sentinel2.metadata_parser import S2Metadata
16
+ from mapchete_eo.platforms.sentinel2.metadata_parser.s2metadata import S2Metadata
17
17
  from mapchete_eo.platforms.sentinel2.types import (
18
18
  L2ABand,
19
19
  Resolution,
@@ -16,7 +16,7 @@ from mapchete_eo.platforms.sentinel2.brdf.protocols import (
16
16
  )
17
17
  from mapchete_eo.platforms.sentinel2.brdf.config import L2ABandFParams, ModelParameters
18
18
  from mapchete_eo.platforms.sentinel2.brdf.sun_angle_arrays import get_sun_zenith_angles
19
- from mapchete_eo.platforms.sentinel2.metadata_parser import S2Metadata
19
+ from mapchete_eo.platforms.sentinel2.metadata_parser.s2metadata import S2Metadata
20
20
  from mapchete_eo.platforms.sentinel2.types import L2ABand
21
21
 
22
22
 
@@ -12,7 +12,7 @@ from mapchete_eo.platforms.sentinel2.brdf.hls import HLS
12
12
  from mapchete_eo.platforms.sentinel2.brdf.ross_thick import RossThick
13
13
 
14
14
  # from mapchete_eo.platforms.sentinel2.brdf.hls2 import HLS2
15
- from mapchete_eo.platforms.sentinel2.metadata_parser import S2Metadata
15
+ from mapchete_eo.platforms.sentinel2.metadata_parser.s2metadata import S2Metadata
16
16
  from mapchete_eo.platforms.sentinel2.types import L2ABand
17
17
 
18
18
  logger = logging.getLogger(__name__)
@@ -6,7 +6,7 @@ from mapchete.io.raster import ReferencedRaster
6
6
  import numpy as np
7
7
  from numpy.typing import DTypeLike
8
8
 
9
- from mapchete_eo.platforms.sentinel2.metadata_parser import S2Metadata
9
+ from mapchete_eo.platforms.sentinel2.metadata_parser.s2metadata import S2Metadata
10
10
  from mapchete_eo.platforms.sentinel2.types import L2ABand
11
11
 
12
12
 
@@ -14,7 +14,7 @@ from mapchete_eo.platforms.sentinel2.brdf.protocols import (
14
14
  )
15
15
  from mapchete_eo.platforms.sentinel2.brdf.config import L2ABandFParams, ModelParameters
16
16
  from mapchete_eo.platforms.sentinel2.brdf.hls import _get_viewing_angles
17
- from mapchete_eo.platforms.sentinel2.metadata_parser import S2Metadata
17
+ from mapchete_eo.platforms.sentinel2.metadata_parser.s2metadata import S2Metadata
18
18
  from mapchete_eo.platforms.sentinel2.types import L2ABand
19
19
 
20
20
 
@@ -3,7 +3,7 @@ from typing import Tuple
3
3
  from fiona.transform import transform
4
4
  import numpy as np
5
5
 
6
- from mapchete_eo.platforms.sentinel2.metadata_parser import S2Metadata
6
+ from mapchete_eo.platforms.sentinel2.metadata_parser.s2metadata import S2Metadata
7
7
 
8
8
 
9
9
  def get_sun_zenith_angles(s2_metadata: S2Metadata) -> np.ndarray:
@@ -1,18 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import List, Optional, Union
3
+ from typing import List, Optional, Union, Dict, Any
4
+ import warnings
4
5
 
5
6
  from mapchete.path import MPathLike
6
- from pydantic import (
7
- BaseModel,
8
- ValidationError,
9
- field_validator,
10
- )
7
+ from pydantic import BaseModel, ValidationError, field_validator, model_validator
11
8
 
12
9
  from mapchete_eo.base import BaseDriverConfig
13
10
  from mapchete_eo.io.path import ProductPathGenerationMethod
14
- from mapchete_eo.platforms.sentinel2.archives import ArchiveClsFromString, AWSL2ACOGv1
15
11
  from mapchete_eo.platforms.sentinel2.brdf.config import BRDFModels
12
+ from mapchete_eo.platforms.sentinel2.preconfigured_sources import (
13
+ KNOWN_SOURCES,
14
+ DEPRECATED_ARCHIVES,
15
+ )
16
+ from mapchete_eo.platforms.sentinel2.source import Sentinel2Source
16
17
  from mapchete_eo.platforms.sentinel2.types import (
17
18
  CloudType,
18
19
  ProductQIMaskResolution,
@@ -23,6 +24,9 @@ from mapchete_eo.search.config import StacSearchConfig
23
24
  from mapchete_eo.types import TimeRange
24
25
 
25
26
 
27
+ default_source = Sentinel2Source.model_validate(KNOWN_SOURCES["EarthSearch"])
28
+
29
+
26
30
  class BRDFModelConfig(BaseModel):
27
31
  model: BRDFModels = BRDFModels.HLS
28
32
  bands: List[str] = ["blue", "green", "red", "nir"]
@@ -47,7 +51,7 @@ class BRDFSCLClassConfig(BRDFModelConfig):
47
51
  out.append(value)
48
52
  elif isinstance(value, str):
49
53
  out.append(SceneClassification[value])
50
- else:
54
+ else: # pragma: no cover
51
55
  raise ValidationError("value must be mappable to SceneClassification")
52
56
  return out
53
57
 
@@ -107,10 +111,18 @@ class CacheConfig(BaseModel):
107
111
  class Sentinel2DriverConfig(BaseDriverConfig):
108
112
  format: str = "Sentinel-2"
109
113
  time: Union[TimeRange, List[TimeRange]]
110
- archive: ArchiveClsFromString = AWSL2ACOGv1
111
- cat_baseurl: Optional[MPathLike] = None
114
+
115
+ # new
116
+ source: List[Sentinel2Source] = [default_source]
117
+
118
+ # deprecated
119
+ # for backwards compatibility, archive should be converted to
120
+ # catalog & data_archive
121
+ # archive: ArchiveClsFromString = AWSL2ACOGv1
122
+ # cat_baseurl: Optional[MPathLike] = None
112
123
  search_index: Optional[MPathLike] = None
113
- max_cloud_cover: float = 100.0
124
+
125
+ # custom params
114
126
  stac_config: StacSearchConfig = StacSearchConfig()
115
127
  first_granule_only: bool = False
116
128
  utm_zone: Optional[int] = None
@@ -118,6 +130,54 @@ class Sentinel2DriverConfig(BaseDriverConfig):
118
130
  brdf: Optional[BRDFConfig] = None
119
131
  cache: Optional[CacheConfig] = None
120
132
 
133
+ @model_validator(mode="before")
134
+ def deprecated_values(cls, values: Dict[str, Any]) -> Dict[str, Any]:
135
+ archive = values.pop("archive", None)
136
+ if archive:
137
+ warnings.warn(
138
+ "'archive' will be deprecated soon. Please use 'source'.",
139
+ category=DeprecationWarning,
140
+ stacklevel=2,
141
+ )
142
+ if values.get("source") is None:
143
+ values["source"] = DEPRECATED_ARCHIVES[archive]
144
+
145
+ cat_baseurl = values.pop("cat_baseurl", None)
146
+ if cat_baseurl: # pragma: no cover
147
+ warnings.warn(
148
+ "'cat_baseurl' will be deprecated soon. Please use 'catalog_type=static' in the source.",
149
+ category=DeprecationWarning,
150
+ stacklevel=2,
151
+ )
152
+ if values.get("source", []):
153
+ raise ValueError(
154
+ "deprecated cat_baseurl field found alongside sources."
155
+ )
156
+ values["source"] = [dict(collection=cat_baseurl, catalog_type="static")]
157
+
158
+ # add default source if necessary
159
+ sources = values.get("source", [])
160
+ if not sources:
161
+ values["source"] = [default_source.model_dump(exclude_none=True)]
162
+
163
+ max_cloud_cover = values.pop("max_cloud_cover", None)
164
+ if max_cloud_cover: # pragma: no cover
165
+ warnings.warn(
166
+ "'max_cloud_cover' will be deprecated soon. Please use 'eo:cloud_cover<=...' in the source 'query' field.",
167
+ category=DeprecationWarning,
168
+ stacklevel=2,
169
+ )
170
+ updated_sources = []
171
+ for source in values.get("source", []):
172
+ if source.get("query") is not None:
173
+ raise ValueError(
174
+ f"deprecated max_cloud_cover is set but also a query field is given in {source}"
175
+ )
176
+ source["query"] = f"eo:cloud_cover<={max_cloud_cover}"
177
+ updated_sources.append(source)
178
+ values["source"] = updated_sources
179
+ return values
180
+
121
181
 
122
182
  class MaskConfig(BaseModel):
123
183
  # mask by footprint geometry
@@ -160,7 +220,7 @@ class MaskConfig(BaseModel):
160
220
  out.append(value)
161
221
  elif isinstance(value, str):
162
222
  out.append(SceneClassification[value])
163
- else:
223
+ else: # pragma: no cover
164
224
  raise ValidationError("value must be mappable to SceneClassification")
165
225
  return out
166
226
 
@@ -175,7 +235,7 @@ class MaskConfig(BaseModel):
175
235
  elif isinstance(config, dict):
176
236
  return MaskConfig(**config)
177
237
 
178
- else:
238
+ else: # pragma: no cover
179
239
  raise TypeError(
180
240
  f"mask configuration should either be a dictionary or a MaskConfig object, not {config}"
181
241
  )
@@ -1,16 +1,11 @@
1
1
  from typing import Optional, List, Tuple
2
2
 
3
- from mapchete.geometry import reproject_geometry
4
- from mapchete.path import MPath
5
3
  from mapchete.types import NodataVal
6
4
  from rasterio.enums import Resampling
7
5
 
8
6
  from mapchete_eo import base
9
- from mapchete_eo.archives.base import Archive
10
7
  from mapchete_eo.platforms.sentinel2.config import Sentinel2DriverConfig
11
8
  from mapchete_eo.platforms.sentinel2.preprocessing_tasks import parse_s2_product
12
- from mapchete_eo.search.stac_static import STACStaticCatalog
13
- from mapchete_eo.settings import mapchete_eo_settings
14
9
  from mapchete_eo.types import MergeMethod
15
10
 
16
11
  METADATA: dict = {
@@ -42,37 +37,3 @@ class InputData(base.InputData):
42
37
  driver_config_model = Sentinel2DriverConfig
43
38
  params: Sentinel2DriverConfig
44
39
  input_tile_cls = Sentinel2Cube
45
-
46
- def set_archive(self, base_dir: MPath):
47
- if self.params.cat_baseurl:
48
- self.archive = Archive(
49
- catalog=STACStaticCatalog(
50
- baseurl=MPath(self.params.cat_baseurl).absolute_path(
51
- base_dir=base_dir
52
- ),
53
- ),
54
- area=self.bbox(mapchete_eo_settings.default_catalog_crs),
55
- time=self.time,
56
- search_kwargs=dict(max_cloud_cover=self.params.max_cloud_cover),
57
- )
58
- elif self.params.archive:
59
- catalog_area = reproject_geometry(
60
- self.area,
61
- src_crs=self.crs,
62
- dst_crs=mapchete_eo_settings.default_catalog_crs,
63
- )
64
- self.archive = self.params.archive(
65
- time=self.time,
66
- bounds=catalog_area.bounds,
67
- area=catalog_area,
68
- search_kwargs=dict(
69
- search_index=(
70
- MPath(self.params.search_index).absolute_path(base_dir=base_dir)
71
- if self.params.search_index
72
- else None
73
- ),
74
- max_cloud_cover=self.params.max_cloud_cover,
75
- ),
76
- )
77
- else:
78
- raise ValueError("either 'archive' or 'cat_baseurl' or both is required.")
@@ -0,0 +1,6 @@
1
+ from mapchete_eo.platforms.sentinel2.metadata_parser.s2metadata import (
2
+ S2Metadata,
3
+ S2MetadataPathMapper,
4
+ )
5
+
6
+ __all__ = ["S2Metadata", "S2MetadataPathMapper"]
@@ -11,7 +11,7 @@ from mapchete_eo.platforms.sentinel2.types import (
11
11
  )
12
12
 
13
13
 
14
- class S2PathMapper(ABC):
14
+ class S2MetadataPathMapper(ABC):
15
15
  """
16
16
  Abstract class to help mapping asset paths from metadata.xml to their
17
17
  locations of various data archives.
@@ -11,7 +11,7 @@ from typing import Optional
11
11
  from mapchete.path import MPath
12
12
 
13
13
  from mapchete_eo.io import open_xml
14
- from mapchete_eo.platforms.sentinel2.path_mappers.base import S2PathMapper
14
+ from mapchete_eo.platforms.sentinel2.metadata_parser.base import S2MetadataPathMapper
15
15
  from mapchete_eo.platforms.sentinel2.processing_baseline import ProcessingBaseline
16
16
  from mapchete_eo.platforms.sentinel2.types import (
17
17
  BandQI,
@@ -23,7 +23,7 @@ from mapchete_eo.platforms.sentinel2.types import (
23
23
  logger = logging.getLogger(__name__)
24
24
 
25
25
 
26
- class XMLMapper(S2PathMapper):
26
+ class XMLMapper(S2MetadataPathMapper):
27
27
  def __init__(
28
28
  self, metadata_xml: MPath, xml_root: Optional[Element] = None, **kwargs
29
29
  ):
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import warnings
5
+ from typing import Dict
6
+
7
+ import numpy as np
8
+ import numpy.ma as ma
9
+ from pydantic import BaseModel
10
+ from mapchete.io.raster import ReferencedRaster
11
+ from rasterio.fill import fillnodata
12
+
13
+ from mapchete_eo.exceptions import CorruptedProductMetadata
14
+ from mapchete_eo.platforms.sentinel2.types import (
15
+ SunAngle,
16
+ ViewAngle,
17
+ )
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class SunAngleData(BaseModel):
23
+ model_config = dict(arbitrary_types_allowed=True)
24
+ raster: ReferencedRaster
25
+ mean: float
26
+
27
+
28
+ class SunAnglesData(BaseModel):
29
+ azimuth: SunAngleData
30
+ zenith: SunAngleData
31
+
32
+ def get_angle(self, angle: SunAngle) -> SunAngleData:
33
+ if angle == SunAngle.azimuth:
34
+ return self.azimuth
35
+ elif angle == SunAngle.zenith:
36
+ return self.zenith
37
+ else:
38
+ raise KeyError(f"unknown angle: {angle}")
39
+
40
+
41
+ class ViewingIncidenceAngle(BaseModel):
42
+ model_config = dict(arbitrary_types_allowed=True)
43
+ detectors: Dict[int, ReferencedRaster]
44
+ mean: float
45
+
46
+ def merge_detectors(
47
+ self, fill_edges: bool = True, smoothing_iterations: int = 3
48
+ ) -> ReferencedRaster:
49
+ if not self.detectors:
50
+ raise CorruptedProductMetadata("no viewing incidence angles available")
51
+ sample = next(iter(self.detectors.values()))
52
+ with warnings.catch_warnings():
53
+ warnings.simplefilter("ignore", category=RuntimeWarning)
54
+ merged = np.nanmean(
55
+ np.stack([raster.data for raster in self.detectors.values()]), axis=0
56
+ )
57
+ if fill_edges:
58
+ merged = fillnodata(
59
+ ma.masked_invalid(merged), smoothing_iterations=smoothing_iterations
60
+ )
61
+ return ReferencedRaster.from_array_like(
62
+ array_like=ma.masked_invalid(merged),
63
+ transform=sample.transform,
64
+ crs=sample.crs,
65
+ )
66
+
67
+
68
+ class ViewingIncidenceAngles(BaseModel):
69
+ azimuth: ViewingIncidenceAngle
70
+ zenith: ViewingIncidenceAngle
71
+
72
+ def get_angle(self, angle: ViewAngle) -> ViewingIncidenceAngle:
73
+ if angle == ViewAngle.azimuth:
74
+ return self.azimuth
75
+ elif angle == ViewAngle.zenith:
76
+ return self.zenith
77
+ else:
78
+ raise KeyError(f"unknown angle: {angle}")