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,772 @@
1
+ """Support for GOES access and analysis.
2
+
3
+ Resources
4
+ ---------
5
+
6
+ - `GOES 16/18 on GCP notes <https://console.cloud.google.com/marketplace/product/noaa-public/goes>`_
7
+ - `GOES on AWS notes <https://docs.opendata.aws/noaa-goes16/cics-readme.html>`_
8
+ - `Scan Mode information and timing <https://www.ospo.noaa.gov/Operations/GOES/16/GOES-16%20Scan%20Mode%206.html>`_
9
+ - `Current position of the MESO1 sector <https://www.ospo.noaa.gov/Operations/GOES/east/meso1-img.html>`_
10
+ - `Current position of the MESO2 sector <https://www.ospo.noaa.gov/Operations/GOES/east/meso2-img.html>`_
11
+ - `Historical Mesoscale regions <https://qcweb.ssec.wisc.edu/web/meso_search/>`_
12
+ - `Real time GOES data quality <https://qcweb.ssec.wisc.edu/web/abi_quality_scores/>`_
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import datetime
18
+ import enum
19
+ import tempfile
20
+ from collections.abc import Iterable
21
+
22
+ import numpy as np
23
+ import numpy.typing as npt
24
+ import pandas as pd
25
+ import xarray as xr
26
+
27
+ from pycontrails.core import cache
28
+ from pycontrails.core.met import XArrayType
29
+ from pycontrails.utils import dependencies
30
+
31
+ try:
32
+ import cartopy.crs as ccrs
33
+ except ModuleNotFoundError as exc:
34
+ dependencies.raise_module_not_found_error(
35
+ name="goes module",
36
+ package_name="cartopy",
37
+ module_not_found_error=exc,
38
+ pycontrails_optional_package="sat",
39
+ )
40
+
41
+ try:
42
+ import gcsfs
43
+ except ModuleNotFoundError as exc:
44
+ dependencies.raise_module_not_found_error(
45
+ name="goes module",
46
+ package_name="gcsfs",
47
+ module_not_found_error=exc,
48
+ pycontrails_optional_package="sat",
49
+ )
50
+
51
+
52
+ #: Default channels to use if none are specified. These are the channels
53
+ #: required by the SEVIRI (MIT) ash color scheme.
54
+ DEFAULT_CHANNELS = "C11", "C14", "C15"
55
+
56
+ #: The time at which the GOES scan mode changed from mode 3 to mode 6. This
57
+ #: is used to determine the scan time resolution.
58
+ #: See `GOES ABI scan information <https://www.goes-r.gov/users/abiScanModeInfo.html>`_.
59
+ GOES_SCAN_MODE_CHANGE = datetime.datetime(2019, 4, 2, 16)
60
+
61
+
62
+ class GOESRegion(enum.Enum):
63
+ """GOES Region of interest.
64
+
65
+ Uses the following conventions.
66
+
67
+ - F: Full Disk
68
+ - C: CONUS
69
+ - M1: Mesoscale 1
70
+ - M2: Mesoscale 2
71
+ """
72
+
73
+ F = enum.auto()
74
+ C = enum.auto()
75
+ M1 = enum.auto()
76
+ M2 = enum.auto()
77
+
78
+
79
+ def _check_time_resolution(t: datetime.datetime, region: GOESRegion) -> datetime.datetime:
80
+ """Confirm request t is at GOES scan time resolution."""
81
+ if t.second != 0 or t.microsecond != 0:
82
+ raise ValueError(
83
+ "Time must be at GOES scan time resolution. Seconds or microseconds not supported"
84
+ )
85
+
86
+ if region == GOESRegion.F:
87
+ # Full Disk: Scan times are available every 10 minutes after
88
+ # 2019-04-02 and every 15 minutes before
89
+ if t >= GOES_SCAN_MODE_CHANGE:
90
+ if t.minute % 10:
91
+ raise ValueError(
92
+ f"Time must be at GOES scan time resolution for {region}. "
93
+ f"After {GOES_SCAN_MODE_CHANGE}, time should be a multiple of 10 minutes."
94
+ )
95
+ elif t.minute % 15:
96
+ raise ValueError(
97
+ f"Time must be at GOES scan time resolution for {region}. "
98
+ f"Before {GOES_SCAN_MODE_CHANGE}, time should be a multiple of 15 minutes."
99
+ )
100
+ return t
101
+
102
+ if region == GOESRegion.C:
103
+ # CONUS: Scan times are every 5 minutes
104
+ if t.minute % 5:
105
+ raise ValueError(
106
+ f"Time must be at GOES scan time resolution for {region}. "
107
+ "Time should be a multiple of 5 minutes."
108
+ )
109
+ return t
110
+
111
+ return t
112
+
113
+
114
+ def _parse_channels(channels: str | Iterable[str] | None) -> set[str]:
115
+ """Check that the channels are valid and return as a set."""
116
+ if channels is None:
117
+ return set(DEFAULT_CHANNELS)
118
+
119
+ if isinstance(channels, str):
120
+ channels = (channels,)
121
+
122
+ available = {f"C{i:02d}" for i in range(1, 17)}
123
+ channels = {c.upper() for c in channels}
124
+ if not channels.issubset(available):
125
+ raise ValueError(f"Channels must be in {sorted(available)}")
126
+ return channels
127
+
128
+
129
+ def _check_channel_resolution(channels: Iterable[str]) -> None:
130
+ """Confirm request channels have a common horizontal resolution."""
131
+ assert channels, "channels must be non-empty"
132
+
133
+ # https://www.goes-r.gov/spacesegment/abi.html
134
+ resolutions = {
135
+ "C01": 1.0,
136
+ "C02": 1.0, # XXX: this actually has a resolution of 0.5 km, but we treat it as 1 km
137
+ "C03": 1.0,
138
+ "C04": 2.0,
139
+ "C05": 1.0,
140
+ "C06": 2.0,
141
+ "C07": 2.0,
142
+ "C08": 2.0,
143
+ "C09": 2.0,
144
+ "C10": 2.0,
145
+ "C11": 2.0,
146
+ "C12": 2.0,
147
+ "C13": 2.0,
148
+ "C14": 2.0,
149
+ "C15": 2.0,
150
+ "C16": 2.0,
151
+ }
152
+
153
+ resolutions = {c: resolutions[c] for c in channels}
154
+ c0, res0 = resolutions.popitem()
155
+
156
+ try:
157
+ c1, res1 = next((c, res) for c, res in resolutions.items() if res != res0)
158
+ except StopIteration:
159
+ # All resolutions are the same
160
+ return
161
+ raise ValueError(
162
+ "Channels must have a common horizontal resolution. "
163
+ f"Channel {c0} has resolution {res0} km and channel {c1} has resolution {res1} km."
164
+ )
165
+
166
+
167
+ def _parse_region(region: GOESRegion | str) -> GOESRegion:
168
+ """Parse region from string."""
169
+ if isinstance(region, GOESRegion):
170
+ return region
171
+
172
+ region = region.upper().replace(" ", "").replace("_", "")
173
+
174
+ if region in ("F", "FULL", "FULLDISK"):
175
+ return GOESRegion.F
176
+ if region in ("C", "CONUS", "CONTINENTAL"):
177
+ return GOESRegion.C
178
+ if region in ("M1", "MESO1", "MESOSCALE1"):
179
+ return GOESRegion.M1
180
+ if region in ("M2", "MESO2", "MESOSCALE2"):
181
+ return GOESRegion.M2
182
+ raise ValueError(f"Region must be one of {GOESRegion._member_names_} or their abbreviations")
183
+
184
+
185
+ def gcs_goes_path(
186
+ time: datetime.datetime,
187
+ region: GOESRegion,
188
+ channels: str | Iterable[str] | None = None,
189
+ bucket: str = "gcp-public-data-goes-16",
190
+ fs: gcsfs.GCSFileSystem | None = None,
191
+ ) -> list[str]:
192
+ """Return GCS paths to GOES data at the given time for the given region and channels.
193
+
194
+ Presently only supported for GOES data whose scan time minute coincides with
195
+ the minute of the time parameter.
196
+
197
+ Parameters
198
+ ----------
199
+ time : datetime.datetime
200
+ Time of GOES data. This should be a timezone-naive datetime object or an
201
+ ISO 8601 formatted string.
202
+ region : GOESRegion
203
+ GOES Region of interest.
204
+ channels : str | Iterable[str]
205
+ Set of channels or bands for CMIP data. The 16 possible channels are
206
+ represented by the strings "C01" to "C16". For the SEVIRI ash color scheme,
207
+ set ``channels=("C11", "C14", "C15")``. For the true color scheme,
208
+ set ``channels=("C01", "C02", "C03")``. By default, the channels
209
+ required by the SEVIRI ash color scheme are used.
210
+
211
+ Returns
212
+ -------
213
+ list[str]
214
+ List of GCS paths to GOES data.
215
+
216
+ Examples
217
+ --------
218
+ >>> from pprint import pprint
219
+ >>> t = datetime.datetime(2023, 4, 3, 2, 10)
220
+
221
+ >>> paths = gcs_goes_path(t, GOESRegion.F, channels=("C11", "C12", "C13"))
222
+ >>> pprint(paths)
223
+ ['gcp-public-data-goes-16/ABI-L2-CMIPF/2023/093/02/OR_ABI-L2-CMIPF-M6C11_G16_s20230930210203_e20230930219511_c20230930219586.nc',
224
+ 'gcp-public-data-goes-16/ABI-L2-CMIPF/2023/093/02/OR_ABI-L2-CMIPF-M6C12_G16_s20230930210203_e20230930219516_c20230930219596.nc',
225
+ 'gcp-public-data-goes-16/ABI-L2-CMIPF/2023/093/02/OR_ABI-L2-CMIPF-M6C13_G16_s20230930210203_e20230930219523_c20230930219586.nc']
226
+
227
+ >>> paths = gcs_goes_path(t, GOESRegion.C, channels=("C11", "C12", "C13"))
228
+ >>> pprint(paths)
229
+ ['gcp-public-data-goes-16/ABI-L2-CMIPC/2023/093/02/OR_ABI-L2-CMIPC-M6C11_G16_s20230930211170_e20230930213543_c20230930214055.nc',
230
+ 'gcp-public-data-goes-16/ABI-L2-CMIPC/2023/093/02/OR_ABI-L2-CMIPC-M6C12_G16_s20230930211170_e20230930213551_c20230930214045.nc',
231
+ 'gcp-public-data-goes-16/ABI-L2-CMIPC/2023/093/02/OR_ABI-L2-CMIPC-M6C13_G16_s20230930211170_e20230930213557_c20230930214065.nc']
232
+
233
+ >>> t = datetime.datetime(2023, 4, 3, 2, 11)
234
+ >>> paths = gcs_goes_path(t, GOESRegion.M1, channels="C01")
235
+ >>> pprint(paths)
236
+ ['gcp-public-data-goes-16/ABI-L2-CMIPM/2023/093/02/OR_ABI-L2-CMIPM1-M6C01_G16_s20230930211249_e20230930211309_c20230930211386.nc']
237
+
238
+ """
239
+ time = _check_time_resolution(time, region)
240
+ year = time.strftime("%Y")
241
+ yday = time.strftime("%j")
242
+ hour = time.strftime("%H")
243
+
244
+ sensor = "ABI" # Advanced Baseline Imager
245
+ level = "L2" # Level 2
246
+ product_name = "CMIP" # Cloud and Moisture Imagery
247
+ product = f"{sensor}-{level}-{product_name}{region.name[0]}"
248
+
249
+ bucket = bucket.removeprefix("gs://")
250
+
251
+ path_prefix = f"gs://{bucket}/{product}/{year}/{yday}/{hour}/"
252
+
253
+ # https://www.goes-r.gov/users/abiScanModeInfo.html
254
+ mode = "M6" if time >= GOES_SCAN_MODE_CHANGE else "M3"
255
+
256
+ # Example name pattern
257
+ # OR_ABI-L1b-RadF-M3C02_G16_s20171671145342_e20171671156109_c20171671156144.nc
258
+ time_str = time.strftime("%Y%j%H%M")
259
+ if region == GOESRegion.F:
260
+ time_str = time_str[:-1] # might not work before 2019-04-02?
261
+ elif region == GOESRegion.C:
262
+ # Very crude -- assuming scan time ends with 1 or 6
263
+ if time_str.endswith("0"):
264
+ time_str = f"{time_str[:-1]}1"
265
+ elif time_str.endswith("5"):
266
+ time_str = f"{time_str[:-1]}6"
267
+
268
+ name_prefix = f"OR_{product[:-1]}{region.name}-{mode}"
269
+ name_suffix = f"_G16_s{time_str}*"
270
+
271
+ channels = _parse_channels(channels)
272
+
273
+ # It's faster to run a single glob with C?? then running a glob for
274
+ # each channel. The downside is that we have to filter the results.
275
+ rpath = f"{path_prefix}{name_prefix}C??{name_suffix}"
276
+
277
+ fs = fs or gcsfs.GCSFileSystem(token="anon")
278
+ rpaths: list[str] = fs.glob(rpath)
279
+
280
+ out = [r for r in rpaths if _extract_channel_from_rpath(r) in channels]
281
+ if not out:
282
+ raise RuntimeError(f"No data found for {time} in {region} for channels {channels}")
283
+ return out
284
+
285
+
286
+ def _extract_channel_from_rpath(rpath: str) -> str:
287
+ # Split at the separator between product name and mode
288
+ # This works for both M3 and M6
289
+ sep = "-M"
290
+ suffix = rpath.split(sep, maxsplit=1)[1]
291
+ return suffix[1:4]
292
+
293
+
294
+ class GOES:
295
+ """Support for GOES-16 data handling.
296
+
297
+ Parameters
298
+ ----------
299
+ region : GOESRegion | str = {"F", "C", "M1", "M2"}
300
+ GOES Region of interest. Uses the following conventions.
301
+
302
+ - F: Full Disk
303
+ - C: CONUS
304
+ - M1: Mesoscale 1
305
+ - M2: Mesoscale 2
306
+
307
+ channels : str | set[str] | None
308
+ Set of channels or bands for CMIP data. The 16 possible channels are
309
+ represented by the strings "C01" to "C16". For the SEVIRI ash color scheme,
310
+ set ``channels=("C11", "C14", "C15")``. For the true color scheme,
311
+ set ``channels=("C01", "C02", "C03")``. By default, the channels
312
+ required by the SEVIRI ash color scheme are used. The channels must have
313
+ a common horizontal resolution. The resolutions are:
314
+
315
+ - C01: 1.0 km
316
+ - C02: 0.5 km (treated as 1.0 km)
317
+ - C03: 1.0 km
318
+ - C04: 2.0 km
319
+ - C05: 1.0 km
320
+ - C06 - C16: 2.0 km
321
+
322
+ cachestore : cache.CacheStore | None
323
+ Cache store for GOES data. If None, data is downloaded directly into
324
+ memory. By default, a :class:`cache.DiskCacheStore` is used.
325
+ goes_bucket : str = "gcp-public-data-goes-16"
326
+ GCP bucket for GOES data. AWS access is not supported.
327
+
328
+ See Also
329
+ --------
330
+ GOESRegion
331
+ gcs_goes_path
332
+
333
+ Examples
334
+ --------
335
+ >>> goes = GOES(region="M1", channels=("C11", "C14"))
336
+ >>> da = goes.get("2021-04-03 02:10:00")
337
+ >>> da.shape
338
+ (2, 500, 500)
339
+
340
+ >>> da.dims
341
+ ('band_id', 'y', 'x')
342
+
343
+ >>> da.band_id.values
344
+ array([11, 14], dtype=int32)
345
+
346
+ >>> # Print out a sample of the data
347
+ >>> da.sel(band_id=11).isel(x=slice(0, 50, 10), y=slice(0, 50, 10)).values
348
+ array([[266.8644 , 265.50812, 271.5592 , 271.45486, 272.75897],
349
+ [250.53697, 273.28064, 273.80225, 270.77673, 274.8977 ],
350
+ [272.8633 , 272.65466, 271.5592 , 274.01093, 273.12415],
351
+ [274.16742, 274.11523, 276.5148 , 273.85443, 270.51593],
352
+ [274.84555, 275.15854, 272.60248, 270.67242, 272.23734]],
353
+ dtype=float32)
354
+
355
+ >>> # The data has been cached locally
356
+ >>> assert goes.cachestore.listdir()
357
+
358
+ >>> # Download GOES data directly into memory by setting cachestore=None
359
+ >>> goes = GOES(region="M2", channels=("C11", "C12", "C13"), cachestore=None)
360
+ >>> da = goes.get("2021-04-03 02:10:00")
361
+
362
+ >>> da.shape
363
+ (3, 500, 500)
364
+
365
+ >>> da.dims
366
+ ('band_id', 'y', 'x')
367
+
368
+ >>> da.band_id.values
369
+ array([11, 12, 13], dtype=int32)
370
+
371
+ >>> da.attrs["long_name"]
372
+ 'ABI L2+ Cloud and Moisture Imagery brightness temperature'
373
+
374
+ >>> da.sel(band_id=11).values
375
+ array([[251.31944, 249.59802, 249.65018, ..., 270.30725, 270.51593,
376
+ 269.83777],
377
+ [250.53697, 249.0242 , 249.12854, ..., 270.15076, 270.30725,
378
+ 269.73346],
379
+ [249.1807 , 249.33719, 251.99757, ..., 270.15076, 270.20294,
380
+ 268.7945 ],
381
+ ...,
382
+ [277.24512, 277.29727, 277.45377, ..., 274.42822, 274.11523,
383
+ 273.7501 ],
384
+ [277.24512, 277.45377, 278.18408, ..., 274.6369 , 274.01093,
385
+ 274.06308],
386
+ [276.8278 , 277.14078, 277.7146 , ..., 274.6369 , 273.9066 ,
387
+ 274.16742]], dtype=float32)
388
+
389
+ """
390
+
391
+ __marker = object()
392
+
393
+ def __init__(
394
+ self,
395
+ region: GOESRegion | str = GOESRegion.F,
396
+ channels: str | Iterable[str] | None = None,
397
+ cachestore: cache.CacheStore | None = __marker, # type: ignore[assignment]
398
+ goes_bucket: str = "gcp-public-data-goes-16",
399
+ ) -> None:
400
+ self.region = _parse_region(region)
401
+ self.channels = _parse_channels(channels)
402
+ _check_channel_resolution(self.channels)
403
+
404
+ self.goes_bucket = goes_bucket
405
+ self.fs = gcsfs.GCSFileSystem(token="anon")
406
+
407
+ if cachestore is self.__marker:
408
+ cache_root = cache._get_user_cache_dir()
409
+ cache_dir = f"{cache_root}/goes"
410
+ cachestore = cache.DiskCacheStore(cache_dir=cache_dir)
411
+ self.cachestore = cachestore
412
+
413
+ def __repr__(self) -> str:
414
+ """Return string representation."""
415
+ return f"GOES(region='{self.region}', channels={sorted(self.channels)})"
416
+
417
+ def gcs_goes_path(self, time: datetime.datetime, channels: set[str] | None = None) -> list[str]:
418
+ """Return GCS paths to GOES data at given time.
419
+
420
+ Presently only supported for GOES data whose scan time minute coincides with
421
+ the minute of the time parameter.
422
+
423
+ Parameters
424
+ ----------
425
+ time : datetime.datetime
426
+ Time of GOES data.
427
+ channels : set[str] | None
428
+ Set of channels or bands for CMIP data. If None, the :attr:`channels`
429
+ attribute is used.
430
+
431
+ Returns
432
+ -------
433
+ list[str]
434
+ List of GCS paths to GOES data.
435
+ """
436
+ channels = channels or self.channels
437
+ return gcs_goes_path(time, self.region, channels, self.goes_bucket)
438
+
439
+ def _lpaths(self, time: datetime.datetime) -> dict[str, str]:
440
+ """Construct names for local netcdf files using the :attr:`cachestore`.
441
+
442
+ Returns dictionary of the form ``{channel: local_path}``.
443
+ """
444
+ assert self.cachestore, "cachestore must be set"
445
+
446
+ t_str = time.strftime("%Y%m%d%H%M")
447
+
448
+ out = {}
449
+ for c in self.channels:
450
+ name = f"{self.region.name}_{t_str}_{c}.nc"
451
+ lpath = self.cachestore.path(name)
452
+ out[c] = lpath
453
+
454
+ return out
455
+
456
+ def get(self, time: datetime.datetime | str) -> xr.DataArray:
457
+ """Return GOES data at given time.
458
+
459
+ Parameters
460
+ ----------
461
+ time : datetime.datetime | str
462
+ Time of GOES data. This should be a timezone-naive datetime object
463
+ or an ISO 8601 formatted string.
464
+
465
+ Returns
466
+ -------
467
+ xr.DataArray
468
+ DataArray of GOES data with coordinates:
469
+
470
+ - band_id: Channel or band ID
471
+ - x: GOES x-coordinate
472
+ - y: GOES y-coordinate
473
+ """
474
+ if not isinstance(time, datetime.datetime):
475
+ time = pd.Timestamp(time).to_pydatetime()
476
+
477
+ if self.cachestore is not None:
478
+ return self._get_with_cache(time) # type: ignore[arg-type]
479
+ return self._get_without_cache(time) # type: ignore[arg-type]
480
+
481
+ def _get_with_cache(self, time: datetime.datetime) -> xr.DataArray:
482
+ """Download the GOES data to the :attr:`cachestore` at the given time."""
483
+ assert self.cachestore, "cachestore must be set"
484
+
485
+ lpaths = self._lpaths(time)
486
+
487
+ channels_needed = set()
488
+ for c, lpath in lpaths.items():
489
+ if not self.cachestore.exists(lpath):
490
+ channels_needed.add(c)
491
+
492
+ if channels_needed:
493
+ rpaths = self.gcs_goes_path(time, channels_needed)
494
+ for rpath in rpaths:
495
+ channel = _extract_channel_from_rpath(rpath)
496
+ lpath = lpaths[channel]
497
+ self.fs.get(rpath, lpath)
498
+
499
+ # Deal with the different spatial resolutions
500
+ kwargs = {
501
+ "concat_dim": "band",
502
+ "combine": "nested",
503
+ "data_vars": ["CMI"],
504
+ "compat": "override",
505
+ "coords": "minimal",
506
+ }
507
+ if len(lpaths) == 1:
508
+ ds = xr.open_dataset(lpaths.popitem()[1])
509
+ ds["CMI"] = ds["CMI"].expand_dims(band=ds["band_id"].values)
510
+ elif "C02" in lpaths:
511
+ lpath02 = lpaths.pop("C02")
512
+ ds1 = xr.open_mfdataset(lpaths.values(), **kwargs) # type: ignore[arg-type]
513
+ ds2 = xr.open_dataset(lpath02)
514
+ ds = _concat_c02(ds1, ds2)
515
+ else:
516
+ ds = xr.open_mfdataset(lpaths.values(), **kwargs) # type: ignore[arg-type]
517
+
518
+ da = ds["CMI"]
519
+ da = da.swap_dims({"band": "band_id"}).sortby("band_id")
520
+
521
+ # Attach some useful attrs -- only using goes_imager_projection currently
522
+ da.attrs["goes_imager_projection"] = ds.goes_imager_projection.attrs
523
+ da.attrs["geospatial_lat_lon_extent"] = ds.geospatial_lat_lon_extent.attrs
524
+
525
+ return da
526
+
527
+ def _get_without_cache(self, time: datetime.datetime) -> xr.DataArray:
528
+ """Download the GOES data into memory at the given time."""
529
+ rpaths = self.gcs_goes_path(time)
530
+
531
+ # Load into memory
532
+ data = self.fs.cat(rpaths)
533
+
534
+ if isinstance(data, dict):
535
+ da_dict = {}
536
+ for rpath, init_bytes in data.items():
537
+ channel = _extract_channel_from_rpath(rpath)
538
+ ds = _load_via_tempfile(init_bytes)
539
+
540
+ da = ds["CMI"]
541
+ da = da.expand_dims(band_id=ds["band_id"].values)
542
+ da_dict[channel] = da
543
+
544
+ if len(da_dict) == 1: # This might be redundant with the branch below
545
+ da = da_dict.popitem()[1]
546
+ elif "C02" in da_dict:
547
+ da2 = da_dict.pop("C02")
548
+ da1 = xr.concat(da_dict.values(), dim="band_id")
549
+ da = _concat_c02(da1, da2)
550
+ else:
551
+ da = xr.concat(da_dict.values(), dim="band_id")
552
+
553
+ else:
554
+ ds = _load_via_tempfile(data)
555
+ da = ds["CMI"]
556
+ da = da.expand_dims(band_id=ds["band_id"].values)
557
+
558
+ da = da.sortby("band_id")
559
+
560
+ # Attach some useful attrs -- only using goes_imager_projection currently
561
+ da.attrs["goes_imager_projection"] = ds.goes_imager_projection.attrs
562
+ da.attrs["geospatial_lat_lon_extent"] = ds.geospatial_lat_lon_extent.attrs
563
+
564
+ return da
565
+
566
+
567
+ def _load_via_tempfile(data: bytes) -> xr.Dataset:
568
+ """Load xarray dataset via temporary file."""
569
+ with tempfile.NamedTemporaryFile(buffering=0) as tmp:
570
+ tmp.write(data)
571
+ return xr.load_dataset(tmp.name)
572
+
573
+
574
+ def _concat_c02(ds1: XArrayType, ds2: XArrayType) -> XArrayType:
575
+ """Concatenate two datasets with C01 and C02 data."""
576
+ # Average the C02 data to the C01 resolution
577
+ ds2 = ds2.coarsen(x=2, y=2, boundary="exact").mean() # type: ignore[attr-defined]
578
+
579
+ # Gut check
580
+ np.testing.assert_allclose(ds1["x"], ds2["x"], rtol=0.0005)
581
+ np.testing.assert_allclose(ds1["y"], ds2["y"], rtol=0.0005)
582
+
583
+ # Assign the C01 data to the C02 data
584
+ ds2["x"] = ds1["x"]
585
+ ds2["y"] = ds1["y"]
586
+
587
+ # Finally, combine the datasets
588
+ dim = "band_id" if "band_id" in ds1.dims else "band"
589
+ return xr.concat([ds1, ds2], dim=dim)
590
+
591
+
592
+ def extract_goes_visualization(
593
+ da: xr.DataArray,
594
+ color_scheme: str = "ash",
595
+ ash_convention: str = "SEVIRI",
596
+ gamma: float = 2.2,
597
+ ) -> tuple[npt.NDArray[np.float32], ccrs.Geostationary, tuple[float, float, float, float]]:
598
+ """Extract artifacts for visualizing GOES data with the given color scheme.
599
+
600
+ Parameters
601
+ ----------
602
+ da : xr.DataArray
603
+ DataArray of GOES data as returned by :meth:`GOES.get`. Must have the channels
604
+ required by :func:`to_ash`.
605
+ color_scheme : str = {"ash", "true"}
606
+ Color scheme to use for visualization.
607
+ ash_convention : str = {"SEVIRI", "standard"}
608
+ Passed into :func:`to_ash`. Only used if ``color_scheme="ash"``.
609
+ gamma : float = 2.2
610
+ Passed into :func:`to_true_color`. Only used if ``color_scheme="true"``.
611
+
612
+ Returns
613
+ -------
614
+ rgb : npt.NDArray[np.float32]
615
+ 3D RGB array of shape ``(height, width, 3)``. Any nan values are replaced with 0.
616
+ src_crs : ccrs.Geostationary
617
+ The Geostationary projection built from the GOES metadata.
618
+ src_extent : tuple[float, float, float, float]
619
+ Extent of GOES data in the Geostationary projection
620
+ """
621
+ proj_info = da.attrs["goes_imager_projection"]
622
+ h = proj_info["perspective_point_height"]
623
+ lon0 = proj_info["longitude_of_projection_origin"]
624
+ src_crs = ccrs.Geostationary(central_longitude=lon0, satellite_height=h, sweep_axis="x")
625
+
626
+ if color_scheme == "true":
627
+ rgb = to_true_color(da, gamma)
628
+ elif color_scheme == "ash":
629
+ rgb = to_ash(da, ash_convention)
630
+ else:
631
+ raise ValueError(f"Color scheme must be 'true' or 'ash', not '{color_scheme}'")
632
+
633
+ np.nan_to_num(rgb, copy=False)
634
+
635
+ x = da["x"].values
636
+ y = da["y"].values
637
+
638
+ # Multiply extremes by the satellite height
639
+ src_extent = h * x.min(), h * x.max(), h * y.min(), h * y.max()
640
+
641
+ return rgb, src_crs, src_extent
642
+
643
+
644
+ def to_true_color(da: xr.DataArray, gamma: float = 2.2) -> npt.NDArray[np.float32]:
645
+ """Compute 3d RGB array for the true color scheme.
646
+
647
+ Parameters
648
+ ----------
649
+ da : xr.DataArray
650
+ DataArray of GOES data with channels C01, C02, C03.
651
+ gamma : float = 2.2
652
+ Gamma correction for the RGB channels.
653
+
654
+ Returns
655
+ -------
656
+ npt.NDArray[np.float32]
657
+ 3d RGB array with true color scheme.
658
+
659
+ References
660
+ ----------
661
+ - `Unidata's true color recipe <https://unidata.github.io/python-gallery/examples/mapping_GOES16_TrueColor.html>`_
662
+ """
663
+ red = da.sel(band_id=2).values
664
+ green = da.sel(band_id=3).values
665
+ blue = da.sel(band_id=1).values
666
+
667
+ red = _clip_and_scale(red, 0.0, 1.0)
668
+ green = _clip_and_scale(green, 0.0, 1.0)
669
+ blue = _clip_and_scale(blue, 0.0, 1.0)
670
+
671
+ red = red ** (1 / gamma)
672
+ green = green ** (1 / gamma)
673
+ blue = blue ** (1 / gamma)
674
+
675
+ # Calculate "true" green channel
676
+ green = 0.45 * red + 0.1 * green + 0.45 * blue
677
+ green = _clip_and_scale(green, 0.0, 1.0)
678
+
679
+ return np.dstack([red, green, blue])
680
+
681
+
682
+ def to_ash(da: xr.DataArray, convention: str = "SEVIRI") -> npt.NDArray[np.float32]:
683
+ """Compute 3d RGB array for the ASH color scheme.
684
+
685
+ Parameters
686
+ ----------
687
+ da : xr.DataArray
688
+ DataArray of GOES data with appropriate channels.
689
+ convention : str = {"SEVIRI", "standard"}
690
+ Convention for color space.
691
+
692
+ - SEVIRI convention requires channels C11, C14, C15.
693
+ Used in :cite:`kulikSatellitebasedDetectionContrails2019`.
694
+ - Standard convention requires channels C11, C13, C14, C15
695
+
696
+ Returns
697
+ -------
698
+ npt.NDArray[np.float32]
699
+ 3d RGB array with ASH color scheme according to convention.
700
+
701
+ References
702
+ ----------
703
+ - `Ash RGB quick guide (the color space and color interpretations) <https://rammb.cira.colostate.edu/training/visit/quick_guides/GOES_Ash_RGB.pdf>`_
704
+ - :cite:`SEVIRIRGBCal`
705
+ - :cite:`kulikSatellitebasedDetectionContrails2019`
706
+
707
+ Examples
708
+ --------
709
+ >>> goes = GOES(region="M2", channels=("C11", "C14", "C15"))
710
+ >>> da = goes.get("2022-10-03 04:34:00")
711
+ >>> rgb = to_ash(da)
712
+ >>> rgb.shape
713
+ (500, 500, 3)
714
+
715
+ >>> rgb[0, 0, :]
716
+ array([0.0127004 , 0.22793579, 0.3930847 ], dtype=float32)
717
+ """
718
+ if convention == "standard":
719
+ c11 = da.sel(band_id=11).values # 8.44
720
+ c13 = da.sel(band_id=13).values # 10.33
721
+ c14 = da.sel(band_id=14).values # 11.19
722
+ c15 = da.sel(band_id=15).values # 12.27
723
+
724
+ red = c15 - c13
725
+ green = c14 - c11
726
+ blue = c13
727
+
728
+ elif convention in ["SEVIRI", "MIT"]: # retain MIT for backwards compatibility
729
+ c11 = da.sel(band_id=11).values # 8.44
730
+ c14 = da.sel(band_id=14).values # 11.19
731
+ c15 = da.sel(band_id=15).values # 12.27
732
+
733
+ red = c15 - c14
734
+ green = c14 - c11
735
+ blue = c14
736
+
737
+ else:
738
+ raise ValueError("Convention must be either 'SEVIRI' or 'standard'")
739
+
740
+ # See colostate pdf for slightly wider values
741
+ red = _clip_and_scale(red, -4.0, 2.0)
742
+ green = _clip_and_scale(green, -4.0, 5.0)
743
+ blue = _clip_and_scale(blue, 243.0, 303.0)
744
+ return np.dstack([red, green, blue])
745
+
746
+
747
+ def _clip_and_scale(
748
+ arr: npt.NDArray[np.float64], low: float, high: float
749
+ ) -> npt.NDArray[np.float64]:
750
+ """Clip array and rescale to the interval [0, 1].
751
+
752
+ Array is first clipped to the interval [low, high] and then linearly rescaled
753
+ to the interval [0, 1] so that::
754
+
755
+ low -> 0
756
+ high -> 1
757
+
758
+ Parameters
759
+ ----------
760
+ arr : npt.NDArray[np.float64]
761
+ Array to clip and scale.
762
+ low : float
763
+ Lower clipping bound.
764
+ high : float
765
+ Upper clipping bound.
766
+
767
+ Returns
768
+ -------
769
+ npt.NDArray[np.float64]
770
+ Clipped and scaled array.
771
+ """
772
+ return (arr.clip(low, high) - low) / (high - low)