pycontrails 0.53.0__cp313-cp313-manylinux_2_17_x86_64.manylinux2014_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 +2312 -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-313-x86_64-linux-gnu.so +0 -0
  18. pycontrails/core/vector.py +2191 -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 +743 -0
  24. pycontrails/datalib/ecmwf/__init__.py +53 -0
  25. pycontrails/datalib/ecmwf/arco_era5.py +527 -0
  26. pycontrails/datalib/ecmwf/common.py +109 -0
  27. pycontrails/datalib/ecmwf/era5.py +538 -0
  28. pycontrails/datalib/ecmwf/era5_model_level.py +482 -0
  29. pycontrails/datalib/ecmwf/hres.py +782 -0
  30. pycontrails/datalib/ecmwf/hres_model_level.py +495 -0
  31. pycontrails/datalib/ecmwf/ifs.py +284 -0
  32. pycontrails/datalib/ecmwf/model_levels.py +79 -0
  33. pycontrails/datalib/ecmwf/static/model_level_dataframe_v20240418.csv +139 -0
  34. pycontrails/datalib/ecmwf/variables.py +256 -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 +568 -0
  40. pycontrails/datalib/sentinel.py +512 -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 +426 -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 +983 -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 +2617 -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 +486 -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.53.0.dist-info/LICENSE +178 -0
  105. pycontrails-0.53.0.dist-info/METADATA +181 -0
  106. pycontrails-0.53.0.dist-info/NOTICE +43 -0
  107. pycontrails-0.53.0.dist-info/RECORD +109 -0
  108. pycontrails-0.53.0.dist-info/WHEEL +6 -0
  109. pycontrails-0.53.0.dist-info/top_level.txt +3 -0
@@ -0,0 +1,512 @@
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
+
189
+ self.base_url = base_url
190
+ self.granule_id = granule_id
191
+ self.bands = _parse_bands(bands)
192
+ _check_band_resolution(self.bands)
193
+ self.fs = gcsfs.GCSFileSystem(token="anon")
194
+
195
+ if cachestore is None:
196
+ cache_root = cache._get_user_cache_dir()
197
+ cache_dir = f"{cache_root}/sentinel"
198
+ cachestore = cache.DiskCacheStore(cache_dir=cache_dir)
199
+ self.cachestore = cachestore
200
+
201
+ def __repr__(self) -> str:
202
+ """Return string representation."""
203
+ return (
204
+ f"Sentinel(base_url='{self.base_url}',\n"
205
+ f"\tgranule_id='{self.granule_id}',\n"
206
+ f"\tbands={sorted(self.bands)})"
207
+ )
208
+
209
+ def get(self, reflective: str = "reflectance") -> xr.Dataset:
210
+ """Retrieve Sentinel-2 imagery.
211
+
212
+ Parameters
213
+ ----------
214
+ reflective : str = {"raw", "reflectance"}, optional
215
+ Whether to return raw values or rescaled reflectances for reflective bands.
216
+ By default, return reflectances.
217
+
218
+ Returns
219
+ -------
220
+ xr.DataArray
221
+ DataArray of Sentinel-2 data.
222
+ """
223
+ if reflective not in ["raw", "reflectance"]:
224
+ msg = "reflective band processing must be one of ['raw', 'radiance', 'reflectance']"
225
+ raise ValueError(msg)
226
+
227
+ ds = xr.Dataset()
228
+ for band in self.bands:
229
+ ds[band] = self._get(band, reflective)
230
+ return ds
231
+
232
+ def _get(self, band: str, processing: str) -> xr.DataArray:
233
+ """Download Sentinel-2 band to the :attr:`cachestore` and return processed data."""
234
+ jp2_path = self._get_jp2(band)
235
+ granule_meta_path, safe_meta_path = self._get_meta()
236
+ return _read(jp2_path, granule_meta_path, safe_meta_path, band, processing)
237
+
238
+ def _get_jp2(self, band: str) -> str:
239
+ """Download Sentinel-2 imagery and return path to cached file."""
240
+ fs = self.fs
241
+ base_url = self.base_url
242
+ granule_id = self.granule_id
243
+ prefix = f"{base_url}/GRANULE/{granule_id}/IMG_DATA"
244
+ files = fs.ls(prefix)
245
+
246
+ urls = [f"gs://{f}" for f in files if f.endswith(f"{band}.jp2")]
247
+ if len(urls) > 1:
248
+ msg = f"Multiple image files found for band {band}"
249
+ raise ValueError(msg)
250
+ if len(urls) == 0:
251
+ msg = f"No image files found for band {band}"
252
+ raise ValueError(msg)
253
+ url = urls[0]
254
+
255
+ sink = self.cachestore.path(url.removeprefix(GCP_STRIP_PREFIX))
256
+ if not self.cachestore.exists(sink):
257
+ fs.get(url, sink)
258
+ return sink
259
+
260
+ def _get_meta(self) -> tuple[str, str]:
261
+ """Download Sentinel-2 metadata files and return path to cached files.
262
+
263
+ Note that two XML files must be retrieved: one inside the GRANULE
264
+ subdirectory, and one at the top level of the SAFE archive.
265
+ """
266
+ fs = self.fs
267
+ base_url = self.base_url
268
+ granule_id = self.granule_id
269
+
270
+ url = f"{base_url}/GRANULE/{granule_id}/MTD_TL.xml"
271
+ granule_sink = self.cachestore.path(url.removeprefix(GCP_STRIP_PREFIX))
272
+ if not self.cachestore.exists(granule_sink):
273
+ fs.get(url, granule_sink)
274
+
275
+ url = f"{base_url}/MTD_MSIL1C.xml"
276
+ safe_sink = self.cachestore.path(url.removeprefix(GCP_STRIP_PREFIX))
277
+ if not self.cachestore.exists(safe_sink):
278
+ fs.get(url, safe_sink)
279
+
280
+ return granule_sink, safe_sink
281
+
282
+
283
+ def _parse_bands(bands: str | Iterable[str] | None) -> set[str]:
284
+ """Check that the bands are valid and return as a set."""
285
+ if bands is None:
286
+ return set(DEFAULT_BANDS)
287
+
288
+ if isinstance(bands, str):
289
+ bands = (bands,)
290
+
291
+ available = {f"B{i:02d}" for i in range(1, 13)} | {"B8A"}
292
+ bands = {b.upper() for b in bands}
293
+ if len(bands) == 0:
294
+ msg = "At least one band must be provided"
295
+ raise ValueError(msg)
296
+ if not bands.issubset(available):
297
+ msg = f"Bands must be in {sorted(available)}"
298
+ raise ValueError(msg)
299
+ return bands
300
+
301
+
302
+ def _check_band_resolution(bands: set[str]) -> None:
303
+ """Confirm requested bands have a common horizontal resolution."""
304
+ groups = [
305
+ {"B02", "B03", "B04", "B08"}, # 10 m
306
+ {"B05", "B06", "B07", "B8A", "B11", "B12"}, # 20 m
307
+ {"B01", "B09", "B10"}, # 60 m
308
+ ]
309
+ if not any(bands.issubset(group) for group in groups):
310
+ msg = "Bands must have a common horizontal resolution."
311
+ raise ValueError(msg)
312
+
313
+
314
+ def _read(path: str, granule_meta: str, safe_meta: str, band: str, processing: str) -> xr.DataArray:
315
+ """Read imagery data from Sentinel-2 files."""
316
+ Image.MAX_IMAGE_PIXELS = None # avoid decompression bomb warning
317
+ src = Image.open(path)
318
+ img = np.asarray(src)
319
+ src.close()
320
+
321
+ if processing == "reflectance":
322
+ gain, offset = _read_band_reflectance_rescaling(safe_meta, band)
323
+ img = np.where(img == 0, np.nan, (img + offset) / gain).astype("float32")
324
+
325
+ tree = ElementTree.parse(granule_meta)
326
+ elem = tree.find(".//HORIZONTAL_CS_CODE")
327
+ if elem is None or elem.text is None:
328
+ msg = "Could not find imagery projection in metadata."
329
+ raise ValueError(msg)
330
+ epsg = int(elem.text.split(":")[1])
331
+ crs = pyproj.CRS.from_epsg(epsg)
332
+
333
+ x, y = _read_image_coordinates(granule_meta, band)
334
+
335
+ da = xr.DataArray(
336
+ data=img,
337
+ coords={"y": y, "x": x},
338
+ dims=("y", "x"),
339
+ attrs={
340
+ "long_name": f"{band} {processing}",
341
+ "units": "nondim" if processing == "reflectance" else "none",
342
+ "crs": crs,
343
+ },
344
+ )
345
+ da["x"].attrs = {"long_name": "easting", "units": "m"}
346
+ da["y"].attrs = {"long_name": "northing", "units": "m"}
347
+ return da
348
+
349
+
350
+ def _band_resolution(band: str) -> int:
351
+ """Get band resolution in meters."""
352
+ return (
353
+ 60 if band in ("B01", "B09", "B10") else 10 if band in ("B02", "B03", "B04", "B08") else 20
354
+ )
355
+
356
+
357
+ def _band_id(band: str) -> int:
358
+ """Get band ID used in some metadata files."""
359
+ if band in (f"B{i:2d}" for i in range(1, 9)):
360
+ return int(band[1:]) - 1
361
+ elif band == "B8A":
362
+ return 8
363
+ else:
364
+ return int(band[1:])
365
+
366
+
367
+ def _read_band_reflectance_rescaling(meta: str, band: str) -> tuple[float, float]:
368
+ """Read reflectance rescaling factors from metadata file.
369
+
370
+ See https://sentiwiki.copernicus.eu/web/s2-processing#S2Processing-TOAReflectanceComputation
371
+ and https://scihub.copernicus.eu/news/News00931.
372
+ """
373
+ # Find quantization gain (present in all files)
374
+ tree = ElementTree.parse(meta)
375
+ elem = tree.find(".//QUANTIFICATION_VALUE")
376
+ if elem is None or elem.text is None:
377
+ msg = "Could not find reflectance quantization gain."
378
+ raise ValueError(msg)
379
+ gain = float(elem.text)
380
+
381
+ # See if offset (used in recently processed files) is present
382
+ elems = tree.findall(".//RADIO_ADD_OFFSET")
383
+
384
+ # If not, set offset to 0
385
+ if len(elems) == 0:
386
+ return gain, 0.0
387
+
388
+ # Otherwise, search for offset with correct band ID
389
+ band_id = _band_id(band)
390
+ for elem in elems:
391
+ if int(elem.attrib["band_id"]) == band_id and elem.text is not None:
392
+ offset = float(elem.text)
393
+ break
394
+ else:
395
+ msg = f"Could not find reflectance offset for band {band} (band ID {band_id})"
396
+ raise ValueError(msg)
397
+
398
+ return gain, offset
399
+
400
+
401
+ def _read_image_coordinates(meta: str, band: str) -> tuple[np.ndarray, np.ndarray]:
402
+ """Read image x and y coordinates."""
403
+
404
+ # convenience function that satisfies mypy
405
+ def _text_from_tag(parent: ElementTree.Element, tag: str) -> str:
406
+ elem = parent.find(tag)
407
+ if elem is None or elem.text is None:
408
+ msg = f"Could not find text in {tag} element"
409
+ raise ValueError(msg)
410
+ return elem.text
411
+
412
+ resolution = _band_resolution(band)
413
+
414
+ # find coordinates of upper left corner and pixel size
415
+ tree = ElementTree.parse(meta)
416
+ elems = tree.findall(".//Geoposition")
417
+ for elem in elems:
418
+ if int(elem.attrib["resolution"]) == resolution:
419
+ ulx = float(_text_from_tag(elem, "ULX"))
420
+ uly = float(_text_from_tag(elem, "ULY"))
421
+ dx = float(_text_from_tag(elem, "XDIM"))
422
+ dy = float(_text_from_tag(elem, "YDIM"))
423
+ break
424
+ else:
425
+ msg = f"Could not find image geoposition for resolution of {resolution} m"
426
+ raise ValueError(msg)
427
+
428
+ # find image size
429
+ elems = tree.findall(".//Size")
430
+ for elem in elems:
431
+ if int(elem.attrib["resolution"]) == resolution:
432
+ nx = int(_text_from_tag(elem, "NCOLS"))
433
+ ny = int(_text_from_tag(elem, "NROWS"))
434
+ break
435
+ else:
436
+ msg = f"Could not find image size for resolution of {resolution} m"
437
+ raise ValueError(msg)
438
+
439
+ # compute pixel coordinates
440
+ xlim = (ulx, ulx + (nx - 1) * dx)
441
+ ylim = (uly, uly + (ny - 1) * dy) # dy is < 0
442
+ x = np.linspace(xlim[0], xlim[1], nx)
443
+ y = np.linspace(ylim[0], ylim[1], ny)
444
+
445
+ return x, y
446
+
447
+
448
+ def extract_sentinel_visualization(
449
+ ds: xr.Dataset, color_scheme: str = "true"
450
+ ) -> tuple[np.ndarray, pyproj.CRS, tuple[float, float, float, float]]:
451
+ """Extract artifacts for visualizing Sentinel data with the given color scheme.
452
+
453
+ Parameters
454
+ ----------
455
+ ds : xr.Dataset
456
+ Dataset of Sentinel data as returned by :meth:`Sentinel.get`.
457
+ color_scheme : str = {"true"}
458
+ Color scheme to use for visualization. The true color scheme
459
+ (the only option currently implemented) requires bands B02, B03, and B04.
460
+
461
+ Returns
462
+ -------
463
+ rgb : npt.NDArray[np.float32]
464
+ 3D RGB array of shape ``(height, width, 3)``.
465
+ src_crs : pyproj.CRS
466
+ Imagery projection
467
+ src_extent : tuple[float,float,float,float]
468
+ Imagery extent in projected coordinates
469
+ """
470
+
471
+ if color_scheme == "true":
472
+ rgb, src_crs = to_true_color(ds)
473
+ else:
474
+ raise ValueError(f"Color scheme must be 'true', not '{color_scheme}'")
475
+
476
+ x = ds["x"].values
477
+ y = ds["y"].values
478
+ src_extent = x.min(), x.max(), y.min(), y.max()
479
+
480
+ return rgb, src_crs, src_extent
481
+
482
+
483
+ def to_true_color(ds: xr.Dataset) -> tuple[np.ndarray, pyproj.CRS]:
484
+ """Compute 3d RGB array for the true color scheme.
485
+
486
+ Parameters
487
+ ----------
488
+ ds : xr.Dataset
489
+ DataArray of Sentinel data with bands B02, B03, and B04.
490
+
491
+ Returns
492
+ -------
493
+ np.ndarray
494
+ 3d RGB array with true color scheme.
495
+
496
+ src_crs : pyproj.CRS
497
+ Imagery projection
498
+ """
499
+ red = ds["B04"]
500
+ green = ds["B03"]
501
+ blue = ds["B02"]
502
+
503
+ crs = red.attrs["crs"]
504
+ if not (crs.equals(green.attrs["crs"]) and crs.equals(blue.attrs["crs"])):
505
+ msg = "Bands B02, B03, and B04 do not share a common projection."
506
+ raise ValueError(msg)
507
+
508
+ img = np.dstack(
509
+ [equalize(normalize(band.values), clip_limit=0.03) for band in (red, green, blue)]
510
+ )
511
+
512
+ return img, crs