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