pycontrails 0.56.0__cp313-cp313-win_amd64.whl → 0.58.0__cp313-cp313-win_amd64.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 (40) hide show
  1. pycontrails/_version.py +3 -3
  2. pycontrails/core/aircraft_performance.py +1 -1
  3. pycontrails/core/cache.py +2 -2
  4. pycontrails/core/fleet.py +2 -7
  5. pycontrails/core/flight.py +2 -7
  6. pycontrails/core/interpolation.py +42 -64
  7. pycontrails/core/met.py +36 -16
  8. pycontrails/core/polygon.py +3 -3
  9. pycontrails/core/rgi_cython.cp313-win_amd64.pyd +0 -0
  10. pycontrails/core/vector.py +3 -8
  11. pycontrails/datalib/_met_utils/metsource.py +4 -7
  12. pycontrails/datalib/ecmwf/common.py +2 -2
  13. pycontrails/datalib/ecmwf/hres.py +2 -2
  14. pycontrails/datalib/ecmwf/ifs.py +1 -1
  15. pycontrails/datalib/geo_utils.py +261 -0
  16. pycontrails/datalib/gfs/gfs.py +59 -65
  17. pycontrails/datalib/goes.py +193 -399
  18. pycontrails/datalib/himawari/__init__.py +27 -0
  19. pycontrails/datalib/himawari/header_struct.py +266 -0
  20. pycontrails/datalib/himawari/himawari.py +667 -0
  21. pycontrails/datalib/leo_utils/sentinel_metadata.py +9 -9
  22. pycontrails/ext/synthetic_flight.py +2 -2
  23. pycontrails/models/cocip/cocip_uncertainty.py +1 -1
  24. pycontrails/models/cocip/contrail_properties.py +1 -1
  25. pycontrails/models/cocip/output_formats.py +1 -1
  26. pycontrails/models/cocipgrid/cocip_grid.py +3 -3
  27. pycontrails/models/dry_advection.py +1 -1
  28. pycontrails/models/extended_k15.py +4 -4
  29. pycontrails/models/humidity_scaling/humidity_scaling.py +2 -2
  30. pycontrails/models/ps_model/ps_grid.py +2 -2
  31. pycontrails/models/sac.py +1 -1
  32. pycontrails/models/tau_cirrus.py +1 -1
  33. pycontrails/physics/thermo.py +1 -1
  34. pycontrails/utils/iteration.py +1 -1
  35. {pycontrails-0.56.0.dist-info → pycontrails-0.58.0.dist-info}/METADATA +6 -6
  36. {pycontrails-0.56.0.dist-info → pycontrails-0.58.0.dist-info}/RECORD +40 -36
  37. {pycontrails-0.56.0.dist-info → pycontrails-0.58.0.dist-info}/WHEEL +0 -0
  38. {pycontrails-0.56.0.dist-info → pycontrails-0.58.0.dist-info}/licenses/LICENSE +0 -0
  39. {pycontrails-0.56.0.dist-info → pycontrails-0.58.0.dist-info}/licenses/NOTICE +0 -0
  40. {pycontrails-0.56.0.dist-info → pycontrails-0.58.0.dist-info}/top_level.txt +0 -0
@@ -18,7 +18,9 @@ import datetime
18
18
  import enum
19
19
  import os
20
20
  import tempfile
21
+ import warnings
21
22
  from collections.abc import Iterable
23
+ from typing import TYPE_CHECKING, Any
22
24
 
23
25
  import numpy as np
24
26
  import numpy.typing as npt
@@ -26,18 +28,15 @@ import pandas as pd
26
28
  import xarray as xr
27
29
 
28
30
  from pycontrails.core import cache
29
- from pycontrails.core.met import XArrayType
31
+ from pycontrails.datalib import geo_utils
32
+ from pycontrails.datalib.geo_utils import (
33
+ parallax_correct, # noqa: F401, keep for backwards compatibility
34
+ to_ash, # keep for backwards compatibility
35
+ )
30
36
  from pycontrails.utils import dependencies
31
37
 
32
- try:
33
- import cartopy.crs as ccrs
34
- except ModuleNotFoundError as exc:
35
- dependencies.raise_module_not_found_error(
36
- name="goes module",
37
- package_name="cartopy",
38
- module_not_found_error=exc,
39
- pycontrails_optional_package="sat",
40
- )
38
+ if TYPE_CHECKING:
39
+ import cartopy.crs
41
40
 
42
41
  try:
43
42
  import gcsfs
@@ -50,9 +49,9 @@ except ModuleNotFoundError as exc:
50
49
  )
51
50
 
52
51
 
53
- #: Default channels to use if none are specified. These are the channels
52
+ #: Default bands to use if none are specified. These are the bands
54
53
  #: required by the SEVIRI (MIT) ash color scheme.
55
- DEFAULT_CHANNELS = "C11", "C14", "C15"
54
+ DEFAULT_BANDS = "C11", "C14", "C15"
56
55
 
57
56
  #: The time at which the GOES scan mode changed from mode 3 to mode 6. This
58
57
  #: is used to determine the scan time resolution.
@@ -128,29 +127,27 @@ def _check_time_resolution(t: datetime.datetime, region: GOESRegion) -> datetime
128
127
  return t
129
128
 
130
129
 
131
- def _parse_channels(channels: str | Iterable[str] | None) -> set[str]:
132
- """Check that the channels are valid and return as a set."""
133
- if channels is None:
134
- return set(DEFAULT_CHANNELS)
130
+ def _parse_bands(bands: str | Iterable[str] | None) -> set[str]:
131
+ """Check that the bands are valid and return as a set."""
132
+ if bands is None:
133
+ return set(DEFAULT_BANDS)
135
134
 
136
- if isinstance(channels, str):
137
- channels = (channels,)
135
+ if isinstance(bands, str):
136
+ bands = (bands,)
138
137
 
139
138
  available = {f"C{i:02d}" for i in range(1, 17)}
140
- channels = {c.upper() for c in channels}
141
- if not channels.issubset(available):
142
- raise ValueError(f"Channels must be in {sorted(available)}")
143
- return channels
144
-
139
+ bands = {c.upper() for c in bands}
140
+ if not bands.issubset(available):
141
+ raise ValueError(f"Bands must be in {sorted(available)}")
142
+ return bands
145
143
 
146
- def _check_channel_resolution(channels: Iterable[str]) -> None:
147
- """Confirm request channels have a common horizontal resolution."""
148
- assert channels, "channels must be non-empty"
149
144
 
145
+ def _check_band_resolution(bands: Iterable[str]) -> None:
146
+ """Confirm request bands have a common horizontal resolution."""
150
147
  # https://www.goes-r.gov/spacesegment/abi.html
151
- resolutions = {
148
+ res = {
152
149
  "C01": 1.0,
153
- "C02": 1.0, # XXX: this actually has a resolution of 0.5 km, but we treat it as 1 km
150
+ "C02": 1.0, # XXX: this actually has a resolution of 0.5 km, but we coarsen it to 1 km
154
151
  "C03": 1.0,
155
152
  "C04": 2.0,
156
153
  "C05": 1.0,
@@ -167,18 +164,15 @@ def _check_channel_resolution(channels: Iterable[str]) -> None:
167
164
  "C16": 2.0,
168
165
  }
169
166
 
170
- resolutions = {c: resolutions[c] for c in channels}
171
- c0, res0 = resolutions.popitem()
172
-
173
- try:
174
- c1, res1 = next((c, res) for c, res in resolutions.items() if res != res0)
175
- except StopIteration:
176
- # All resolutions are the same
177
- return
178
- raise ValueError(
179
- "Channels must have a common horizontal resolution. "
180
- f"Channel {c0} has resolution {res0} km and channel {c1} has resolution {res1} km."
181
- )
167
+ found_res = {b: res[b] for b in bands}
168
+ unique_res = set(found_res.values())
169
+ if len(unique_res) > 1:
170
+ b0, r0 = found_res.popitem()
171
+ b1, r1 = next((b, r) for b, r in found_res.items() if r != r0)
172
+ raise ValueError(
173
+ "Bands must have a common horizontal resolution. "
174
+ f"Band {b0} has resolution {r0} km and band {b1} has resolution {r1} km."
175
+ )
182
176
 
183
177
 
184
178
  def _parse_region(region: GOESRegion | str) -> GOESRegion:
@@ -202,11 +196,11 @@ def _parse_region(region: GOESRegion | str) -> GOESRegion:
202
196
  def gcs_goes_path(
203
197
  time: datetime.datetime,
204
198
  region: GOESRegion,
205
- channels: str | Iterable[str] | None = None,
199
+ bands: str | Iterable[str] | None = None,
206
200
  bucket: str | None = None,
207
201
  fs: gcsfs.GCSFileSystem | None = None,
208
202
  ) -> list[str]:
209
- """Return GCS paths to GOES data at the given time for the given region and channels.
203
+ """Return GCS paths to GOES data at the given time for the given region and bands.
210
204
 
211
205
  Presently only supported for GOES data whose scan time minute coincides with
212
206
  the minute of the time parameter.
@@ -218,11 +212,11 @@ def gcs_goes_path(
218
212
  ISO 8601 formatted string.
219
213
  region : GOESRegion
220
214
  GOES Region of interest.
221
- channels : str | Iterable[str]
222
- Set of channels or bands for CMIP data. The 16 possible channels are
215
+ bands : str | Iterable[str] | None, optional
216
+ Set of bands or bands for CMIP data. The 16 possible bands are
223
217
  represented by the strings "C01" to "C16". For the SEVIRI ash color scheme,
224
- set ``channels=("C11", "C14", "C15")``. For the true color scheme,
225
- set ``channels=("C01", "C02", "C03")``. By default, the channels
218
+ set ``bands=("C11", "C14", "C15")``. For the true color scheme,
219
+ set ``bands=("C01", "C02", "C03")``. By default, the bands
226
220
  required by the SEVIRI ash color scheme are used.
227
221
  bucket : str | None
228
222
  GCS bucket for GOES data. If None, the bucket is automatically
@@ -241,25 +235,25 @@ def gcs_goes_path(
241
235
  >>> from pprint import pprint
242
236
  >>> t = datetime.datetime(2023, 4, 3, 2, 10)
243
237
 
244
- >>> paths = gcs_goes_path(t, GOESRegion.F, channels=("C11", "C12", "C13"))
238
+ >>> paths = gcs_goes_path(t, GOESRegion.F, bands=("C11", "C12", "C13"))
245
239
  >>> pprint(paths)
246
240
  ['gcp-public-data-goes-16/ABI-L2-CMIPF/2023/093/02/OR_ABI-L2-CMIPF-M6C11_G16_s20230930210203_e20230930219511_c20230930219586.nc',
247
241
  'gcp-public-data-goes-16/ABI-L2-CMIPF/2023/093/02/OR_ABI-L2-CMIPF-M6C12_G16_s20230930210203_e20230930219516_c20230930219596.nc',
248
242
  'gcp-public-data-goes-16/ABI-L2-CMIPF/2023/093/02/OR_ABI-L2-CMIPF-M6C13_G16_s20230930210203_e20230930219523_c20230930219586.nc']
249
243
 
250
- >>> paths = gcs_goes_path(t, GOESRegion.C, channels=("C11", "C12", "C13"))
244
+ >>> paths = gcs_goes_path(t, GOESRegion.C, bands=("C11", "C12", "C13"))
251
245
  >>> pprint(paths)
252
246
  ['gcp-public-data-goes-16/ABI-L2-CMIPC/2023/093/02/OR_ABI-L2-CMIPC-M6C11_G16_s20230930211170_e20230930213543_c20230930214055.nc',
253
247
  'gcp-public-data-goes-16/ABI-L2-CMIPC/2023/093/02/OR_ABI-L2-CMIPC-M6C12_G16_s20230930211170_e20230930213551_c20230930214045.nc',
254
248
  'gcp-public-data-goes-16/ABI-L2-CMIPC/2023/093/02/OR_ABI-L2-CMIPC-M6C13_G16_s20230930211170_e20230930213557_c20230930214065.nc']
255
249
 
256
250
  >>> t = datetime.datetime(2023, 4, 3, 2, 11)
257
- >>> paths = gcs_goes_path(t, GOESRegion.M1, channels="C01")
251
+ >>> paths = gcs_goes_path(t, GOESRegion.M1, bands="C01")
258
252
  >>> pprint(paths)
259
253
  ['gcp-public-data-goes-16/ABI-L2-CMIPM/2023/093/02/OR_ABI-L2-CMIPM1-M6C01_G16_s20230930211249_e20230930211309_c20230930211386.nc']
260
254
 
261
255
  >>> t = datetime.datetime(2025, 5, 4, 3, 2)
262
- >>> paths = gcs_goes_path(t, GOESRegion.M2, channels="C01")
256
+ >>> paths = gcs_goes_path(t, GOESRegion.M2, bands="C01")
263
257
  >>> pprint(paths)
264
258
  ['gcp-public-data-goes-19/ABI-L2-CMIPM/2025/124/03/OR_ABI-L2-CMIPM2-M6C01_G19_s20251240302557_e20251240303014_c20251240303092.nc']
265
259
 
@@ -305,22 +299,22 @@ def gcs_goes_path(
305
299
  raise ValueError(msg) from exc
306
300
  name_suffix = f"_G{satellite_number}_s{time_str}*"
307
301
 
308
- channels = _parse_channels(channels)
302
+ bands = _parse_bands(bands)
309
303
 
310
304
  # It's faster to run a single glob with C?? then running a glob for
311
- # each channel. The downside is that we have to filter the results.
305
+ # each band. The downside is that we have to filter the results.
312
306
  rpath = f"{path_prefix}{name_prefix}C??{name_suffix}"
313
307
 
314
308
  fs = fs or gcsfs.GCSFileSystem(token="anon")
315
- rpaths: list[str] = fs.glob(rpath)
309
+ rpaths = fs.glob(rpath)
316
310
 
317
- out = [r for r in rpaths if _extract_channel_from_rpath(r) in channels]
311
+ out = [r for r in rpaths if _extract_band_from_rpath(r) in bands]
318
312
  if not out:
319
- raise RuntimeError(f"No data found for {time} in {region} for channels {channels}")
313
+ raise RuntimeError(f"No data found for {time} in {region} for bands {bands}")
320
314
  return out
321
315
 
322
316
 
323
- def _extract_channel_from_rpath(rpath: str) -> str:
317
+ def _extract_band_from_rpath(rpath: str) -> str:
324
318
  # Split at the separator between product name and mode
325
319
  # This works for both M3 and M6
326
320
  sep = "-M"
@@ -329,11 +323,13 @@ def _extract_channel_from_rpath(rpath: str) -> str:
329
323
 
330
324
 
331
325
  class GOES:
332
- """Support for GOES-16 data handling.
326
+ """Support for GOES-16 data access via GCP.
327
+
328
+ This interface requires the ``gcsfs`` package.
333
329
 
334
330
  Parameters
335
331
  ----------
336
- region : GOESRegion | str = {"F", "C", "M1", "M2"}
332
+ region : GOESRegion | str, optional
337
333
  GOES Region of interest. Uses the following conventions.
338
334
 
339
335
  - F: Full Disk
@@ -341,12 +337,14 @@ class GOES:
341
337
  - M1: Mesoscale 1
342
338
  - M2: Mesoscale 2
343
339
 
344
- channels : str | set[str] | None
345
- Set of channels or bands for CMIP data. The 16 possible channels are
340
+ By default, Full Disk (F) is used.
341
+
342
+ bands : str | Iterable[str] | None
343
+ Set of bands or bands for CMIP data. The 16 possible bands are
346
344
  represented by the strings "C01" to "C16". For the SEVIRI ash color scheme,
347
- set ``channels=("C11", "C14", "C15")``. For the true color scheme,
348
- set ``channels=("C01", "C02", "C03")``. By default, the channels
349
- required by the SEVIRI ash color scheme are used. The channels must have
345
+ set ``bands=("C11", "C14", "C15")``. For the true color scheme,
346
+ set ``bands=("C01", "C02", "C03")``. By default, the bands
347
+ required by the SEVIRI ash color scheme are used. The bands must have
350
348
  a common horizontal resolution. The resolutions are:
351
349
 
352
350
  - C01: 1.0 km
@@ -356,11 +354,11 @@ class GOES:
356
354
  - C05: 1.0 km
357
355
  - C06 - C16: 2.0 km
358
356
 
359
- cachestore : cache.CacheStore | None
357
+ cachestore : cache.CacheStore | None, optional
360
358
  Cache store for GOES data. If None, data is downloaded directly into
361
359
  memory. By default, a :class:`cache.DiskCacheStore` is used.
362
- goes_bucket : str | None = None
363
- GCP bucket for GOES data. If None, the bucket is automatically
360
+ bucket : str | None, optional
361
+ GCP bucket for GOES data. If None, the default option, the bucket is automatically
364
362
  set to ``GOES_16_BUCKET`` if the requested time is before
365
363
  ``GOES_16_19_SWITCH_DATE`` and ``GOES_19_BUCKET`` otherwise.
366
364
  The satellite number used for filename construction is derived from the
@@ -373,7 +371,7 @@ class GOES:
373
371
 
374
372
  Examples
375
373
  --------
376
- >>> goes = GOES(region="M1", channels=("C11", "C14"))
374
+ >>> goes = GOES(region="M1", bands=("C11", "C14"))
377
375
  >>> da = goes.get("2021-04-03 02:10:00")
378
376
  >>> da.shape
379
377
  (2, 500, 500)
@@ -397,7 +395,7 @@ class GOES:
397
395
  >>> assert goes.cachestore.listdir()
398
396
 
399
397
  >>> # Download GOES data directly into memory by setting cachestore=None
400
- >>> goes = GOES(region="M2", channels=("C11", "C12", "C13"), cachestore=None)
398
+ >>> goes = GOES(region="M2", bands=("C11", "C12", "C13"), cachestore=None)
401
399
  >>> da = goes.get("2021-04-03 02:10:00")
402
400
 
403
401
  >>> da.shape
@@ -434,15 +432,39 @@ class GOES:
434
432
  def __init__(
435
433
  self,
436
434
  region: GOESRegion | str = GOESRegion.F,
437
- channels: str | Iterable[str] | None = None,
435
+ bands: str | Iterable[str] | None = None,
436
+ *,
437
+ channels: str | Iterable[str] | None = None, # deprecated alias for bands
438
438
  cachestore: cache.CacheStore | None = __marker, # type: ignore[assignment]
439
- goes_bucket: str | None = None,
439
+ bucket: str | None = None,
440
+ goes_bucket: str | None = None, # deprecated alias for bucket
440
441
  ) -> None:
442
+ if channels is not None:
443
+ if bands is not None:
444
+ raise ValueError("Only one of channels or bands should be specified")
445
+ warnings.warn(
446
+ "The 'channels' parameter is deprecated and will be removed in a future release. "
447
+ "Use 'bands' instead.",
448
+ DeprecationWarning,
449
+ stacklevel=2,
450
+ )
451
+ bands = channels
452
+ if goes_bucket is not None:
453
+ if bucket is not None:
454
+ raise ValueError("Only one of goes_bucket or bucket should be specified")
455
+ warnings.warn(
456
+ "The 'goes_bucket' parameter is deprecated and will be removed in a future release."
457
+ "Use 'bucket' instead.",
458
+ DeprecationWarning,
459
+ stacklevel=2,
460
+ )
461
+ bucket = goes_bucket
462
+
441
463
  self.region = _parse_region(region)
442
- self.channels = _parse_channels(channels)
443
- _check_channel_resolution(self.channels)
464
+ self.bands = _parse_bands(bands)
465
+ _check_band_resolution(self.bands)
444
466
 
445
- self.goes_bucket = goes_bucket
467
+ self.bucket = bucket
446
468
  self.fs = gcsfs.GCSFileSystem(token="anon")
447
469
 
448
470
  if cachestore is self.__marker:
@@ -454,11 +476,10 @@ class GOES:
454
476
  def __repr__(self) -> str:
455
477
  """Return string representation."""
456
478
  return (
457
- f"GOES(region={self.region}, channels={sorted(self.channels)}, "
458
- f"goes_bucket={self.goes_bucket})"
479
+ f"GOES(region='{self.region.name}', bands={sorted(self.bands)}, bucket={self.bucket})"
459
480
  )
460
481
 
461
- def gcs_goes_path(self, time: datetime.datetime, channels: set[str] | None = None) -> list[str]:
482
+ def gcs_goes_path(self, time: datetime.datetime, bands: set[str] | None = None) -> list[str]:
462
483
  """Return GCS paths to GOES data at given time.
463
484
 
464
485
  Presently only supported for GOES data whose scan time minute coincides with
@@ -468,8 +489,8 @@ class GOES:
468
489
  ----------
469
490
  time : datetime.datetime
470
491
  Time of GOES data.
471
- channels : set[str] | None
472
- Set of channels or bands for CMIP data. If None, the :attr:`channels`
492
+ bands : set[str] | None
493
+ Set of bands or bands for CMIP data. If None, the :attr:`bands`
473
494
  attribute is used.
474
495
 
475
496
  Returns
@@ -477,27 +498,28 @@ class GOES:
477
498
  list[str]
478
499
  List of GCS paths to GOES data.
479
500
  """
480
- channels = channels or self.channels
481
- return gcs_goes_path(time, self.region, channels, bucket=self.goes_bucket, fs=self.fs)
501
+ bands = bands or self.bands
502
+ return gcs_goes_path(time, self.region, bands, bucket=self.bucket, fs=self.fs)
482
503
 
483
504
  def _lpaths(self, time: datetime.datetime) -> dict[str, str]:
484
505
  """Construct names for local netcdf files using the :attr:`cachestore`.
485
506
 
486
- Returns dictionary of the form ``{channel: local_path}``.
507
+ Returns dictionary of the form ``{band: local_path}``.
487
508
  """
488
- assert self.cachestore, "cachestore must be set"
509
+ if not self.cachestore:
510
+ raise ValueError("cachestore must be set to use _lpaths")
489
511
 
490
512
  t_str = time.strftime("%Y%m%d%H%M")
491
513
 
492
514
  out = {}
493
- for c in self.channels:
494
- if self.goes_bucket:
495
- name = f"{self.goes_bucket}_{self.region.name}_{t_str}_{c}.nc"
515
+ for band in self.bands:
516
+ if self.bucket:
517
+ name = f"{self.bucket}_{self.region.name}_{t_str}_{band}.nc"
496
518
  else:
497
- name = f"{self.region.name}_{t_str}_{c}.nc"
519
+ name = f"{self.region.name}_{t_str}_{band}.nc"
498
520
 
499
521
  lpath = self.cachestore.path(name)
500
- out[c] = lpath
522
+ out[band] = lpath
501
523
 
502
524
  return out
503
525
 
@@ -519,29 +541,25 @@ class GOES:
519
541
  - x: GOES x-coordinate
520
542
  - y: GOES y-coordinate
521
543
  """
522
- if not isinstance(time, datetime.datetime):
523
- time = pd.Timestamp(time).to_pydatetime()
544
+ t = pd.Timestamp(time).to_pydatetime()
524
545
 
525
546
  if self.cachestore is not None:
526
- return self._get_with_cache(time) # type: ignore[arg-type]
527
- return self._get_without_cache(time) # type: ignore[arg-type]
547
+ return self._get_with_cache(t)
548
+ return self._get_without_cache(t)
528
549
 
529
550
  def _get_with_cache(self, time: datetime.datetime) -> xr.DataArray:
530
551
  """Download the GOES data to the :attr:`cachestore` at the given time."""
531
- assert self.cachestore, "cachestore must be set"
552
+ if self.cachestore is None:
553
+ raise ValueError("cachestore must be set to use _get_with_cache")
532
554
 
533
555
  lpaths = self._lpaths(time)
556
+ bands_needed = {c for c, lpath in lpaths.items() if not self.cachestore.exists(lpath)}
534
557
 
535
- channels_needed = set()
536
- for c, lpath in lpaths.items():
537
- if not self.cachestore.exists(lpath):
538
- channels_needed.add(c)
539
-
540
- if channels_needed:
541
- rpaths = self.gcs_goes_path(time, channels_needed)
558
+ if bands_needed:
559
+ rpaths = self.gcs_goes_path(time, bands_needed)
542
560
  for rpath in rpaths:
543
- channel = _extract_channel_from_rpath(rpath)
544
- lpath = lpaths[channel]
561
+ band = _extract_band_from_rpath(rpath)
562
+ lpath = lpaths[band]
545
563
  self.fs.get(rpath, lpath)
546
564
 
547
565
  # Deal with the different spatial resolutions
@@ -552,19 +570,19 @@ class GOES:
552
570
  "compat": "override",
553
571
  "coords": "minimal",
554
572
  }
555
- if len(lpaths) == 1:
556
- ds = xr.open_dataset(lpaths.popitem()[1])
557
- ds["CMI"] = ds["CMI"].expand_dims(band=ds["band_id"].values)
558
- elif "C02" in lpaths:
573
+ if len(lpaths) > 1 and "C02" in lpaths: # xr.open_mfdataset fails after pop if only 1 file
559
574
  lpath02 = lpaths.pop("C02")
560
- ds1 = xr.open_mfdataset(lpaths.values(), **kwargs) # type: ignore[arg-type]
561
- ds2 = xr.open_dataset(lpath02)
562
- ds = _concat_c02(ds1, ds2)
575
+ ds = xr.open_mfdataset(lpaths.values(), **kwargs).swap_dims(band="band_id") # type: ignore[arg-type]
576
+ da1 = ds.reset_coords()["CMI"]
577
+ da2 = xr.open_dataset(lpath02).reset_coords()["CMI"].expand_dims(band_id=[2])
578
+ da = (
579
+ geo_utils._coarsen_then_concat(da1, da2)
580
+ .sortby("band_id")
581
+ .assign_coords(t=ds["t"].values)
582
+ )
563
583
  else:
564
- ds = xr.open_mfdataset(lpaths.values(), **kwargs) # type: ignore[arg-type]
565
-
566
- da = ds["CMI"]
567
- da = da.swap_dims({"band": "band_id"}).sortby("band_id")
584
+ ds = xr.open_mfdataset(lpaths.values(), **kwargs).swap_dims(band="band_id") # type: ignore[arg-type]
585
+ da = ds["CMI"].sortby("band_id")
568
586
 
569
587
  # Attach some useful attrs -- only using goes_imager_projection currently
570
588
  da.attrs["goes_imager_projection"] = ds.goes_imager_projection.attrs
@@ -579,29 +597,21 @@ class GOES:
579
597
  # Load into memory
580
598
  data = self.fs.cat(rpaths)
581
599
 
582
- if isinstance(data, dict):
583
- da_dict = {}
584
- for rpath, init_bytes in data.items():
585
- channel = _extract_channel_from_rpath(rpath)
586
- ds = _load_via_tempfile(init_bytes)
587
-
588
- da = ds["CMI"]
589
- da = da.expand_dims(band_id=ds["band_id"].values)
590
- da_dict[channel] = da
591
-
592
- if len(da_dict) == 1: # This might be redundant with the branch below
593
- da = da_dict.popitem()[1]
594
- elif "C02" in da_dict:
595
- da2 = da_dict.pop("C02")
596
- da = xr.concat(da_dict.values(), dim="band_id", coords="different", compat="equals")
597
- da = _concat_c02(da, da2)
598
- else:
599
- da = xr.concat(da_dict.values(), dim="band_id", coords="different", compat="equals")
600
+ da_dict = {}
601
+ for rpath, init_bytes in data.items():
602
+ band = _extract_band_from_rpath(rpath)
603
+ ds = _load_via_tempfile(init_bytes)
600
604
 
601
- else:
602
- ds = _load_via_tempfile(data)
603
605
  da = ds["CMI"]
604
606
  da = da.expand_dims(band_id=ds["band_id"].values)
607
+ da_dict[band] = da
608
+
609
+ if len(da_dict) > 1 and "C02" in da_dict: # xr.concat fails after pop if only 1 file
610
+ da2 = da_dict.pop("C02")
611
+ da1 = xr.concat(da_dict.values(), dim="band_id", coords="different", compat="equals")
612
+ da = geo_utils._coarsen_then_concat(da1, da2)
613
+ else:
614
+ da = xr.concat(da_dict.values(), dim="band_id", coords="different", compat="equals")
605
615
 
606
616
  da = da.sortby("band_id")
607
617
 
@@ -622,57 +632,67 @@ def _load_via_tempfile(data: bytes) -> xr.Dataset:
622
632
  os.remove(tmp.name)
623
633
 
624
634
 
625
- def _concat_c02(ds1: XArrayType, ds2: XArrayType) -> XArrayType:
626
- """Concatenate two datasets with C01 and C02 data."""
627
- # Average the C02 data to the C01 resolution
628
- ds2 = ds2.coarsen(x=2, y=2, boundary="exact").mean() # type: ignore[attr-defined]
629
-
630
- # Gut check
631
- np.testing.assert_allclose(ds1["x"], ds2["x"], rtol=0.0005)
632
- np.testing.assert_allclose(ds1["y"], ds2["y"], rtol=0.0005)
633
-
634
- # Assign the C01 data to the C02 data
635
- ds2["x"] = ds1["x"]
636
- ds2["y"] = ds1["y"]
635
+ def _cartopy_crs(proj_info: dict[str, Any]) -> cartopy.crs.Geostationary:
636
+ try:
637
+ from cartopy import crs as ccrs
638
+ except ModuleNotFoundError as exc:
639
+ dependencies.raise_module_not_found_error(
640
+ name="GOES visualization",
641
+ package_name="cartopy",
642
+ module_not_found_error=exc,
643
+ pycontrails_optional_package="sat",
644
+ )
637
645
 
638
- # Finally, combine the datasets
639
- dim = "band_id" if "band_id" in ds1.dims else "band"
640
- return xr.concat([ds1, ds2], dim=dim)
646
+ globe = ccrs.Globe(
647
+ semimajor_axis=proj_info["semi_major_axis"],
648
+ semiminor_axis=proj_info["semi_minor_axis"],
649
+ )
650
+ return ccrs.Geostationary(
651
+ central_longitude=proj_info["longitude_of_projection_origin"],
652
+ satellite_height=proj_info["perspective_point_height"],
653
+ sweep_axis=proj_info["sweep_angle_axis"],
654
+ globe=globe,
655
+ )
641
656
 
642
657
 
643
- def extract_goes_visualization(
658
+ def extract_visualization(
644
659
  da: xr.DataArray,
645
660
  color_scheme: str = "ash",
646
661
  ash_convention: str = "SEVIRI",
647
662
  gamma: float = 2.2,
648
- ) -> tuple[npt.NDArray[np.float32], ccrs.Geostationary, tuple[float, float, float, float]]:
663
+ ) -> tuple[npt.NDArray[np.float32], cartopy.crs.Geostationary, tuple[float, float, float, float]]:
649
664
  """Extract artifacts for visualizing GOES data with the given color scheme.
650
665
 
651
666
  Parameters
652
667
  ----------
653
668
  da : xr.DataArray
654
- DataArray of GOES data as returned by :meth:`GOES.get`. Must have the channels
669
+ DataArray of GOES data as returned by :meth:`GOES.get`. Must have the bands
655
670
  required by :func:`to_ash`.
656
- color_scheme : str = {"ash", "true"}
657
- Color scheme to use for visualization.
658
- ash_convention : str = {"SEVIRI", "standard"}
659
- Passed into :func:`to_ash`. Only used if ``color_scheme="ash"``.
660
- gamma : float = 2.2
661
- Passed into :func:`to_true_color`. Only used if ``color_scheme="true"``.
671
+ color_scheme : str
672
+ Color scheme to use for visualization. Must be one of {"true", "ash"}.
673
+ If "true", the ``da`` must contain bands C01, C02, and C03.
674
+ If "ash", the ``da`` must contain bands C11, C14, and C15 (SEVIRI convention)
675
+ or bands C11, C13, C14, and C15 (standard convention).
676
+ ash_convention : str
677
+ Passed into :func:`to_ash`. Only used if ``color_scheme="ash"``. Must be one
678
+ of {"SEVIRI", "standard"}. By default, "SEVIRI" is used.
679
+ gamma : float
680
+ Passed into :func:`to_true_color`. Only used if ``color_scheme="true"``. By
681
+ default, 2.2 is used.
662
682
 
663
683
  Returns
664
684
  -------
665
685
  rgb : npt.NDArray[np.float32]
666
686
  3D RGB array of shape ``(height, width, 3)``. Any nan values are replaced with 0.
667
- src_crs : ccrs.Geostationary
687
+ src_crs : cartopy.crs.Geostationary
668
688
  The Geostationary projection built from the GOES metadata.
669
689
  src_extent : tuple[float, float, float, float]
670
690
  Extent of GOES data in the Geostationary projection
671
691
  """
672
692
  proj_info = da.attrs["goes_imager_projection"]
673
693
  h = proj_info["perspective_point_height"]
674
- lon0 = proj_info["longitude_of_projection_origin"]
675
- src_crs = ccrs.Geostationary(central_longitude=lon0, satellite_height=h, sweep_axis="x")
694
+
695
+ src_crs = _cartopy_crs(proj_info)
676
696
 
677
697
  if color_scheme == "true":
678
698
  rgb = to_true_color(da, gamma)
@@ -692,15 +712,18 @@ def extract_goes_visualization(
692
712
  return rgb, src_crs, src_extent
693
713
 
694
714
 
715
+ extract_goes_visualization = extract_visualization # keep for backwards compatibility
716
+
717
+
695
718
  def to_true_color(da: xr.DataArray, gamma: float = 2.2) -> npt.NDArray[np.float32]:
696
719
  """Compute 3d RGB array for the true color scheme.
697
720
 
698
721
  Parameters
699
722
  ----------
700
723
  da : xr.DataArray
701
- DataArray of GOES data with channels C01, C02, C03.
702
- gamma : float = 2.2
703
- Gamma correction for the RGB channels.
724
+ DataArray of GOES data with bands C01, C02, C03.
725
+ gamma : float, optional
726
+ Gamma correction for the RGB bands.
704
727
 
705
728
  Returns
706
729
  -------
@@ -716,248 +739,19 @@ def to_true_color(da: xr.DataArray, gamma: float = 2.2) -> npt.NDArray[np.float3
716
739
  raise ValueError(msg)
717
740
 
718
741
  red = da.sel(band_id=2).values
719
- green = da.sel(band_id=3).values
742
+ veggie = da.sel(band_id=3).values
720
743
  blue = da.sel(band_id=1).values
721
744
 
722
- red = _clip_and_scale(red, 0.0, 1.0)
723
- green = _clip_and_scale(green, 0.0, 1.0)
724
- blue = _clip_and_scale(blue, 0.0, 1.0)
745
+ red = geo_utils._clip_and_scale(red, 0.0, 1.0)
746
+ veggie = geo_utils._clip_and_scale(veggie, 0.0, 1.0)
747
+ blue = geo_utils._clip_and_scale(blue, 0.0, 1.0)
725
748
 
726
749
  red = red ** (1 / gamma)
727
- green = green ** (1 / gamma)
750
+ veggie = veggie ** (1 / gamma)
728
751
  blue = blue ** (1 / gamma)
729
752
 
730
- # Calculate "true" green channel
731
- green = 0.45 * red + 0.1 * green + 0.45 * blue
732
- green = _clip_and_scale(green, 0.0, 1.0)
733
-
734
- return np.dstack([red, green, blue])
735
-
736
-
737
- def to_ash(da: xr.DataArray, convention: str = "SEVIRI") -> npt.NDArray[np.float32]:
738
- """Compute 3d RGB array for the ASH color scheme.
739
-
740
- Parameters
741
- ----------
742
- da : xr.DataArray
743
- DataArray of GOES data with appropriate channels.
744
- convention : str = {"SEVIRI", "standard"}
745
- Convention for color space.
746
-
747
- - SEVIRI convention requires channels C11, C14, C15.
748
- Used in :cite:`kulikSatellitebasedDetectionContrails2019`.
749
- - Standard convention requires channels C11, C13, C14, C15
750
-
751
- Returns
752
- -------
753
- npt.NDArray[np.float32]
754
- 3d RGB array with ASH color scheme according to convention.
755
-
756
- References
757
- ----------
758
- - `Ash RGB quick guide (the color space and color interpretations) <https://rammb.cira.colostate.edu/training/visit/quick_guides/GOES_Ash_RGB.pdf>`_
759
- - :cite:`SEVIRIRGBCal`
760
- - :cite:`kulikSatellitebasedDetectionContrails2019`
761
-
762
- Examples
763
- --------
764
- >>> goes = GOES(region="M2", channels=("C11", "C14", "C15"))
765
- >>> da = goes.get("2022-10-03 04:34:00")
766
- >>> rgb = to_ash(da)
767
- >>> rgb.shape
768
- (500, 500, 3)
769
-
770
- >>> rgb[0, 0, :]
771
- array([0.0127004 , 0.22793579, 0.3930847 ], dtype=float32)
772
- """
773
- if convention == "standard":
774
- if not np.all(np.isin([11, 13, 14, 15], da["band_id"])):
775
- msg = "DataArray must contain bands 11, 13, 14, and 15 for standard ash"
776
- raise ValueError(msg)
777
- c11 = da.sel(band_id=11).values # 8.44
778
- c13 = da.sel(band_id=13).values # 10.33
779
- c14 = da.sel(band_id=14).values # 11.19
780
- c15 = da.sel(band_id=15).values # 12.27
781
-
782
- red = c15 - c13
783
- green = c14 - c11
784
- blue = c13
785
-
786
- elif convention in ("SEVIRI", "MIT"): # retain MIT for backwards compatibility
787
- if not np.all(np.isin([11, 14, 15], da["band_id"])):
788
- msg = "DataArray must contain bands 11, 14, and 15 for SEVIRI ash"
789
- raise ValueError(msg)
790
- c11 = da.sel(band_id=11).values # 8.44
791
- c14 = da.sel(band_id=14).values # 11.19
792
- c15 = da.sel(band_id=15).values # 12.27
793
-
794
- red = c15 - c14
795
- green = c14 - c11
796
- blue = c14
797
-
798
- else:
799
- raise ValueError("Convention must be either 'SEVIRI' or 'standard'")
753
+ # Calculate synthetic green band
754
+ green = 0.45 * red + 0.1 * veggie + 0.45 * blue
755
+ green = geo_utils._clip_and_scale(green, 0.0, 1.0)
800
756
 
801
- # See colostate pdf for slightly wider values
802
- red = _clip_and_scale(red, -4.0, 2.0)
803
- green = _clip_and_scale(green, -4.0, 5.0)
804
- blue = _clip_and_scale(blue, 243.0, 303.0)
805
757
  return np.dstack([red, green, blue])
806
-
807
-
808
- def _clip_and_scale(
809
- arr: npt.NDArray[np.floating], low: float, high: float
810
- ) -> npt.NDArray[np.floating]:
811
- """Clip array and rescale to the interval [0, 1].
812
-
813
- Array is first clipped to the interval [low, high] and then linearly rescaled
814
- to the interval [0, 1] so that::
815
-
816
- low -> 0
817
- high -> 1
818
-
819
- Parameters
820
- ----------
821
- arr : npt.NDArray[np.floating]
822
- Array to clip and scale.
823
- low : float
824
- Lower clipping bound.
825
- high : float
826
- Upper clipping bound.
827
-
828
- Returns
829
- -------
830
- npt.NDArray[np.floating]
831
- Clipped and scaled array.
832
- """
833
- return (arr.clip(low, high) - low) / (high - low)
834
-
835
-
836
- def parallax_correct(
837
- longitude: npt.NDArray[np.floating],
838
- latitude: npt.NDArray[np.floating],
839
- altitude: npt.NDArray[np.floating],
840
- goes_da: xr.DataArray,
841
- ) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]:
842
- r"""Apply parallax correction to WGS84 geodetic coordinates based on satellite perspective.
843
-
844
- This function considers the ray from the satellite to the points of interest and finds
845
- the intersection of this ray with the WGS84 ellipsoid. The intersection point is then
846
- returned as the corrected longitude and latitude coordinates.
847
-
848
- ::
849
-
850
- @ satellite
851
- \
852
- \
853
- \
854
- \
855
- \
856
- * aircraft
857
- \
858
- \
859
- x parallax corrected aircraft
860
- ------------------------- surface
861
-
862
- If the point of interest is not visible from the satellite (ie, on the opposite side of the
863
- earth), the function returns nan for the corrected coordinates.
864
-
865
- This function requires the :mod:`pyproj` package to be installed.
866
-
867
- Parameters
868
- ----------
869
- longitude : npt.NDArray[np.floating]
870
- A 1D array of longitudes in degrees.
871
- latitude : npt.NDArray[np.floating]
872
- A 1D array of latitudes in degrees.
873
- altitude : npt.NDArray[np.floating]
874
- A 1D array of altitudes in meters.
875
- goes_da : xr.DataArray
876
- DataArray containing the GOES projection information. Only the ``goes_imager_projection``
877
- field of the :attr:`xr.DataArray.attrs` is used.
878
-
879
- Returns
880
- -------
881
- tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]
882
- A tuple containing the corrected longitude and latitude coordinates.
883
-
884
- """
885
- goes_imager_projection = goes_da.attrs["goes_imager_projection"]
886
- sat_lon = goes_imager_projection["longitude_of_projection_origin"]
887
- sat_lat = goes_imager_projection["latitude_of_projection_origin"]
888
- sat_alt = goes_imager_projection["perspective_point_height"]
889
-
890
- try:
891
- import pyproj
892
- except ModuleNotFoundError as exc:
893
- dependencies.raise_module_not_found_error(
894
- name="parallax_correct function",
895
- package_name="pyproj",
896
- module_not_found_error=exc,
897
- pycontrails_optional_package="pyproj",
898
- )
899
-
900
- # Convert from WGS84 to ECEF coordinates
901
- ecef_crs = pyproj.CRS("EPSG:4978")
902
- transformer = pyproj.Transformer.from_crs("WGS84", ecef_crs, always_xy=True)
903
-
904
- p0 = np.array(transformer.transform([sat_lon], [sat_lat], [sat_alt]))
905
- p1 = np.array(transformer.transform(longitude, latitude, altitude))
906
-
907
- # Major and minor axes of the ellipsoid
908
- a = ecef_crs.ellipsoid.semi_major_metre # type: ignore[union-attr]
909
- b = ecef_crs.ellipsoid.semi_minor_metre # type: ignore[union-attr]
910
- intersection = _intersection_with_ellipsoid(p0, p1, a, b)
911
-
912
- # Convert back to WGS84 coordinates
913
- inv_transformer = pyproj.Transformer.from_crs(ecef_crs, "WGS84", always_xy=True)
914
- return inv_transformer.transform(*intersection)[:2] # final coord is (close to) 0
915
-
916
-
917
- def _intersection_with_ellipsoid(
918
- p0: npt.NDArray[np.floating],
919
- p1: npt.NDArray[np.floating],
920
- a: float,
921
- b: float,
922
- ) -> npt.NDArray[np.floating]:
923
- """Find the intersection of a line with the surface of an ellipsoid."""
924
- # Calculate the direction vector
925
- px, py, pz = p0
926
- v = p1 - p0
927
- vx, vy, vz = v
928
-
929
- # The line between p0 and p1 in parametric form is p(t) = p0 + t * v
930
- # We need to find t such that p(t) lies on the ellipsoid
931
- # x^2 / a^2 + y^2 / a^2 + z^2 / b^2 = 1
932
- # (px + t * vx)^2 / a^2 + (py + t * vy)^2 / a^2 + (pz + t * vz)^2 / b^2 = 1
933
- # Rearranging gives a quadratic in t
934
-
935
- # Calculate the coefficients of this quadratic equation
936
- A = vx**2 / a**2 + vy**2 / a**2 + vz**2 / b**2
937
- B = 2 * (px * vx / a**2 + py * vy / a**2 + pz * vz / b**2)
938
- C = px**2 / a**2 + py**2 / a**2 + pz**2 / b**2 - 1.0
939
-
940
- # Calculate the discriminant
941
- D = B**2 - 4 * A * C
942
- sqrtD = np.sqrt(D, where=D >= 0, out=np.full_like(D, np.nan))
943
-
944
- # Calculate the two possible solutions for t
945
- t0 = (-B + sqrtD) / (2.0 * A)
946
- t1 = (-B - sqrtD) / (2.0 * A)
947
-
948
- # Calculate the intersection points
949
- intersection0 = p0 + t0 * v
950
- intersection1 = p0 + t1 * v
951
-
952
- # Pick the intersection point that is closer to the aircraft (p1)
953
- d0 = np.linalg.norm(intersection0 - p1, axis=0)
954
- d1 = np.linalg.norm(intersection1 - p1, axis=0)
955
- out = np.where(d0 < d1, intersection0, intersection1)
956
-
957
- # Fill the points in which the aircraft is not visible by the satellite with nan
958
- # This occurs when the earth is between the satellite and the aircraft
959
- # In other words, we can check for t0 < 1 (or t1 < 1)
960
- opposite_side = t0 < 1.0
961
- out[:, opposite_side] = np.nan
962
-
963
- return out