pycontrails 0.54.0__cp312-cp312-macosx_10_13_x86_64.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.

Potentially problematic release.


This version of pycontrails might be problematic. Click here for more details.

Files changed (109) hide show
  1. pycontrails/__init__.py +70 -0
  2. pycontrails/_version.py +16 -0
  3. pycontrails/core/__init__.py +30 -0
  4. pycontrails/core/aircraft_performance.py +641 -0
  5. pycontrails/core/airports.py +226 -0
  6. pycontrails/core/cache.py +881 -0
  7. pycontrails/core/coordinates.py +174 -0
  8. pycontrails/core/fleet.py +470 -0
  9. pycontrails/core/flight.py +2314 -0
  10. pycontrails/core/flightplan.py +220 -0
  11. pycontrails/core/fuel.py +140 -0
  12. pycontrails/core/interpolation.py +721 -0
  13. pycontrails/core/met.py +2833 -0
  14. pycontrails/core/met_var.py +307 -0
  15. pycontrails/core/models.py +1181 -0
  16. pycontrails/core/polygon.py +549 -0
  17. pycontrails/core/rgi_cython.cpython-312-darwin.so +0 -0
  18. pycontrails/core/vector.py +2190 -0
  19. pycontrails/datalib/__init__.py +12 -0
  20. pycontrails/datalib/_leo_utils/search.py +250 -0
  21. pycontrails/datalib/_leo_utils/static/bq_roi_query.sql +6 -0
  22. pycontrails/datalib/_leo_utils/vis.py +59 -0
  23. pycontrails/datalib/_met_utils/metsource.py +746 -0
  24. pycontrails/datalib/ecmwf/__init__.py +73 -0
  25. pycontrails/datalib/ecmwf/arco_era5.py +340 -0
  26. pycontrails/datalib/ecmwf/common.py +109 -0
  27. pycontrails/datalib/ecmwf/era5.py +550 -0
  28. pycontrails/datalib/ecmwf/era5_model_level.py +487 -0
  29. pycontrails/datalib/ecmwf/hres.py +782 -0
  30. pycontrails/datalib/ecmwf/hres_model_level.py +459 -0
  31. pycontrails/datalib/ecmwf/ifs.py +284 -0
  32. pycontrails/datalib/ecmwf/model_levels.py +434 -0
  33. pycontrails/datalib/ecmwf/static/model_level_dataframe_v20240418.csv +139 -0
  34. pycontrails/datalib/ecmwf/variables.py +267 -0
  35. pycontrails/datalib/gfs/__init__.py +28 -0
  36. pycontrails/datalib/gfs/gfs.py +646 -0
  37. pycontrails/datalib/gfs/variables.py +100 -0
  38. pycontrails/datalib/goes.py +772 -0
  39. pycontrails/datalib/landsat.py +569 -0
  40. pycontrails/datalib/sentinel.py +511 -0
  41. pycontrails/datalib/spire.py +739 -0
  42. pycontrails/ext/bada.py +41 -0
  43. pycontrails/ext/cirium.py +14 -0
  44. pycontrails/ext/empirical_grid.py +140 -0
  45. pycontrails/ext/synthetic_flight.py +430 -0
  46. pycontrails/models/__init__.py +1 -0
  47. pycontrails/models/accf.py +406 -0
  48. pycontrails/models/apcemm/__init__.py +8 -0
  49. pycontrails/models/apcemm/apcemm.py +982 -0
  50. pycontrails/models/apcemm/inputs.py +226 -0
  51. pycontrails/models/apcemm/static/apcemm_yaml_template.yaml +183 -0
  52. pycontrails/models/apcemm/utils.py +437 -0
  53. pycontrails/models/cocip/__init__.py +29 -0
  54. pycontrails/models/cocip/cocip.py +2616 -0
  55. pycontrails/models/cocip/cocip_params.py +299 -0
  56. pycontrails/models/cocip/cocip_uncertainty.py +285 -0
  57. pycontrails/models/cocip/contrail_properties.py +1517 -0
  58. pycontrails/models/cocip/output_formats.py +2261 -0
  59. pycontrails/models/cocip/radiative_forcing.py +1262 -0
  60. pycontrails/models/cocip/radiative_heating.py +520 -0
  61. pycontrails/models/cocip/unterstrasser_wake_vortex.py +403 -0
  62. pycontrails/models/cocip/wake_vortex.py +396 -0
  63. pycontrails/models/cocip/wind_shear.py +120 -0
  64. pycontrails/models/cocipgrid/__init__.py +9 -0
  65. pycontrails/models/cocipgrid/cocip_grid.py +2573 -0
  66. pycontrails/models/cocipgrid/cocip_grid_params.py +138 -0
  67. pycontrails/models/dry_advection.py +494 -0
  68. pycontrails/models/emissions/__init__.py +21 -0
  69. pycontrails/models/emissions/black_carbon.py +594 -0
  70. pycontrails/models/emissions/emissions.py +1353 -0
  71. pycontrails/models/emissions/ffm2.py +336 -0
  72. pycontrails/models/emissions/static/default-engine-uids.csv +239 -0
  73. pycontrails/models/emissions/static/edb-gaseous-v29b-engines.csv +596 -0
  74. pycontrails/models/emissions/static/edb-nvpm-v29b-engines.csv +215 -0
  75. pycontrails/models/humidity_scaling/__init__.py +37 -0
  76. pycontrails/models/humidity_scaling/humidity_scaling.py +1025 -0
  77. pycontrails/models/humidity_scaling/quantiles/era5-model-level-quantiles.pq +0 -0
  78. pycontrails/models/humidity_scaling/quantiles/era5-pressure-level-quantiles.pq +0 -0
  79. pycontrails/models/issr.py +210 -0
  80. pycontrails/models/pcc.py +327 -0
  81. pycontrails/models/pcr.py +154 -0
  82. pycontrails/models/ps_model/__init__.py +17 -0
  83. pycontrails/models/ps_model/ps_aircraft_params.py +376 -0
  84. pycontrails/models/ps_model/ps_grid.py +505 -0
  85. pycontrails/models/ps_model/ps_model.py +1017 -0
  86. pycontrails/models/ps_model/ps_operational_limits.py +540 -0
  87. pycontrails/models/ps_model/static/ps-aircraft-params-20240524.csv +68 -0
  88. pycontrails/models/ps_model/static/ps-synonym-list-20240524.csv +103 -0
  89. pycontrails/models/sac.py +459 -0
  90. pycontrails/models/tau_cirrus.py +168 -0
  91. pycontrails/physics/__init__.py +1 -0
  92. pycontrails/physics/constants.py +116 -0
  93. pycontrails/physics/geo.py +989 -0
  94. pycontrails/physics/jet.py +837 -0
  95. pycontrails/physics/thermo.py +451 -0
  96. pycontrails/physics/units.py +472 -0
  97. pycontrails/py.typed +0 -0
  98. pycontrails/utils/__init__.py +1 -0
  99. pycontrails/utils/dependencies.py +66 -0
  100. pycontrails/utils/iteration.py +13 -0
  101. pycontrails/utils/json.py +188 -0
  102. pycontrails/utils/temp.py +50 -0
  103. pycontrails/utils/types.py +165 -0
  104. pycontrails-0.54.0.dist-info/LICENSE +178 -0
  105. pycontrails-0.54.0.dist-info/METADATA +179 -0
  106. pycontrails-0.54.0.dist-info/NOTICE +43 -0
  107. pycontrails-0.54.0.dist-info/RECORD +109 -0
  108. pycontrails-0.54.0.dist-info/WHEEL +5 -0
  109. pycontrails-0.54.0.dist-info/top_level.txt +3 -0
@@ -0,0 +1,511 @@
1
+ """Support for Sentinel-2 imagery retrieval through Google Cloud Platform."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pathlib
6
+ from collections.abc import Iterable
7
+ from xml.etree import ElementTree
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+ import xarray as xr
12
+
13
+ from pycontrails.core import Flight, cache
14
+ from pycontrails.datalib._leo_utils import search
15
+ from pycontrails.datalib._leo_utils.vis import equalize, normalize
16
+ from pycontrails.utils import dependencies
17
+
18
+ try:
19
+ import gcsfs
20
+ except ModuleNotFoundError as exc:
21
+ dependencies.raise_module_not_found_error(
22
+ name="sentinel module",
23
+ package_name="gcsfs",
24
+ module_not_found_error=exc,
25
+ pycontrails_optional_package="sat",
26
+ )
27
+
28
+ try:
29
+ import pyproj
30
+ except ModuleNotFoundError as exc:
31
+ dependencies.raise_module_not_found_error(
32
+ name="sentinel module",
33
+ package_name="pyproj",
34
+ module_not_found_error=exc,
35
+ pycontrails_optional_package="sat",
36
+ )
37
+
38
+ try:
39
+ from PIL import Image
40
+ except ModuleNotFoundError as exc:
41
+ dependencies.raise_module_not_found_error(
42
+ name="sentinel module",
43
+ package_name="pillow",
44
+ module_not_found_error=exc,
45
+ pycontrails_optional_package="sat",
46
+ )
47
+
48
+
49
+ _path_to_static = pathlib.Path(__file__).parent / "static"
50
+ ROI_QUERY_FILENAME = _path_to_static / "sentinel_roi_query.sql"
51
+
52
+ #: BigQuery table with imagery metadata
53
+ BQ_TABLE = "bigquery-public-data.cloud_storage_geo_index.sentinel_2_index"
54
+
55
+ #: Default columns to include in queries
56
+ BQ_DEFAULT_COLUMNS = ["base_url", "granule_id", "sensing_time"]
57
+
58
+ #: Default spatial extent for queries
59
+ BQ_DEFAULT_EXTENT = search.GLOBAL_EXTENT
60
+
61
+ #: Default Sentinel channels to use if none are specified.
62
+ #: These are visible bands for producing a true color composite.
63
+ DEFAULT_BANDS = ["B02", "B03", "B04"]
64
+
65
+ #: Strip this prefix from GCP URLs when caching Sentinel files locally
66
+ GCP_STRIP_PREFIX = "gs://gcp-public-data-sentinel-2/"
67
+
68
+
69
+ def query(
70
+ start_time: np.datetime64,
71
+ end_time: np.datetime64,
72
+ extent: str | None = None,
73
+ columns: list[str] | None = None,
74
+ ) -> pd.DataFrame:
75
+ """Find Sentinel-2 imagery within spatiotemporal region of interest.
76
+
77
+ This function requires access to the
78
+ `Google BigQuery API <https://cloud.google.com/bigquery?hl=en>`__
79
+ and uses the `BigQuery python library <https://cloud.google.com/python/docs/reference/bigquery/latest/index.html>`__.
80
+
81
+ Parameters
82
+ ----------
83
+ start_time : np.datetime64
84
+ Start of time period for search
85
+ end_time : np.datetime64
86
+ End of time period for search
87
+ extent : str, optional
88
+ Spatial region of interest as a GeoJSON string. If not provided, defaults
89
+ to a global extent.
90
+ columns : list[str], optional
91
+ Columns to return from Google
92
+ `BigQuery table <https://console.cloud.google.com/bigquery?p=bigquery-public-data&d=cloud_storage_geo_index&t=landsat_index&page=table&_ga=2.90807450.1051800793.1716904050-255800408.1705955196>`__.
93
+ By default, returns imagery base URL, granule ID, and sensing time.
94
+
95
+ Returns
96
+ -------
97
+ pd.DataFrame
98
+ Query results in pandas DataFrame
99
+
100
+ See Also
101
+ --------
102
+ :func:`search.query`
103
+ """
104
+ extent = extent or BQ_DEFAULT_EXTENT
105
+ roi = search.ROI(start_time, end_time, extent)
106
+ columns = columns or BQ_DEFAULT_COLUMNS
107
+ return search.query(BQ_TABLE, roi, columns)
108
+
109
+
110
+ def intersect(
111
+ flight: Flight,
112
+ columns: list[str] | None = None,
113
+ ) -> pd.DataFrame:
114
+ """Find Sentinel-2 imagery intersecting with flight track.
115
+
116
+ This function will return all scenes with a bounding box that includes flight waypoints
117
+ both before and after the sensing time.
118
+
119
+ This function requires access to the
120
+ `Google BigQuery API <https://cloud.google.com/bigquery?hl=en>`__
121
+ and uses the `BigQuery python library <https://cloud.google.com/python/docs/reference/bigquery/latest/index.html>`__.
122
+
123
+ Parameters
124
+ ----------
125
+ flight : Flight
126
+ Flight for intersection
127
+ columns : list[str], optional.
128
+ Columns to return from Google
129
+ `BigQuery table <https://console.cloud.google.com/bigquery?p=bigquery-public-data&d=cloud_storage_geo_index&t=landsat_index&page=table&_ga=2.90807450.1051800793.1716904050-255800408.1705955196>`__.
130
+ By default, returns imagery base URL, granule ID, and sensing time.
131
+
132
+ Returns
133
+ -------
134
+ pd.DataFrame
135
+ Query results in pandas DataFrame
136
+
137
+ See Also
138
+ --------
139
+ :func:`search.intersect`
140
+ """
141
+ columns = columns or BQ_DEFAULT_COLUMNS
142
+ return search.intersect(BQ_TABLE, flight, columns)
143
+
144
+
145
+ class Sentinel:
146
+ """Support for Sentinel-2 data handling.
147
+
148
+ This class uses the `PROJ <https://proj.org/en/9.4/index.html>`__ coordinate
149
+ transformation software through the
150
+ `pyproj <https://pyproj4.github.io/pyproj/stable/index.html>`__ python interface.
151
+ pyproj is installed as part of the ``sat`` set of optional dependencies
152
+ (``pip install pycontrails[sat]``), but PROJ must be installed manually.
153
+
154
+ Parameters
155
+ ----------
156
+ base_url : str
157
+ Base URL of Sentinel-2 scene. To find URLs for Sentinel-2 scenes at
158
+ specific locations and times, see :func:`query` and :func:`intersect`.
159
+ granule_id : str
160
+ Granule ID of Sentinel-2 scene. To find URLs for Sentinel-2 scenes at
161
+ specific locations and times, see :func:`query` and :func:`intersect`.
162
+ bands : str | set[str] | None
163
+ Set of bands to retrieve. The 13 possible bands are represented by
164
+ the string "B01" to "B12" plus "B8A". For the true color scheme, set
165
+ ``bands=("B02", "B03", "B04")``. By default, bands for the true color scheme
166
+ are used. Bands must share a common resolution. The resolutions of each band are:
167
+
168
+ - B02-B04, B08: 10 m
169
+ - B05-B07, B8A, B11, B12: 20 m
170
+ - B01, B09, B10: 60 m
171
+
172
+ cachestore : cache.CacheStore, optional
173
+ Cache store for Landsat data. If None, a :class:`DiskCacheStore` is used.
174
+
175
+ See Also
176
+ --------
177
+ query
178
+ intersect
179
+ """
180
+
181
+ def __init__(
182
+ self,
183
+ base_url: str,
184
+ granule_id: str,
185
+ bands: str | Iterable[str] | None = None,
186
+ cachestore: cache.CacheStore | None = None,
187
+ ) -> None:
188
+ self.base_url = base_url
189
+ self.granule_id = granule_id
190
+ self.bands = _parse_bands(bands)
191
+ _check_band_resolution(self.bands)
192
+ self.fs = gcsfs.GCSFileSystem(token="anon")
193
+
194
+ if cachestore is None:
195
+ cache_root = cache._get_user_cache_dir()
196
+ cache_dir = f"{cache_root}/sentinel"
197
+ cachestore = cache.DiskCacheStore(cache_dir=cache_dir)
198
+ self.cachestore = cachestore
199
+
200
+ def __repr__(self) -> str:
201
+ """Return string representation."""
202
+ return (
203
+ f"Sentinel(base_url='{self.base_url}',\n"
204
+ f"\tgranule_id='{self.granule_id}',\n"
205
+ f"\tbands={sorted(self.bands)})"
206
+ )
207
+
208
+ def get(self, reflective: str = "reflectance") -> xr.Dataset:
209
+ """Retrieve Sentinel-2 imagery.
210
+
211
+ Parameters
212
+ ----------
213
+ reflective : str = {"raw", "reflectance"}, optional
214
+ Whether to return raw values or rescaled reflectances for reflective bands.
215
+ By default, return reflectances.
216
+
217
+ Returns
218
+ -------
219
+ xr.DataArray
220
+ DataArray of Sentinel-2 data.
221
+ """
222
+ if reflective not in ["raw", "reflectance"]:
223
+ msg = "reflective band processing must be one of ['raw', 'radiance', 'reflectance']"
224
+ raise ValueError(msg)
225
+
226
+ ds = xr.Dataset()
227
+ for band in self.bands:
228
+ ds[band] = self._get(band, reflective)
229
+ return ds
230
+
231
+ def _get(self, band: str, processing: str) -> xr.DataArray:
232
+ """Download Sentinel-2 band to the :attr:`cachestore` and return processed data."""
233
+ jp2_path = self._get_jp2(band)
234
+ granule_meta_path, safe_meta_path = self._get_meta()
235
+ return _read(jp2_path, granule_meta_path, safe_meta_path, band, processing)
236
+
237
+ def _get_jp2(self, band: str) -> str:
238
+ """Download Sentinel-2 imagery and return path to cached file."""
239
+ fs = self.fs
240
+ base_url = self.base_url
241
+ granule_id = self.granule_id
242
+ prefix = f"{base_url}/GRANULE/{granule_id}/IMG_DATA"
243
+ files = fs.ls(prefix)
244
+
245
+ urls = [f"gs://{f}" for f in files if f.endswith(f"{band}.jp2")]
246
+ if len(urls) > 1:
247
+ msg = f"Multiple image files found for band {band}"
248
+ raise ValueError(msg)
249
+ if len(urls) == 0:
250
+ msg = f"No image files found for band {band}"
251
+ raise ValueError(msg)
252
+ url = urls[0]
253
+
254
+ sink = self.cachestore.path(url.removeprefix(GCP_STRIP_PREFIX))
255
+ if not self.cachestore.exists(sink):
256
+ fs.get(url, sink)
257
+ return sink
258
+
259
+ def _get_meta(self) -> tuple[str, str]:
260
+ """Download Sentinel-2 metadata files and return path to cached files.
261
+
262
+ Note that two XML files must be retrieved: one inside the GRANULE
263
+ subdirectory, and one at the top level of the SAFE archive.
264
+ """
265
+ fs = self.fs
266
+ base_url = self.base_url
267
+ granule_id = self.granule_id
268
+
269
+ url = f"{base_url}/GRANULE/{granule_id}/MTD_TL.xml"
270
+ granule_sink = self.cachestore.path(url.removeprefix(GCP_STRIP_PREFIX))
271
+ if not self.cachestore.exists(granule_sink):
272
+ fs.get(url, granule_sink)
273
+
274
+ url = f"{base_url}/MTD_MSIL1C.xml"
275
+ safe_sink = self.cachestore.path(url.removeprefix(GCP_STRIP_PREFIX))
276
+ if not self.cachestore.exists(safe_sink):
277
+ fs.get(url, safe_sink)
278
+
279
+ return granule_sink, safe_sink
280
+
281
+
282
+ def _parse_bands(bands: str | Iterable[str] | None) -> set[str]:
283
+ """Check that the bands are valid and return as a set."""
284
+ if bands is None:
285
+ return set(DEFAULT_BANDS)
286
+
287
+ if isinstance(bands, str):
288
+ bands = (bands,)
289
+
290
+ available = {f"B{i:02d}" for i in range(1, 13)} | {"B8A"}
291
+ bands = {b.upper() for b in bands}
292
+ if len(bands) == 0:
293
+ msg = "At least one band must be provided"
294
+ raise ValueError(msg)
295
+ if not bands.issubset(available):
296
+ msg = f"Bands must be in {sorted(available)}"
297
+ raise ValueError(msg)
298
+ return bands
299
+
300
+
301
+ def _check_band_resolution(bands: set[str]) -> None:
302
+ """Confirm requested bands have a common horizontal resolution."""
303
+ groups = [
304
+ {"B02", "B03", "B04", "B08"}, # 10 m
305
+ {"B05", "B06", "B07", "B8A", "B11", "B12"}, # 20 m
306
+ {"B01", "B09", "B10"}, # 60 m
307
+ ]
308
+ if not any(bands.issubset(group) for group in groups):
309
+ msg = "Bands must have a common horizontal resolution."
310
+ raise ValueError(msg)
311
+
312
+
313
+ def _read(path: str, granule_meta: str, safe_meta: str, band: str, processing: str) -> xr.DataArray:
314
+ """Read imagery data from Sentinel-2 files."""
315
+ Image.MAX_IMAGE_PIXELS = None # avoid decompression bomb warning
316
+ src = Image.open(path)
317
+ img = np.asarray(src)
318
+ src.close()
319
+
320
+ if processing == "reflectance":
321
+ gain, offset = _read_band_reflectance_rescaling(safe_meta, band)
322
+ img = np.where(img == 0, np.nan, (img + offset) / gain).astype("float32")
323
+
324
+ tree = ElementTree.parse(granule_meta)
325
+ elem = tree.find(".//HORIZONTAL_CS_CODE")
326
+ if elem is None or elem.text is None:
327
+ msg = "Could not find imagery projection in metadata."
328
+ raise ValueError(msg)
329
+ epsg = int(elem.text.split(":")[1])
330
+ crs = pyproj.CRS.from_epsg(epsg)
331
+
332
+ x, y = _read_image_coordinates(granule_meta, band)
333
+
334
+ da = xr.DataArray(
335
+ data=img,
336
+ coords={"y": y, "x": x},
337
+ dims=("y", "x"),
338
+ attrs={
339
+ "long_name": f"{band} {processing}",
340
+ "units": "nondim" if processing == "reflectance" else "none",
341
+ "crs": crs,
342
+ },
343
+ )
344
+ da["x"].attrs = {"long_name": "easting", "units": "m"}
345
+ da["y"].attrs = {"long_name": "northing", "units": "m"}
346
+ return da
347
+
348
+
349
+ def _band_resolution(band: str) -> int:
350
+ """Get band resolution in meters."""
351
+ return (
352
+ 60 if band in ("B01", "B09", "B10") else 10 if band in ("B02", "B03", "B04", "B08") else 20
353
+ )
354
+
355
+
356
+ def _band_id(band: str) -> int:
357
+ """Get band ID used in some metadata files."""
358
+ if band in (f"B{i:2d}" for i in range(1, 9)):
359
+ return int(band[1:]) - 1
360
+ elif band == "B8A":
361
+ return 8
362
+ else:
363
+ return int(band[1:])
364
+
365
+
366
+ def _read_band_reflectance_rescaling(meta: str, band: str) -> tuple[float, float]:
367
+ """Read reflectance rescaling factors from metadata file.
368
+
369
+ See https://sentiwiki.copernicus.eu/web/s2-processing#S2Processing-TOAReflectanceComputation
370
+ and https://scihub.copernicus.eu/news/News00931.
371
+ """
372
+ # Find quantization gain (present in all files)
373
+ tree = ElementTree.parse(meta)
374
+ elem = tree.find(".//QUANTIFICATION_VALUE")
375
+ if elem is None or elem.text is None:
376
+ msg = "Could not find reflectance quantization gain."
377
+ raise ValueError(msg)
378
+ gain = float(elem.text)
379
+
380
+ # See if offset (used in recently processed files) is present
381
+ elems = tree.findall(".//RADIO_ADD_OFFSET")
382
+
383
+ # If not, set offset to 0
384
+ if len(elems) == 0:
385
+ return gain, 0.0
386
+
387
+ # Otherwise, search for offset with correct band ID
388
+ band_id = _band_id(band)
389
+ for elem in elems:
390
+ if int(elem.attrib["band_id"]) == band_id and elem.text is not None:
391
+ offset = float(elem.text)
392
+ break
393
+ else:
394
+ msg = f"Could not find reflectance offset for band {band} (band ID {band_id})"
395
+ raise ValueError(msg)
396
+
397
+ return gain, offset
398
+
399
+
400
+ def _read_image_coordinates(meta: str, band: str) -> tuple[np.ndarray, np.ndarray]:
401
+ """Read image x and y coordinates."""
402
+
403
+ # convenience function that satisfies mypy
404
+ def _text_from_tag(parent: ElementTree.Element, tag: str) -> str:
405
+ elem = parent.find(tag)
406
+ if elem is None or elem.text is None:
407
+ msg = f"Could not find text in {tag} element"
408
+ raise ValueError(msg)
409
+ return elem.text
410
+
411
+ resolution = _band_resolution(band)
412
+
413
+ # find coordinates of upper left corner and pixel size
414
+ tree = ElementTree.parse(meta)
415
+ elems = tree.findall(".//Geoposition")
416
+ for elem in elems:
417
+ if int(elem.attrib["resolution"]) == resolution:
418
+ ulx = float(_text_from_tag(elem, "ULX"))
419
+ uly = float(_text_from_tag(elem, "ULY"))
420
+ dx = float(_text_from_tag(elem, "XDIM"))
421
+ dy = float(_text_from_tag(elem, "YDIM"))
422
+ break
423
+ else:
424
+ msg = f"Could not find image geoposition for resolution of {resolution} m"
425
+ raise ValueError(msg)
426
+
427
+ # find image size
428
+ elems = tree.findall(".//Size")
429
+ for elem in elems:
430
+ if int(elem.attrib["resolution"]) == resolution:
431
+ nx = int(_text_from_tag(elem, "NCOLS"))
432
+ ny = int(_text_from_tag(elem, "NROWS"))
433
+ break
434
+ else:
435
+ msg = f"Could not find image size for resolution of {resolution} m"
436
+ raise ValueError(msg)
437
+
438
+ # compute pixel coordinates
439
+ xlim = (ulx, ulx + (nx - 1) * dx)
440
+ ylim = (uly, uly + (ny - 1) * dy) # dy is < 0
441
+ x = np.linspace(xlim[0], xlim[1], nx)
442
+ y = np.linspace(ylim[0], ylim[1], ny)
443
+
444
+ return x, y
445
+
446
+
447
+ def extract_sentinel_visualization(
448
+ ds: xr.Dataset, color_scheme: str = "true"
449
+ ) -> tuple[np.ndarray, pyproj.CRS, tuple[float, float, float, float]]:
450
+ """Extract artifacts for visualizing Sentinel data with the given color scheme.
451
+
452
+ Parameters
453
+ ----------
454
+ ds : xr.Dataset
455
+ Dataset of Sentinel data as returned by :meth:`Sentinel.get`.
456
+ color_scheme : str = {"true"}
457
+ Color scheme to use for visualization. The true color scheme
458
+ (the only option currently implemented) requires bands B02, B03, and B04.
459
+
460
+ Returns
461
+ -------
462
+ rgb : npt.NDArray[np.float32]
463
+ 3D RGB array of shape ``(height, width, 3)``.
464
+ src_crs : pyproj.CRS
465
+ Imagery projection
466
+ src_extent : tuple[float,float,float,float]
467
+ Imagery extent in projected coordinates
468
+ """
469
+
470
+ if color_scheme == "true":
471
+ rgb, src_crs = to_true_color(ds)
472
+ else:
473
+ raise ValueError(f"Color scheme must be 'true', not '{color_scheme}'")
474
+
475
+ x = ds["x"].values
476
+ y = ds["y"].values
477
+ src_extent = x.min(), x.max(), y.min(), y.max()
478
+
479
+ return rgb, src_crs, src_extent
480
+
481
+
482
+ def to_true_color(ds: xr.Dataset) -> tuple[np.ndarray, pyproj.CRS]:
483
+ """Compute 3d RGB array for the true color scheme.
484
+
485
+ Parameters
486
+ ----------
487
+ ds : xr.Dataset
488
+ DataArray of Sentinel data with bands B02, B03, and B04.
489
+
490
+ Returns
491
+ -------
492
+ np.ndarray
493
+ 3d RGB array with true color scheme.
494
+
495
+ src_crs : pyproj.CRS
496
+ Imagery projection
497
+ """
498
+ red = ds["B04"]
499
+ green = ds["B03"]
500
+ blue = ds["B02"]
501
+
502
+ crs = red.attrs["crs"]
503
+ if not (crs.equals(green.attrs["crs"]) and crs.equals(blue.attrs["crs"])):
504
+ msg = "Bands B02, B03, and B04 do not share a common projection."
505
+ raise ValueError(msg)
506
+
507
+ img = np.dstack(
508
+ [equalize(normalize(band.values), clip_limit=0.03) for band in (red, green, blue)]
509
+ )
510
+
511
+ return img, crs