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,569 @@
1
+ """Support for LANDSAT 8-9 imagery retrieval through Google Cloud Platform."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ import xarray as xr
10
+
11
+ from pycontrails.core import Flight, cache
12
+ from pycontrails.datalib._leo_utils import search
13
+ from pycontrails.datalib._leo_utils.vis import equalize, normalize
14
+ from pycontrails.utils import dependencies
15
+
16
+ try:
17
+ import gcsfs
18
+ except ModuleNotFoundError as exc:
19
+ dependencies.raise_module_not_found_error(
20
+ name="landsat module",
21
+ package_name="gcsfs",
22
+ module_not_found_error=exc,
23
+ pycontrails_optional_package="sat",
24
+ )
25
+
26
+ try:
27
+ import pyproj
28
+ except ModuleNotFoundError as exc:
29
+ dependencies.raise_module_not_found_error(
30
+ name="landsat module",
31
+ package_name="pyproj",
32
+ module_not_found_error=exc,
33
+ pycontrails_optional_package="sat",
34
+ )
35
+
36
+
37
+ #: BigQuery table with imagery metadata
38
+ BQ_TABLE = "bigquery-public-data.cloud_storage_geo_index.landsat_index"
39
+
40
+ #: Default columns to include in queries
41
+ BQ_DEFAULT_COLUMNS = ["base_url", "sensing_time"]
42
+
43
+ #: Default spatial extent for queries
44
+ BQ_DEFAULT_EXTENT = search.GLOBAL_EXTENT
45
+
46
+ #: Extra filters for BigQuery queries
47
+ BQ_EXTRA_FILTERS = 'AND spacecraft_id in ("LANDSAT_8", "LANDSAT_9")'
48
+
49
+ #: Default Landsat channels to use if none are specified.
50
+ #: These are visible bands for producing a true color composite.
51
+ DEFAULT_BANDS = ["B2", "B3", "B4"]
52
+
53
+ #: Strip this prefix from GCP URLs when caching Landsat files locally
54
+ GCP_STRIP_PREFIX = "gs://gcp-public-data-landsat/"
55
+
56
+
57
+ def query(
58
+ start_time: np.datetime64,
59
+ end_time: np.datetime64,
60
+ extent: str | None = None,
61
+ columns: list[str] | None = None,
62
+ ) -> pd.DataFrame:
63
+ """Find Landsat 8 and 9 imagery within spatiotemporal region of interest.
64
+
65
+ This function requires access to the
66
+ `Google BigQuery API <https://cloud.google.com/bigquery?hl=en>`__
67
+ and uses the `BigQuery python library <https://cloud.google.com/python/docs/reference/bigquery/latest/index.html>`__.
68
+
69
+ Parameters
70
+ ----------
71
+ start_time : np.datetime64
72
+ Start of time period for search
73
+ end_time : np.datetime64
74
+ End of time period for search
75
+ extent : str, optional
76
+ Spatial region of interest as a GeoJSON string. If not provided, defaults
77
+ to a global extent.
78
+ columns : list[str], optional.
79
+ Columns to return from Google
80
+ `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>`__.
81
+ By default, returns imagery base URL and sensing time.
82
+
83
+ Returns
84
+ -------
85
+ pd.DataFrame
86
+ Query results in pandas DataFrame
87
+
88
+ See Also
89
+ --------
90
+ :func:`search.query`
91
+ """
92
+ extent = extent or BQ_DEFAULT_EXTENT
93
+ roi = search.ROI(start_time, end_time, extent)
94
+ columns = columns or BQ_DEFAULT_COLUMNS
95
+ return search.query(BQ_TABLE, roi, columns, BQ_EXTRA_FILTERS)
96
+
97
+
98
+ def intersect(
99
+ flight: Flight,
100
+ columns: list[str] | None = None,
101
+ ) -> pd.DataFrame:
102
+ """Find Landsat 8 and 9 imagery intersecting with flight track.
103
+
104
+ This function will return all scenes with a bounding box that includes flight waypoints
105
+ both before and after the sensing time.
106
+
107
+ This function requires access to the
108
+ `Google BigQuery API <https://cloud.google.com/bigquery?hl=en>`__
109
+ and uses the `BigQuery python library <https://cloud.google.com/python/docs/reference/bigquery/latest/index.html>`__.
110
+
111
+ Parameters
112
+ ----------
113
+ flight : Flight
114
+ Flight for intersection
115
+ columns : list[str], optional.
116
+ Columns to return from Google
117
+ `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>`__.
118
+ By default, returns imagery base URL and sensing time.
119
+
120
+ Returns
121
+ -------
122
+ pd.DataFrame
123
+ Query results in pandas DataFrame
124
+
125
+ See Also
126
+ --------
127
+ :func:`search.intersect`
128
+ """
129
+ columns = columns or BQ_DEFAULT_COLUMNS
130
+ return search.intersect(BQ_TABLE, flight, columns, BQ_EXTRA_FILTERS)
131
+
132
+
133
+ class Landsat:
134
+ """Support for Landsat 8 and 9 data handling.
135
+
136
+ This class uses the `PROJ <https://proj.org/en/9.4/index.html>`__ coordinate
137
+ transformation software through the
138
+ `pyproj <https://pyproj4.github.io/pyproj/stable/index.html>`__ python interface.
139
+ pyproj is installed as part of the ``sat`` set of optional dependencies
140
+ (``pip install pycontrails[sat]``), but PROJ must be installed manually.
141
+
142
+ Parameters
143
+ ----------
144
+ base_url : str
145
+ Base URL of Landsat scene. To find URLs for Landsat scenes at
146
+ specific locations and times, see :func:`query` and :func:`intersect`.
147
+ bands : str | set[str] | None
148
+ Set of bands to retrieve. The 11 possible bands are represented by
149
+ the string "B1" to "B11". For the Google Landsat contrails color scheme,
150
+ set ``bands=("B9", "B10", "B11")``. For the true color scheme, set
151
+ ``bands=("B2", "B3", "B4")``. By default, bands for the true color scheme
152
+ are used. Bands must share a common resolution. The resolutions of each band are:
153
+
154
+ - B1-B7, B9: 30 m
155
+ - B9: 15 m
156
+ - B10, B11: 30 m (upsampled from true resolution of 100 m)
157
+
158
+ cachestore : cache.CacheStore, optional
159
+ Cache store for Landsat data. If None, a :class:`DiskCacheStore` is used.
160
+
161
+ See Also
162
+ --------
163
+ query
164
+ intersect
165
+ """
166
+
167
+ def __init__(
168
+ self,
169
+ base_url: str,
170
+ bands: str | Iterable[str] | None = None,
171
+ cachestore: cache.CacheStore | None = None,
172
+ ) -> None:
173
+ self.base_url = base_url
174
+ self.bands = _parse_bands(bands)
175
+ _check_band_resolution(self.bands)
176
+ self.fs = gcsfs.GCSFileSystem(token="anon")
177
+
178
+ if cachestore is None:
179
+ cache_root = cache._get_user_cache_dir()
180
+ cache_dir = f"{cache_root}/landsat"
181
+ cachestore = cache.DiskCacheStore(cache_dir=cache_dir)
182
+ self.cachestore = cachestore
183
+
184
+ def __repr__(self) -> str:
185
+ """Return string representation."""
186
+ return f"Landsat(base_url='{self.base_url}', bands={sorted(self.bands)})"
187
+
188
+ @property
189
+ def reflective_bands(self) -> list[str]:
190
+ """List of reflective bands."""
191
+ return [b for b in self.bands if b not in ["B10", "B11"]]
192
+
193
+ @property
194
+ def thermal_bands(self) -> list[str]:
195
+ """List of thermal bands."""
196
+ return [b for b in self.bands if b in ["B10", "B11"]]
197
+
198
+ def get(
199
+ self, reflective: str = "reflectance", thermal: str = "brightness_temperature"
200
+ ) -> xr.Dataset:
201
+ """Retrieve Landsat imagery.
202
+
203
+ Parameters
204
+ ----------
205
+ reflective : str = {"raw", "radiance", "reflectance"}, optional
206
+ Whether to return raw values or rescaled radiances or reflectances for reflective bands.
207
+ By default, return reflectances.
208
+ thermal : str = {"raw", "radiance", "brightness_temperature"}, optional
209
+ Whether to return raw values or rescaled radiances or brightness temperatures
210
+ for thermal bands. By default, return brightness temperatures.
211
+
212
+ Returns
213
+ -------
214
+ xr.DataArray
215
+ DataArray of Landsat data.
216
+ """
217
+ if reflective not in ["raw", "radiance", "reflectance"]:
218
+ msg = "reflective band processing must be one of ['raw', 'radiance', 'reflectance']"
219
+ raise ValueError(msg)
220
+
221
+ if thermal not in ["raw", "radiance", "brightness_temperature"]:
222
+ msg = (
223
+ "thermal band processing must be one of "
224
+ "['raw', 'radiance', 'brighness_temperature']"
225
+ )
226
+ raise ValueError(msg)
227
+
228
+ ds = xr.Dataset()
229
+ for band in self.reflective_bands:
230
+ ds[band] = self._get(band, reflective)
231
+ for band in self.thermal_bands:
232
+ ds[band] = self._get(band, thermal)
233
+ return ds
234
+
235
+ def _get(self, band: str, processing: str) -> xr.DataArray:
236
+ """Download Landsat band to the :attr:`cachestore` and return processed data."""
237
+ tiff_path = self._get_tiff(band)
238
+ meta_path = self._get_meta()
239
+ return _read(tiff_path, meta_path, band, processing)
240
+
241
+ def _get_tiff(self, band: str) -> str:
242
+ """Download Landsat GeoTIFF imagery and return path to cached file."""
243
+ fs = self.fs
244
+ base_url = self.base_url
245
+ product_id = base_url.split("/")[-1]
246
+ fname = f"{product_id}_{band}.TIF"
247
+ url = f"{base_url}/{fname}"
248
+
249
+ sink = self.cachestore.path(url.removeprefix(GCP_STRIP_PREFIX))
250
+ if not self.cachestore.exists(sink):
251
+ fs.get(url, sink)
252
+ return sink
253
+
254
+ def _get_meta(self) -> str:
255
+ """Download Landsat metadata file and return path to cached file."""
256
+ fs = self.fs
257
+ base_url = self.base_url
258
+ product_id = base_url.split("/")[-1]
259
+ fname = f"{product_id}_MTL.txt"
260
+ url = f"{base_url}/{fname}"
261
+
262
+ sink = self.cachestore.path(url.removeprefix(GCP_STRIP_PREFIX))
263
+ if not self.cachestore.exists(sink):
264
+ fs.get(url, sink)
265
+ return sink
266
+
267
+
268
+ def _parse_bands(bands: str | Iterable[str] | None) -> set[str]:
269
+ """Check that the bands are valid and return as a set."""
270
+ if bands is None:
271
+ return set(DEFAULT_BANDS)
272
+
273
+ if isinstance(bands, str):
274
+ bands = (bands,)
275
+
276
+ available = {f"B{i}" for i in range(1, 12)}
277
+ bands = {b.upper() for b in bands}
278
+ if len(bands) == 0:
279
+ msg = "At least one band must be provided"
280
+ raise ValueError(msg)
281
+ if not bands.issubset(available):
282
+ msg = f"Bands must be in {sorted(available)}"
283
+ raise ValueError(msg)
284
+ return bands
285
+
286
+
287
+ def _check_band_resolution(bands: set[str]) -> None:
288
+ """Confirm requested bands have a common horizontal resolution.
289
+
290
+ All bands have 30 m resolution except the panchromatic band, so
291
+ there are two valid cases: only band 8, or any bands except band 8.
292
+ """
293
+ groups = [
294
+ {
295
+ "B8",
296
+ }, # 15 m
297
+ {f"B{i}" for i in range(1, 12) if i != 8}, # 30 m
298
+ ]
299
+ if not any(bands.issubset(group) for group in groups):
300
+ msg = "Bands must have a common horizontal resolution."
301
+ raise ValueError(msg)
302
+
303
+
304
+ def _read(path: str, meta: str, band: str, processing: str) -> xr.DataArray:
305
+ """Read imagery data from Landsat files."""
306
+ try:
307
+ import rasterio
308
+ except ModuleNotFoundError as exc:
309
+ dependencies.raise_module_not_found_error(
310
+ name="landsat module",
311
+ package_name="rasterio",
312
+ module_not_found_error=exc,
313
+ pycontrails_optional_package="sat",
314
+ )
315
+
316
+ src = rasterio.open(path)
317
+ img = src.read(1)
318
+ crs = pyproj.CRS.from_epsg(src.crs.to_epsg())
319
+ src.close()
320
+
321
+ if processing == "reflectance":
322
+ mult, add = _read_band_reflectance_rescaling(meta, band)
323
+ img = np.where(img == 0, np.nan, img * mult + add).astype("float32")
324
+ if processing in ("radiance", "brightness_temperature"):
325
+ mult, add = _read_band_radiance_rescaling(meta, band)
326
+ img = np.where(img == 0, np.nan, img * mult + add).astype("float32")
327
+ if processing == "brightness_temperature":
328
+ k1, k2 = _read_band_thermal_constants(meta, band)
329
+ img = k2 / np.log(k1 / img + 1)
330
+
331
+ x, y = _read_image_coordinates(meta, band)
332
+
333
+ da = xr.DataArray(
334
+ data=img,
335
+ coords={"y": y, "x": x},
336
+ dims=("y", "x"),
337
+ attrs={
338
+ "long_name": f"{band} {processing.replace('_', ' ')}",
339
+ "units": (
340
+ "W/m^2/sr/um"
341
+ if processing == "radiance"
342
+ else (
343
+ "nondim"
344
+ if processing == "reflectance"
345
+ else "K"
346
+ if processing == "brightness_temperature"
347
+ else "none"
348
+ )
349
+ ),
350
+ "crs": crs,
351
+ },
352
+ )
353
+ da["x"].attrs = {"long_name": "easting", "units": "m"}
354
+ da["y"].attrs = {"long_name": "northing", "units": "m"}
355
+ return da
356
+
357
+
358
+ def _read_meta(meta: str, key: str) -> float:
359
+ """Read values from metadata file."""
360
+ with open(meta) as f:
361
+ for line in f:
362
+ if line.strip().startswith(key):
363
+ split = line.split("=")
364
+ return float(split[1].strip())
365
+
366
+ msg = f"Could not find {key} in Landsat metadata"
367
+ raise ValueError(msg)
368
+
369
+
370
+ def _read_band_radiance_rescaling(meta: str, band: str) -> tuple[float, float]:
371
+ """Read radiance rescaling factors from metadata file."""
372
+ band = band[1:] # strip leading B
373
+ mult = _read_meta(meta, f"RADIANCE_MULT_BAND_{band}")
374
+ add = _read_meta(meta, f"RADIANCE_ADD_BAND_{band}")
375
+ return mult, add
376
+
377
+
378
+ def _read_band_reflectance_rescaling(meta: str, band: str) -> tuple[float, float]:
379
+ """Read reflectance rescaling factors from metadata file."""
380
+ band = band[1:] # strip leading B
381
+ mult = _read_meta(meta, f"REFLECTANCE_MULT_BAND_{band}")
382
+ add = _read_meta(meta, f"REFLECTANCE_ADD_BAND_{band}")
383
+ return mult, add
384
+
385
+
386
+ def _read_band_thermal_constants(meta: str, band: str) -> tuple[float, float]:
387
+ """Read constants for radiance to brightness temperature conversion from metadata file."""
388
+ band = band[1:] # strip leading B
389
+ k1 = _read_meta(meta, f"K1_CONSTANT_BAND_{band}")
390
+ k2 = _read_meta(meta, f"K2_CONSTANT_BAND_{band}")
391
+ return k1, k2
392
+
393
+
394
+ def _read_image_coordinates(meta: str, band: str) -> tuple[np.ndarray, np.ndarray]:
395
+ """Read image x and y coordinates."""
396
+
397
+ # Get coordinates of corners
398
+ ulx = _read_meta(meta, "CORNER_UL_PROJECTION_X_PRODUCT")
399
+ uly = _read_meta(meta, "CORNER_UL_PROJECTION_Y_PRODUCT")
400
+ urx = _read_meta(meta, "CORNER_UR_PROJECTION_X_PRODUCT")
401
+ ury = _read_meta(meta, "CORNER_UR_PROJECTION_Y_PRODUCT")
402
+ llx = _read_meta(meta, "CORNER_LL_PROJECTION_X_PRODUCT")
403
+ lly = _read_meta(meta, "CORNER_LL_PROJECTION_Y_PRODUCT")
404
+ lrx = _read_meta(meta, "CORNER_LR_PROJECTION_X_PRODUCT")
405
+ lry = _read_meta(meta, "CORNER_LR_PROJECTION_Y_PRODUCT")
406
+ if ulx != llx or urx != lrx or uly != ury or lly != lry:
407
+ msg = "Retrieved Landsat image is not aligned with X and Y coordinates"
408
+ raise ValueError(msg)
409
+ xlim = (ulx, urx)
410
+ ylim = (uly, lly)
411
+
412
+ # Get size of pixels
413
+ category = (
414
+ "PANCHROMATIC" if band == "B8" else "THERMAL" if band in ("B10", "B11") else "REFLECTIVE"
415
+ )
416
+ pixel_size = _read_meta(meta, f"GRID_CELL_SIZE_{category}")
417
+
418
+ # Compute pixel coordinates
419
+ nx = np.round((xlim[1] - xlim[0]) / pixel_size).astype(int) + 1
420
+ ny = np.round((ylim[0] - ylim[1]) / pixel_size).astype(int) + 1
421
+ x = np.linspace(xlim[0], xlim[1], nx)
422
+ y = np.linspace(ylim[0], ylim[1], ny)
423
+
424
+ return x, y
425
+
426
+
427
+ def extract_landsat_visualization(
428
+ ds: xr.Dataset, color_scheme: str = "true"
429
+ ) -> tuple[np.ndarray, pyproj.CRS, tuple[float, float, float, float]]:
430
+ """Extract artifacts for visualizing Landsat data with the given color scheme.
431
+
432
+ Parameters
433
+ ----------
434
+ ds : xr.Dataset
435
+ Dataset of Landsat data as returned by :meth:`Landsat.get`.
436
+ color_scheme : str = {"true", "google_contrails"}
437
+ Color scheme to use for visualization. The true color scheme
438
+ requires reflectances for bands B2, B3, and B4; and the
439
+ `Google contrails color scheme <https://research.google/pubs/a-human-labeled-landsat-contrails-dataset>`__
440
+ requires reflectance for band B9 and brightness temperatures for bands B10 and B11.
441
+
442
+ Returns
443
+ -------
444
+ rgb : npt.NDArray[np.float32]
445
+ 3D RGB array of shape ``(height, width, 3)``.
446
+ src_crs : pyproj.CRS
447
+ Imagery projection
448
+ src_extent : tuple[float,float,float,float]
449
+ Imagery extent in projected coordinates
450
+
451
+ References
452
+ ----------
453
+ :cite:`mccloskeyHumanlabeledLandsatContrails2021`
454
+ """
455
+
456
+ if color_scheme == "true":
457
+ rgb, src_crs = to_true_color(ds)
458
+ elif color_scheme == "google_contrails":
459
+ rgb, src_crs = to_google_contrails(ds)
460
+ else:
461
+ raise ValueError(f"Color scheme must be 'true' or 'google_contrails', not '{color_scheme}'")
462
+
463
+ x = ds["x"].values
464
+ y = ds["y"].values
465
+ src_extent = x.min(), x.max(), y.min(), y.max()
466
+
467
+ return rgb, src_crs, src_extent
468
+
469
+
470
+ def to_true_color(ds: xr.Dataset) -> tuple[np.ndarray, pyproj.CRS]:
471
+ """Compute 3d RGB array for the true color scheme.
472
+
473
+ Parameters
474
+ ----------
475
+ ds : xr.Dataset
476
+ DataArray of Landsat data with reflectances for bands B2, B3, and B4.
477
+
478
+ Returns
479
+ -------
480
+ np.ndarray
481
+ 3d RGB array with true color scheme.
482
+
483
+ src_crs : pyproj.CRS
484
+ Imagery projection
485
+ """
486
+ red = ds["B4"]
487
+ green = ds["B3"]
488
+ blue = ds["B2"]
489
+
490
+ crs = red.attrs["crs"]
491
+ if not (crs.equals(green.attrs["crs"]) and crs.equals(blue.attrs["crs"])):
492
+ msg = "Bands B2, B3, and B4 do not share a common projection."
493
+ raise ValueError(msg)
494
+
495
+ if any("reflectance" not in band.attrs["long_name"] for band in (red, green, blue)):
496
+ msg = "Bands B2, B3, and B4 must contain reflectances."
497
+ raise ValueError(msg)
498
+
499
+ img = np.dstack(
500
+ [equalize(normalize(band.values), clip_limit=0.03) for band in (red, green, blue)]
501
+ )
502
+
503
+ return img, crs
504
+
505
+
506
+ def to_google_contrails(ds: xr.Dataset) -> tuple[np.ndarray, pyproj.CRS]:
507
+ """Compute 3d RGB array for the Google contrails color scheme.
508
+
509
+ Parameters
510
+ ----------
511
+ ds : xr.Dataset
512
+ DataArray of Landsat data with reflectance for band B9 and brightness
513
+ temperature for bands B10 and B11.
514
+
515
+ Returns
516
+ -------
517
+ np.ndarray
518
+ 3d RGB array with Google landsat color scheme.
519
+
520
+ src_crs : pyproj.CRS
521
+ Imagery projection
522
+
523
+ References
524
+ ----------
525
+ - `Google human-labeled Landsat contrails dataset <https://research.google/pubs/a-human-labeled-landsat-contrails-dataset/>`__
526
+ - :cite:`mccloskeyHumanlabeledLandsatContrails2021`
527
+ """
528
+ rc = ds["B9"] # cirrus band reflectance
529
+ tb11 = ds["B10"] # 11 um brightness temperature
530
+ tb12 = ds["B11"] # 12 um brightness temperature
531
+
532
+ crs = rc.attrs["crs"]
533
+ if not (crs.equals(tb11.attrs["crs"]) and crs.equals(tb12.attrs["crs"])):
534
+ msg = "Bands B9, B10, and B11 do not share a common projection."
535
+ raise ValueError(msg)
536
+
537
+ if "reflectance" not in rc.attrs["long_name"]:
538
+ msg = "Band B9 must contain reflectance."
539
+ raise ValueError(msg)
540
+
541
+ if any("brightness temperature" not in band.attrs["long_name"] for band in (tb11, tb12)):
542
+ msg = "Bands B10 and B11 must contain brightness temperature."
543
+ raise ValueError(msg)
544
+
545
+ def adapt(channel: np.ndarray) -> np.ndarray:
546
+ if np.all(np.isclose(channel, 0, atol=1e-3)) or np.all(np.isclose(channel, 1, atol=1e-3)):
547
+ return channel
548
+ return equalize(channel, clip_limit=0.03)
549
+
550
+ # red: 12um - 11um brightness temperature difference
551
+ signal = tb12.values - tb11.values
552
+ lower = -5.5
553
+ upper = 1.0
554
+ red = ((signal - lower) / (upper - lower)).clip(0.0, 1.0)
555
+
556
+ # green: cirrus band transmittance
557
+ signal = 1 - rc.values
558
+ lower = 0.8
559
+ upper = 1.0
560
+ green = adapt(((signal - lower) / (upper - lower)).clip(0.0, 1.0))
561
+
562
+ # blue: 12um brightness temperature
563
+ signal = tb12.values
564
+ lower = 283.0
565
+ upper = 303.0
566
+ blue = adapt(((signal - lower) / (upper - lower)).clip(0.0, 1.0))
567
+
568
+ img = np.dstack([red, green, blue])
569
+ return img, crs