pycontrails 0.59.0__cp314-cp314-macosx_10_15_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.
- pycontrails/__init__.py +70 -0
- pycontrails/_version.py +34 -0
- pycontrails/core/__init__.py +30 -0
- pycontrails/core/aircraft_performance.py +679 -0
- pycontrails/core/airports.py +228 -0
- pycontrails/core/cache.py +889 -0
- pycontrails/core/coordinates.py +174 -0
- pycontrails/core/fleet.py +483 -0
- pycontrails/core/flight.py +2185 -0
- pycontrails/core/flightplan.py +228 -0
- pycontrails/core/fuel.py +140 -0
- pycontrails/core/interpolation.py +702 -0
- pycontrails/core/met.py +2936 -0
- pycontrails/core/met_var.py +387 -0
- pycontrails/core/models.py +1321 -0
- pycontrails/core/polygon.py +549 -0
- pycontrails/core/rgi_cython.cpython-314-darwin.so +0 -0
- pycontrails/core/vector.py +2249 -0
- pycontrails/datalib/__init__.py +12 -0
- pycontrails/datalib/_met_utils/metsource.py +746 -0
- pycontrails/datalib/ecmwf/__init__.py +73 -0
- pycontrails/datalib/ecmwf/arco_era5.py +345 -0
- pycontrails/datalib/ecmwf/common.py +114 -0
- pycontrails/datalib/ecmwf/era5.py +554 -0
- pycontrails/datalib/ecmwf/era5_model_level.py +490 -0
- pycontrails/datalib/ecmwf/hres.py +804 -0
- pycontrails/datalib/ecmwf/hres_model_level.py +466 -0
- pycontrails/datalib/ecmwf/ifs.py +287 -0
- pycontrails/datalib/ecmwf/model_levels.py +435 -0
- pycontrails/datalib/ecmwf/static/model_level_dataframe_v20240418.csv +139 -0
- pycontrails/datalib/ecmwf/variables.py +268 -0
- pycontrails/datalib/geo_utils.py +261 -0
- pycontrails/datalib/gfs/__init__.py +28 -0
- pycontrails/datalib/gfs/gfs.py +656 -0
- pycontrails/datalib/gfs/variables.py +104 -0
- pycontrails/datalib/goes.py +764 -0
- pycontrails/datalib/gruan.py +343 -0
- pycontrails/datalib/himawari/__init__.py +27 -0
- pycontrails/datalib/himawari/header_struct.py +266 -0
- pycontrails/datalib/himawari/himawari.py +671 -0
- pycontrails/datalib/landsat.py +589 -0
- pycontrails/datalib/leo_utils/__init__.py +5 -0
- pycontrails/datalib/leo_utils/correction.py +266 -0
- pycontrails/datalib/leo_utils/landsat_metadata.py +300 -0
- pycontrails/datalib/leo_utils/search.py +250 -0
- pycontrails/datalib/leo_utils/sentinel_metadata.py +748 -0
- pycontrails/datalib/leo_utils/static/bq_roi_query.sql +6 -0
- pycontrails/datalib/leo_utils/vis.py +59 -0
- pycontrails/datalib/sentinel.py +650 -0
- pycontrails/datalib/spire/__init__.py +5 -0
- pycontrails/datalib/spire/exceptions.py +62 -0
- pycontrails/datalib/spire/spire.py +604 -0
- pycontrails/ext/bada.py +42 -0
- pycontrails/ext/cirium.py +14 -0
- pycontrails/ext/empirical_grid.py +140 -0
- pycontrails/ext/synthetic_flight.py +431 -0
- pycontrails/models/__init__.py +1 -0
- pycontrails/models/accf.py +425 -0
- pycontrails/models/apcemm/__init__.py +8 -0
- pycontrails/models/apcemm/apcemm.py +983 -0
- pycontrails/models/apcemm/inputs.py +226 -0
- pycontrails/models/apcemm/static/apcemm_yaml_template.yaml +183 -0
- pycontrails/models/apcemm/utils.py +437 -0
- pycontrails/models/cocip/__init__.py +29 -0
- pycontrails/models/cocip/cocip.py +2742 -0
- pycontrails/models/cocip/cocip_params.py +305 -0
- pycontrails/models/cocip/cocip_uncertainty.py +291 -0
- pycontrails/models/cocip/contrail_properties.py +1530 -0
- pycontrails/models/cocip/output_formats.py +2270 -0
- pycontrails/models/cocip/radiative_forcing.py +1260 -0
- pycontrails/models/cocip/radiative_heating.py +520 -0
- pycontrails/models/cocip/unterstrasser_wake_vortex.py +508 -0
- pycontrails/models/cocip/wake_vortex.py +396 -0
- pycontrails/models/cocip/wind_shear.py +120 -0
- pycontrails/models/cocipgrid/__init__.py +9 -0
- pycontrails/models/cocipgrid/cocip_grid.py +2552 -0
- pycontrails/models/cocipgrid/cocip_grid_params.py +138 -0
- pycontrails/models/dry_advection.py +602 -0
- pycontrails/models/emissions/__init__.py +21 -0
- pycontrails/models/emissions/black_carbon.py +599 -0
- pycontrails/models/emissions/emissions.py +1353 -0
- pycontrails/models/emissions/ffm2.py +336 -0
- pycontrails/models/emissions/static/default-engine-uids.csv +239 -0
- pycontrails/models/emissions/static/edb-gaseous-v29b-engines.csv +596 -0
- pycontrails/models/emissions/static/edb-nvpm-v29b-engines.csv +215 -0
- pycontrails/models/extended_k15.py +1327 -0
- pycontrails/models/humidity_scaling/__init__.py +37 -0
- pycontrails/models/humidity_scaling/humidity_scaling.py +1075 -0
- pycontrails/models/humidity_scaling/quantiles/era5-model-level-quantiles.pq +0 -0
- pycontrails/models/humidity_scaling/quantiles/era5-pressure-level-quantiles.pq +0 -0
- pycontrails/models/issr.py +210 -0
- pycontrails/models/pcc.py +326 -0
- pycontrails/models/pcr.py +154 -0
- pycontrails/models/ps_model/__init__.py +18 -0
- pycontrails/models/ps_model/ps_aircraft_params.py +381 -0
- pycontrails/models/ps_model/ps_grid.py +701 -0
- pycontrails/models/ps_model/ps_model.py +1000 -0
- pycontrails/models/ps_model/ps_operational_limits.py +525 -0
- pycontrails/models/ps_model/static/ps-aircraft-params-20250328.csv +69 -0
- pycontrails/models/ps_model/static/ps-synonym-list-20250328.csv +104 -0
- pycontrails/models/sac.py +442 -0
- pycontrails/models/tau_cirrus.py +183 -0
- pycontrails/physics/__init__.py +1 -0
- pycontrails/physics/constants.py +117 -0
- pycontrails/physics/geo.py +1138 -0
- pycontrails/physics/jet.py +968 -0
- pycontrails/physics/static/iata-cargo-load-factors-20250221.csv +74 -0
- pycontrails/physics/static/iata-passenger-load-factors-20250221.csv +74 -0
- pycontrails/physics/thermo.py +551 -0
- pycontrails/physics/units.py +472 -0
- pycontrails/py.typed +0 -0
- pycontrails/utils/__init__.py +1 -0
- pycontrails/utils/dependencies.py +66 -0
- pycontrails/utils/iteration.py +13 -0
- pycontrails/utils/json.py +187 -0
- pycontrails/utils/temp.py +50 -0
- pycontrails/utils/types.py +163 -0
- pycontrails-0.59.0.dist-info/METADATA +179 -0
- pycontrails-0.59.0.dist-info/RECORD +123 -0
- pycontrails-0.59.0.dist-info/WHEEL +6 -0
- pycontrails-0.59.0.dist-info/licenses/LICENSE +178 -0
- pycontrails-0.59.0.dist-info/licenses/NOTICE +43 -0
- pycontrails-0.59.0.dist-info/top_level.txt +3 -0
|
@@ -0,0 +1,764 @@
|
|
|
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 os
|
|
20
|
+
import tempfile
|
|
21
|
+
import warnings
|
|
22
|
+
from collections.abc import Iterable
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
import numpy.typing as npt
|
|
27
|
+
import pandas as pd
|
|
28
|
+
import xarray as xr
|
|
29
|
+
|
|
30
|
+
from pycontrails.core import cache
|
|
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
|
+
)
|
|
36
|
+
from pycontrails.utils import dependencies
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
import cartopy.crs
|
|
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 bands to use if none are specified. These are the bands
|
|
53
|
+
#: required by the SEVIRI (MIT) ash color scheme.
|
|
54
|
+
DEFAULT_BANDS = "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
|
+
#: The date at which GOES-19 data started being available. This is used to
|
|
62
|
+
#: determine the source (GOES-16 or GOES-19) of requested. In particular,
|
|
63
|
+
#: Mesoscale images are only available for GOES-East from GOES-19 after this date.
|
|
64
|
+
#: See the `NOAA press release <https://www.noaa.gov/news-release/noaas-goes-19-satellite-now-operational-providing-critical-new-data-to-forecasters>`_.
|
|
65
|
+
GOES_16_19_SWITCH_DATE = datetime.datetime(2025, 4, 4)
|
|
66
|
+
|
|
67
|
+
#: The GCS bucket for GOES-East data before ``GOES_16_19_SWITCH_DATE``.
|
|
68
|
+
GOES_16_BUCKET = "gcp-public-data-goes-16"
|
|
69
|
+
|
|
70
|
+
#: The GCS bucket for GOES-West data. Note that GOES-17 has degraded data quality
|
|
71
|
+
#: and is not recommended for use. This bucket isn't used by the ``GOES`` handler by default.
|
|
72
|
+
GOES_18_BUCKET = "gcp-public-data-goes-18"
|
|
73
|
+
|
|
74
|
+
#: The GCS bucket for GOES-East data after ``GOES_16_19_SWITCH_DATE``.
|
|
75
|
+
GOES_19_BUCKET = "gcp-public-data-goes-19"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class GOESRegion(enum.Enum):
|
|
79
|
+
"""GOES Region of interest.
|
|
80
|
+
|
|
81
|
+
Uses the following conventions.
|
|
82
|
+
|
|
83
|
+
- F: Full Disk
|
|
84
|
+
- C: CONUS
|
|
85
|
+
- M1: Mesoscale 1
|
|
86
|
+
- M2: Mesoscale 2
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
F = enum.auto()
|
|
90
|
+
C = enum.auto()
|
|
91
|
+
M1 = enum.auto()
|
|
92
|
+
M2 = enum.auto()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _check_time_resolution(t: datetime.datetime, region: GOESRegion) -> datetime.datetime:
|
|
96
|
+
"""Confirm request t is at GOES scan time resolution."""
|
|
97
|
+
if t.second != 0 or t.microsecond != 0:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
"Time must be at GOES scan time resolution. Seconds or microseconds not supported"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if region == GOESRegion.F:
|
|
103
|
+
# Full Disk: Scan times are available every 10 minutes after
|
|
104
|
+
# 2019-04-02 and every 15 minutes before
|
|
105
|
+
if t >= GOES_SCAN_MODE_CHANGE:
|
|
106
|
+
if t.minute % 10:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"Time must be at GOES scan time resolution for {region}. "
|
|
109
|
+
f"After {GOES_SCAN_MODE_CHANGE}, time should be a multiple of 10 minutes."
|
|
110
|
+
)
|
|
111
|
+
elif t.minute % 15:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"Time must be at GOES scan time resolution for {region}. "
|
|
114
|
+
f"Before {GOES_SCAN_MODE_CHANGE}, time should be a multiple of 15 minutes."
|
|
115
|
+
)
|
|
116
|
+
return t
|
|
117
|
+
|
|
118
|
+
if region == GOESRegion.C:
|
|
119
|
+
# CONUS: Scan times are every 5 minutes
|
|
120
|
+
if t.minute % 5:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"Time must be at GOES scan time resolution for {region}. "
|
|
123
|
+
"Time should be a multiple of 5 minutes."
|
|
124
|
+
)
|
|
125
|
+
return t
|
|
126
|
+
|
|
127
|
+
return t
|
|
128
|
+
|
|
129
|
+
|
|
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)
|
|
134
|
+
|
|
135
|
+
if isinstance(bands, str):
|
|
136
|
+
bands = (bands,)
|
|
137
|
+
|
|
138
|
+
available = {f"C{i:02d}" for i in range(1, 17)}
|
|
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
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _check_band_resolution(bands: Iterable[str]) -> None:
|
|
146
|
+
"""Confirm request bands have a common horizontal resolution."""
|
|
147
|
+
# https://www.goes-r.gov/spacesegment/abi.html
|
|
148
|
+
res = {
|
|
149
|
+
"C01": 1.0,
|
|
150
|
+
"C02": 1.0, # XXX: this actually has a resolution of 0.5 km, but we coarsen it to 1 km
|
|
151
|
+
"C03": 1.0,
|
|
152
|
+
"C04": 2.0,
|
|
153
|
+
"C05": 1.0,
|
|
154
|
+
"C06": 2.0,
|
|
155
|
+
"C07": 2.0,
|
|
156
|
+
"C08": 2.0,
|
|
157
|
+
"C09": 2.0,
|
|
158
|
+
"C10": 2.0,
|
|
159
|
+
"C11": 2.0,
|
|
160
|
+
"C12": 2.0,
|
|
161
|
+
"C13": 2.0,
|
|
162
|
+
"C14": 2.0,
|
|
163
|
+
"C15": 2.0,
|
|
164
|
+
"C16": 2.0,
|
|
165
|
+
}
|
|
166
|
+
|
|
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
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _parse_region(region: GOESRegion | str) -> GOESRegion:
|
|
179
|
+
"""Parse region from string."""
|
|
180
|
+
if isinstance(region, GOESRegion):
|
|
181
|
+
return region
|
|
182
|
+
|
|
183
|
+
region = region.upper().replace(" ", "").replace("_", "")
|
|
184
|
+
|
|
185
|
+
if region in ("F", "FULL", "FULLDISK"):
|
|
186
|
+
return GOESRegion.F
|
|
187
|
+
if region in ("C", "CONUS", "CONTINENTAL"):
|
|
188
|
+
return GOESRegion.C
|
|
189
|
+
if region in ("M1", "MESO1", "MESOSCALE1"):
|
|
190
|
+
return GOESRegion.M1
|
|
191
|
+
if region in ("M2", "MESO2", "MESOSCALE2"):
|
|
192
|
+
return GOESRegion.M2
|
|
193
|
+
raise ValueError(f"Region must be one of {GOESRegion._member_names_} or their abbreviations")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def gcs_goes_path(
|
|
197
|
+
time: datetime.datetime,
|
|
198
|
+
region: GOESRegion,
|
|
199
|
+
bands: str | Iterable[str] | None = None,
|
|
200
|
+
bucket: str | None = None,
|
|
201
|
+
fs: gcsfs.GCSFileSystem | None = None,
|
|
202
|
+
) -> list[str]:
|
|
203
|
+
"""Return GCS paths to GOES data at the given time for the given region and bands.
|
|
204
|
+
|
|
205
|
+
Presently only supported for GOES data whose scan time minute coincides with
|
|
206
|
+
the minute of the time parameter.
|
|
207
|
+
|
|
208
|
+
Parameters
|
|
209
|
+
----------
|
|
210
|
+
time : datetime.datetime
|
|
211
|
+
Time of GOES data. This should be a timezone-naive datetime object or an
|
|
212
|
+
ISO 8601 formatted string.
|
|
213
|
+
region : GOESRegion
|
|
214
|
+
GOES Region of interest.
|
|
215
|
+
bands : str | Iterable[str] | None, optional
|
|
216
|
+
Set of bands or bands for CMIP data. The 16 possible bands are
|
|
217
|
+
represented by the strings "C01" to "C16". For the SEVIRI ash color scheme,
|
|
218
|
+
set ``bands=("C11", "C14", "C15")``. For the true color scheme,
|
|
219
|
+
set ``bands=("C01", "C02", "C03")``. By default, the bands
|
|
220
|
+
required by the SEVIRI ash color scheme are used.
|
|
221
|
+
bucket : str | None
|
|
222
|
+
GCS bucket for GOES data. If None, the bucket is automatically
|
|
223
|
+
set to ``GOES_16_BUCKET`` if ``time`` is before
|
|
224
|
+
``GOES_16_19_SWITCH_DATE`` and ``GOES_19_BUCKET`` otherwise.
|
|
225
|
+
fs : gcsfs.GCSFileSystem | None
|
|
226
|
+
GCS file system instance. If None, a default anonymous instance is created.
|
|
227
|
+
|
|
228
|
+
Returns
|
|
229
|
+
-------
|
|
230
|
+
list[str]
|
|
231
|
+
List of GCS paths to GOES data.
|
|
232
|
+
|
|
233
|
+
Examples
|
|
234
|
+
--------
|
|
235
|
+
>>> from pprint import pprint
|
|
236
|
+
>>> t = datetime.datetime(2023, 4, 3, 2, 10)
|
|
237
|
+
|
|
238
|
+
>>> paths = gcs_goes_path(t, GOESRegion.F, bands=("C11", "C12", "C13"))
|
|
239
|
+
>>> pprint(paths)
|
|
240
|
+
['gcp-public-data-goes-16/ABI-L2-CMIPF/2023/093/02/OR_ABI-L2-CMIPF-M6C11_G16_s20230930210203_e20230930219511_c20230930219586.nc',
|
|
241
|
+
'gcp-public-data-goes-16/ABI-L2-CMIPF/2023/093/02/OR_ABI-L2-CMIPF-M6C12_G16_s20230930210203_e20230930219516_c20230930219596.nc',
|
|
242
|
+
'gcp-public-data-goes-16/ABI-L2-CMIPF/2023/093/02/OR_ABI-L2-CMIPF-M6C13_G16_s20230930210203_e20230930219523_c20230930219586.nc']
|
|
243
|
+
|
|
244
|
+
>>> paths = gcs_goes_path(t, GOESRegion.C, bands=("C11", "C12", "C13"))
|
|
245
|
+
>>> pprint(paths)
|
|
246
|
+
['gcp-public-data-goes-16/ABI-L2-CMIPC/2023/093/02/OR_ABI-L2-CMIPC-M6C11_G16_s20230930211170_e20230930213543_c20230930214055.nc',
|
|
247
|
+
'gcp-public-data-goes-16/ABI-L2-CMIPC/2023/093/02/OR_ABI-L2-CMIPC-M6C12_G16_s20230930211170_e20230930213551_c20230930214045.nc',
|
|
248
|
+
'gcp-public-data-goes-16/ABI-L2-CMIPC/2023/093/02/OR_ABI-L2-CMIPC-M6C13_G16_s20230930211170_e20230930213557_c20230930214065.nc']
|
|
249
|
+
|
|
250
|
+
>>> t = datetime.datetime(2023, 4, 3, 2, 11)
|
|
251
|
+
>>> paths = gcs_goes_path(t, GOESRegion.M1, bands="C01")
|
|
252
|
+
>>> pprint(paths)
|
|
253
|
+
['gcp-public-data-goes-16/ABI-L2-CMIPM/2023/093/02/OR_ABI-L2-CMIPM1-M6C01_G16_s20230930211249_e20230930211309_c20230930211386.nc']
|
|
254
|
+
|
|
255
|
+
>>> t = datetime.datetime(2025, 5, 4, 3, 2)
|
|
256
|
+
>>> paths = gcs_goes_path(t, GOESRegion.M2, bands="C01")
|
|
257
|
+
>>> pprint(paths)
|
|
258
|
+
['gcp-public-data-goes-19/ABI-L2-CMIPM/2025/124/03/OR_ABI-L2-CMIPM2-M6C01_G19_s20251240302557_e20251240303014_c20251240303092.nc']
|
|
259
|
+
|
|
260
|
+
"""
|
|
261
|
+
time = _check_time_resolution(time, region)
|
|
262
|
+
year = time.strftime("%Y")
|
|
263
|
+
yday = time.strftime("%j")
|
|
264
|
+
hour = time.strftime("%H")
|
|
265
|
+
|
|
266
|
+
sensor = "ABI" # Advanced Baseline Imager
|
|
267
|
+
level = "L2" # Level 2
|
|
268
|
+
product_name = "CMIP" # Cloud and Moisture Imagery
|
|
269
|
+
product = f"{sensor}-{level}-{product_name}{region.name[0]}"
|
|
270
|
+
|
|
271
|
+
if bucket is None:
|
|
272
|
+
bucket = GOES_16_BUCKET if time < GOES_16_19_SWITCH_DATE else GOES_19_BUCKET
|
|
273
|
+
else:
|
|
274
|
+
bucket = bucket.removeprefix("gs://")
|
|
275
|
+
|
|
276
|
+
path_prefix = f"gs://{bucket}/{product}/{year}/{yday}/{hour}/"
|
|
277
|
+
|
|
278
|
+
# https://www.goes-r.gov/users/abiScanModeInfo.html
|
|
279
|
+
mode = "M6" if time >= GOES_SCAN_MODE_CHANGE else "M3"
|
|
280
|
+
|
|
281
|
+
# Example name pattern
|
|
282
|
+
# OR_ABI-L1b-RadF-M3C02_G16_s20171671145342_e20171671156109_c20171671156144.nc
|
|
283
|
+
time_str = time.strftime("%Y%j%H%M")
|
|
284
|
+
if region == GOESRegion.F:
|
|
285
|
+
time_str = time_str[:-1] # might not work before 2019-04-02?
|
|
286
|
+
elif region == GOESRegion.C:
|
|
287
|
+
# Very crude -- assuming scan time ends with 1 or 6
|
|
288
|
+
if time_str.endswith("0"):
|
|
289
|
+
time_str = f"{time_str[:-1]}1"
|
|
290
|
+
elif time_str.endswith("5"):
|
|
291
|
+
time_str = f"{time_str[:-1]}6"
|
|
292
|
+
|
|
293
|
+
name_prefix = f"OR_{product[:-1]}{region.name}-{mode}"
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
satellite_number = int(bucket[-2:]) # 16 or 18 or 19 -- this may fail for custom buckets
|
|
297
|
+
except (ValueError, IndexError) as exc:
|
|
298
|
+
msg = f"Bucket name {bucket} does not end with a valid satellite number."
|
|
299
|
+
raise ValueError(msg) from exc
|
|
300
|
+
name_suffix = f"_G{satellite_number}_s{time_str}*"
|
|
301
|
+
|
|
302
|
+
bands = _parse_bands(bands)
|
|
303
|
+
|
|
304
|
+
# It's faster to run a single glob with C?? then running a glob for
|
|
305
|
+
# each band. The downside is that we have to filter the results.
|
|
306
|
+
rpath = f"{path_prefix}{name_prefix}C??{name_suffix}"
|
|
307
|
+
|
|
308
|
+
fs = fs or gcsfs.GCSFileSystem(token="anon")
|
|
309
|
+
rpaths = fs.glob(rpath)
|
|
310
|
+
|
|
311
|
+
out = []
|
|
312
|
+
for r in rpaths:
|
|
313
|
+
if (band := _extract_band_from_rpath(r)) in bands:
|
|
314
|
+
out.append(r)
|
|
315
|
+
bands.remove(band)
|
|
316
|
+
|
|
317
|
+
if bands:
|
|
318
|
+
raise FileNotFoundError(f"No data found for {time} in {region} for bands {bands}")
|
|
319
|
+
return out
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _extract_band_from_rpath(rpath: str) -> str:
|
|
323
|
+
# Split at the separator between product name and mode
|
|
324
|
+
# This works for both M3 and M6
|
|
325
|
+
sep = "-M"
|
|
326
|
+
suffix = rpath.split(sep, maxsplit=1)[1]
|
|
327
|
+
return suffix[1:4]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class GOES:
|
|
331
|
+
"""Support for GOES-16 data access via GCP.
|
|
332
|
+
|
|
333
|
+
This interface requires the ``gcsfs`` package.
|
|
334
|
+
|
|
335
|
+
Parameters
|
|
336
|
+
----------
|
|
337
|
+
region : GOESRegion | str, optional
|
|
338
|
+
GOES Region of interest. Uses the following conventions.
|
|
339
|
+
|
|
340
|
+
- F: Full Disk
|
|
341
|
+
- C: CONUS
|
|
342
|
+
- M1: Mesoscale 1
|
|
343
|
+
- M2: Mesoscale 2
|
|
344
|
+
|
|
345
|
+
By default, Full Disk (F) is used.
|
|
346
|
+
|
|
347
|
+
bands : str | Iterable[str] | None
|
|
348
|
+
Set of bands or bands for CMIP data. The 16 possible bands are
|
|
349
|
+
represented by the strings "C01" to "C16". For the SEVIRI ash color scheme,
|
|
350
|
+
set ``bands=("C11", "C14", "C15")``. For the true color scheme,
|
|
351
|
+
set ``bands=("C01", "C02", "C03")``. By default, the bands
|
|
352
|
+
required by the SEVIRI ash color scheme are used. The bands must have
|
|
353
|
+
a common horizontal resolution. The resolutions are:
|
|
354
|
+
|
|
355
|
+
- C01: 1.0 km
|
|
356
|
+
- C02: 0.5 km (treated as 1.0 km)
|
|
357
|
+
- C03: 1.0 km
|
|
358
|
+
- C04: 2.0 km
|
|
359
|
+
- C05: 1.0 km
|
|
360
|
+
- C06 - C16: 2.0 km
|
|
361
|
+
|
|
362
|
+
cachestore : cache.CacheStore | None, optional
|
|
363
|
+
Cache store for GOES data. If None, data is downloaded directly into
|
|
364
|
+
memory. By default, a :class:`cache.DiskCacheStore` is used.
|
|
365
|
+
bucket : str | None, optional
|
|
366
|
+
GCP bucket for GOES data. If None, the default option, the bucket is automatically
|
|
367
|
+
set to ``GOES_16_BUCKET`` if the requested time is before
|
|
368
|
+
``GOES_16_19_SWITCH_DATE`` and ``GOES_19_BUCKET`` otherwise.
|
|
369
|
+
The satellite number used for filename construction is derived from the
|
|
370
|
+
last two characters of this bucket name.
|
|
371
|
+
|
|
372
|
+
See Also
|
|
373
|
+
--------
|
|
374
|
+
GOESRegion
|
|
375
|
+
gcs_goes_path
|
|
376
|
+
|
|
377
|
+
Examples
|
|
378
|
+
--------
|
|
379
|
+
>>> goes = GOES(region="M1", bands=("C11", "C14"))
|
|
380
|
+
>>> da = goes.get("2021-04-03 02:10:00")
|
|
381
|
+
>>> da.shape
|
|
382
|
+
(2, 500, 500)
|
|
383
|
+
|
|
384
|
+
>>> da.dims
|
|
385
|
+
('band_id', 'y', 'x')
|
|
386
|
+
|
|
387
|
+
>>> da.band_id.values
|
|
388
|
+
array([11, 14], dtype=int32)
|
|
389
|
+
|
|
390
|
+
>>> # Print out a sample of the data
|
|
391
|
+
>>> da.sel(band_id=11).isel(x=slice(0, 50, 10), y=slice(0, 50, 10)).values
|
|
392
|
+
array([[266.8644 , 265.50812, 271.5592 , 271.45486, 272.75897],
|
|
393
|
+
[250.53697, 273.28064, 273.80225, 270.77673, 274.8977 ],
|
|
394
|
+
[272.8633 , 272.65466, 271.5592 , 274.01093, 273.12415],
|
|
395
|
+
[274.16742, 274.11523, 276.5148 , 273.85443, 270.51593],
|
|
396
|
+
[274.84555, 275.15854, 272.60248, 270.67242, 272.23734]],
|
|
397
|
+
dtype=float32)
|
|
398
|
+
|
|
399
|
+
>>> # The data has been cached locally
|
|
400
|
+
>>> assert goes.cachestore.listdir()
|
|
401
|
+
|
|
402
|
+
>>> # Download GOES data directly into memory by setting cachestore=None
|
|
403
|
+
>>> goes = GOES(region="M2", bands=("C11", "C12", "C13"), cachestore=None)
|
|
404
|
+
>>> da = goes.get("2021-04-03 02:10:00")
|
|
405
|
+
|
|
406
|
+
>>> da.shape
|
|
407
|
+
(3, 500, 500)
|
|
408
|
+
|
|
409
|
+
>>> da.dims
|
|
410
|
+
('band_id', 'y', 'x')
|
|
411
|
+
|
|
412
|
+
>>> da.band_id.values
|
|
413
|
+
array([11, 12, 13], dtype=int32)
|
|
414
|
+
|
|
415
|
+
>>> da.attrs["long_name"]
|
|
416
|
+
'ABI L2+ Cloud and Moisture Imagery brightness temperature'
|
|
417
|
+
|
|
418
|
+
>>> da.sel(band_id=11).values
|
|
419
|
+
array([[251.31944, 249.59802, 249.65018, ..., 270.30725, 270.51593,
|
|
420
|
+
269.83777],
|
|
421
|
+
[250.53697, 249.0242 , 249.12854, ..., 270.15076, 270.30725,
|
|
422
|
+
269.73346],
|
|
423
|
+
[249.1807 , 249.33719, 251.99757, ..., 270.15076, 270.20294,
|
|
424
|
+
268.7945 ],
|
|
425
|
+
...,
|
|
426
|
+
[277.24512, 277.29727, 277.45377, ..., 274.42822, 274.11523,
|
|
427
|
+
273.7501 ],
|
|
428
|
+
[277.24512, 277.45377, 278.18408, ..., 274.6369 , 274.01093,
|
|
429
|
+
274.06308],
|
|
430
|
+
[276.8278 , 277.14078, 277.7146 , ..., 274.6369 , 273.9066 ,
|
|
431
|
+
274.16742]], shape=(500, 500), dtype=float32)
|
|
432
|
+
|
|
433
|
+
"""
|
|
434
|
+
|
|
435
|
+
__slots__ = ("bands", "bucket", "cachestore", "fs", "region")
|
|
436
|
+
|
|
437
|
+
__marker = object()
|
|
438
|
+
|
|
439
|
+
def __init__(
|
|
440
|
+
self,
|
|
441
|
+
region: GOESRegion | str = GOESRegion.F,
|
|
442
|
+
bands: str | Iterable[str] | None = None,
|
|
443
|
+
*,
|
|
444
|
+
channels: str | Iterable[str] | None = None, # deprecated alias for bands
|
|
445
|
+
cachestore: cache.CacheStore | None = __marker, # type: ignore[assignment]
|
|
446
|
+
bucket: str | None = None,
|
|
447
|
+
goes_bucket: str | None = None, # deprecated alias for bucket
|
|
448
|
+
) -> None:
|
|
449
|
+
if channels is not None:
|
|
450
|
+
if bands is not None:
|
|
451
|
+
raise ValueError("Only one of channels or bands should be specified")
|
|
452
|
+
warnings.warn(
|
|
453
|
+
"The 'channels' parameter is deprecated and will be removed in a future release. "
|
|
454
|
+
"Use 'bands' instead.",
|
|
455
|
+
DeprecationWarning,
|
|
456
|
+
stacklevel=2,
|
|
457
|
+
)
|
|
458
|
+
bands = channels
|
|
459
|
+
if goes_bucket is not None:
|
|
460
|
+
if bucket is not None:
|
|
461
|
+
raise ValueError("Only one of goes_bucket or bucket should be specified")
|
|
462
|
+
warnings.warn(
|
|
463
|
+
"The 'goes_bucket' parameter is deprecated and will be removed in a future release."
|
|
464
|
+
"Use 'bucket' instead.",
|
|
465
|
+
DeprecationWarning,
|
|
466
|
+
stacklevel=2,
|
|
467
|
+
)
|
|
468
|
+
bucket = goes_bucket
|
|
469
|
+
|
|
470
|
+
self.region = _parse_region(region)
|
|
471
|
+
self.bands = _parse_bands(bands)
|
|
472
|
+
_check_band_resolution(self.bands)
|
|
473
|
+
|
|
474
|
+
self.bucket = bucket
|
|
475
|
+
self.fs = gcsfs.GCSFileSystem(token="anon")
|
|
476
|
+
|
|
477
|
+
if cachestore is self.__marker:
|
|
478
|
+
cache_root = cache._get_user_cache_dir()
|
|
479
|
+
cache_dir = f"{cache_root}/goes"
|
|
480
|
+
cachestore = cache.DiskCacheStore(cache_dir=cache_dir)
|
|
481
|
+
self.cachestore = cachestore
|
|
482
|
+
|
|
483
|
+
def __repr__(self) -> str:
|
|
484
|
+
"""Return string representation."""
|
|
485
|
+
return (
|
|
486
|
+
f"GOES(region='{self.region.name}', bands={sorted(self.bands)}, bucket={self.bucket})"
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
def gcs_goes_path(self, time: datetime.datetime, bands: set[str] | None = None) -> list[str]:
|
|
490
|
+
"""Return GCS paths to GOES data at given time.
|
|
491
|
+
|
|
492
|
+
Presently only supported for GOES data whose scan time minute coincides with
|
|
493
|
+
the minute of the time parameter.
|
|
494
|
+
|
|
495
|
+
Parameters
|
|
496
|
+
----------
|
|
497
|
+
time : datetime.datetime
|
|
498
|
+
Time of GOES data.
|
|
499
|
+
bands : set[str] | None
|
|
500
|
+
Set of bands or bands for CMIP data. If None, the :attr:`bands`
|
|
501
|
+
attribute is used.
|
|
502
|
+
|
|
503
|
+
Returns
|
|
504
|
+
-------
|
|
505
|
+
list[str]
|
|
506
|
+
List of GCS paths to GOES data.
|
|
507
|
+
"""
|
|
508
|
+
bands = bands or self.bands
|
|
509
|
+
return gcs_goes_path(time, self.region, bands, bucket=self.bucket, fs=self.fs)
|
|
510
|
+
|
|
511
|
+
def _lpaths(self, time: datetime.datetime) -> dict[str, str]:
|
|
512
|
+
"""Construct names for local netcdf files using the :attr:`cachestore`.
|
|
513
|
+
|
|
514
|
+
Returns dictionary of the form ``{band: local_path}``.
|
|
515
|
+
"""
|
|
516
|
+
if not self.cachestore:
|
|
517
|
+
raise ValueError("cachestore must be set to use _lpaths")
|
|
518
|
+
|
|
519
|
+
t_str = time.strftime("%Y%m%d%H%M")
|
|
520
|
+
|
|
521
|
+
out = {}
|
|
522
|
+
for band in self.bands:
|
|
523
|
+
if self.bucket:
|
|
524
|
+
name = f"{self.bucket}_{self.region.name}_{t_str}_{band}.nc"
|
|
525
|
+
else:
|
|
526
|
+
name = f"{self.region.name}_{t_str}_{band}.nc"
|
|
527
|
+
|
|
528
|
+
lpath = self.cachestore.path(name)
|
|
529
|
+
out[band] = lpath
|
|
530
|
+
|
|
531
|
+
return out
|
|
532
|
+
|
|
533
|
+
def get(self, time: datetime.datetime | str) -> xr.DataArray:
|
|
534
|
+
"""Return GOES data at given time.
|
|
535
|
+
|
|
536
|
+
Parameters
|
|
537
|
+
----------
|
|
538
|
+
time : datetime.datetime | str
|
|
539
|
+
Time of GOES data. This should be a timezone-naive datetime object
|
|
540
|
+
or an ISO 8601 formatted string.
|
|
541
|
+
|
|
542
|
+
Returns
|
|
543
|
+
-------
|
|
544
|
+
xr.DataArray
|
|
545
|
+
DataArray of GOES data with coordinates:
|
|
546
|
+
|
|
547
|
+
- band_id: Channel or band ID
|
|
548
|
+
- x: GOES x-coordinate
|
|
549
|
+
- y: GOES y-coordinate
|
|
550
|
+
"""
|
|
551
|
+
t = pd.Timestamp(time).to_pydatetime()
|
|
552
|
+
|
|
553
|
+
if self.cachestore is not None:
|
|
554
|
+
return self._get_with_cache(t)
|
|
555
|
+
return self._get_without_cache(t)
|
|
556
|
+
|
|
557
|
+
def _get_with_cache(self, time: datetime.datetime) -> xr.DataArray:
|
|
558
|
+
"""Download the GOES data to the :attr:`cachestore` at the given time."""
|
|
559
|
+
if self.cachestore is None:
|
|
560
|
+
raise ValueError("cachestore must be set to use _get_with_cache")
|
|
561
|
+
|
|
562
|
+
lpaths = self._lpaths(time)
|
|
563
|
+
bands_needed = {c for c, lpath in lpaths.items() if not self.cachestore.exists(lpath)}
|
|
564
|
+
|
|
565
|
+
if bands_needed:
|
|
566
|
+
rpaths = self.gcs_goes_path(time, bands_needed)
|
|
567
|
+
for rpath in rpaths:
|
|
568
|
+
band = _extract_band_from_rpath(rpath)
|
|
569
|
+
lpath = lpaths[band]
|
|
570
|
+
self.fs.get(rpath, lpath)
|
|
571
|
+
|
|
572
|
+
# Deal with the different spatial resolutions
|
|
573
|
+
kwargs = {
|
|
574
|
+
"concat_dim": "band",
|
|
575
|
+
"combine": "nested",
|
|
576
|
+
"data_vars": ["CMI"],
|
|
577
|
+
"compat": "override",
|
|
578
|
+
"coords": "minimal",
|
|
579
|
+
}
|
|
580
|
+
if len(lpaths) > 1 and "C02" in lpaths: # xr.open_mfdataset fails after pop if only 1 file
|
|
581
|
+
lpath02 = lpaths.pop("C02")
|
|
582
|
+
ds = xr.open_mfdataset(lpaths.values(), **kwargs).swap_dims(band="band_id") # type: ignore[arg-type]
|
|
583
|
+
da1 = ds.reset_coords()["CMI"]
|
|
584
|
+
da2 = xr.open_dataset(lpath02).reset_coords()["CMI"].expand_dims(band_id=[2])
|
|
585
|
+
da = (
|
|
586
|
+
geo_utils._coarsen_then_concat(da1, da2)
|
|
587
|
+
.sortby("band_id")
|
|
588
|
+
.assign_coords(t=ds["t"].values)
|
|
589
|
+
)
|
|
590
|
+
else:
|
|
591
|
+
ds = xr.open_mfdataset(lpaths.values(), **kwargs).swap_dims(band="band_id") # type: ignore[arg-type]
|
|
592
|
+
da = ds["CMI"].sortby("band_id")
|
|
593
|
+
|
|
594
|
+
# Attach some useful attrs -- only using goes_imager_projection currently
|
|
595
|
+
da.attrs["goes_imager_projection"] = ds.goes_imager_projection.attrs
|
|
596
|
+
da.attrs["geospatial_lat_lon_extent"] = ds.geospatial_lat_lon_extent.attrs
|
|
597
|
+
|
|
598
|
+
return da
|
|
599
|
+
|
|
600
|
+
def _get_without_cache(self, time: datetime.datetime) -> xr.DataArray:
|
|
601
|
+
"""Download the GOES data into memory at the given time."""
|
|
602
|
+
rpaths = self.gcs_goes_path(time)
|
|
603
|
+
|
|
604
|
+
# Load into memory
|
|
605
|
+
data = self.fs.cat(rpaths)
|
|
606
|
+
|
|
607
|
+
da_dict = {}
|
|
608
|
+
for rpath, init_bytes in data.items():
|
|
609
|
+
band = _extract_band_from_rpath(rpath)
|
|
610
|
+
ds = _load_via_tempfile(init_bytes)
|
|
611
|
+
|
|
612
|
+
da = ds["CMI"]
|
|
613
|
+
da = da.expand_dims(band_id=ds["band_id"].values)
|
|
614
|
+
da_dict[band] = da
|
|
615
|
+
|
|
616
|
+
if len(da_dict) > 1 and "C02" in da_dict: # xr.concat fails after pop if only 1 file
|
|
617
|
+
da2 = da_dict.pop("C02")
|
|
618
|
+
da1 = xr.concat(da_dict.values(), dim="band_id", coords="different", compat="equals")
|
|
619
|
+
da = geo_utils._coarsen_then_concat(da1, da2)
|
|
620
|
+
else:
|
|
621
|
+
da = xr.concat(da_dict.values(), dim="band_id", coords="different", compat="equals")
|
|
622
|
+
|
|
623
|
+
da = da.sortby("band_id")
|
|
624
|
+
|
|
625
|
+
# Attach some useful attrs -- only using goes_imager_projection currently
|
|
626
|
+
da.attrs["goes_imager_projection"] = ds.goes_imager_projection.attrs
|
|
627
|
+
da.attrs["geospatial_lat_lon_extent"] = ds.geospatial_lat_lon_extent.attrs
|
|
628
|
+
|
|
629
|
+
return da
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _load_via_tempfile(data: bytes) -> xr.Dataset:
|
|
633
|
+
"""Load xarray dataset via temporary file."""
|
|
634
|
+
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
|
635
|
+
tmp.write(data)
|
|
636
|
+
try:
|
|
637
|
+
return xr.load_dataset(tmp.name)
|
|
638
|
+
finally:
|
|
639
|
+
os.remove(tmp.name)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _cartopy_crs(proj_info: dict[str, Any]) -> cartopy.crs.Geostationary:
|
|
643
|
+
try:
|
|
644
|
+
from cartopy import crs as ccrs
|
|
645
|
+
except ModuleNotFoundError as exc:
|
|
646
|
+
dependencies.raise_module_not_found_error(
|
|
647
|
+
name="GOES visualization",
|
|
648
|
+
package_name="cartopy",
|
|
649
|
+
module_not_found_error=exc,
|
|
650
|
+
pycontrails_optional_package="sat",
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
globe = ccrs.Globe(
|
|
654
|
+
semimajor_axis=proj_info["semi_major_axis"],
|
|
655
|
+
semiminor_axis=proj_info["semi_minor_axis"],
|
|
656
|
+
)
|
|
657
|
+
return ccrs.Geostationary(
|
|
658
|
+
central_longitude=proj_info["longitude_of_projection_origin"],
|
|
659
|
+
satellite_height=proj_info["perspective_point_height"],
|
|
660
|
+
sweep_axis=proj_info["sweep_angle_axis"],
|
|
661
|
+
globe=globe,
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def extract_visualization(
|
|
666
|
+
da: xr.DataArray,
|
|
667
|
+
color_scheme: str = "ash",
|
|
668
|
+
ash_convention: str = "SEVIRI",
|
|
669
|
+
gamma: float = 2.2,
|
|
670
|
+
) -> tuple[npt.NDArray[np.float32], cartopy.crs.Geostationary, tuple[float, float, float, float]]:
|
|
671
|
+
"""Extract artifacts for visualizing GOES data with the given color scheme.
|
|
672
|
+
|
|
673
|
+
Parameters
|
|
674
|
+
----------
|
|
675
|
+
da : xr.DataArray
|
|
676
|
+
DataArray of GOES data as returned by :meth:`GOES.get`. Must have the bands
|
|
677
|
+
required by :func:`to_ash`.
|
|
678
|
+
color_scheme : str
|
|
679
|
+
Color scheme to use for visualization. Must be one of {"true", "ash"}.
|
|
680
|
+
If "true", the ``da`` must contain bands C01, C02, and C03.
|
|
681
|
+
If "ash", the ``da`` must contain bands C11, C14, and C15 (SEVIRI convention)
|
|
682
|
+
or bands C11, C13, C14, and C15 (standard convention).
|
|
683
|
+
ash_convention : str
|
|
684
|
+
Passed into :func:`to_ash`. Only used if ``color_scheme="ash"``. Must be one
|
|
685
|
+
of {"SEVIRI", "standard"}. By default, "SEVIRI" is used.
|
|
686
|
+
gamma : float
|
|
687
|
+
Passed into :func:`to_true_color`. Only used if ``color_scheme="true"``. By
|
|
688
|
+
default, 2.2 is used.
|
|
689
|
+
|
|
690
|
+
Returns
|
|
691
|
+
-------
|
|
692
|
+
rgb : npt.NDArray[np.float32]
|
|
693
|
+
3D RGB array of shape ``(height, width, 3)``. Any nan values are replaced with 0.
|
|
694
|
+
src_crs : cartopy.crs.Geostationary
|
|
695
|
+
The Geostationary projection built from the GOES metadata.
|
|
696
|
+
src_extent : tuple[float, float, float, float]
|
|
697
|
+
Extent of GOES data in the Geostationary projection
|
|
698
|
+
"""
|
|
699
|
+
proj_info = da.attrs["goes_imager_projection"]
|
|
700
|
+
h = proj_info["perspective_point_height"]
|
|
701
|
+
|
|
702
|
+
src_crs = _cartopy_crs(proj_info)
|
|
703
|
+
|
|
704
|
+
if color_scheme == "true":
|
|
705
|
+
rgb = to_true_color(da, gamma)
|
|
706
|
+
elif color_scheme == "ash":
|
|
707
|
+
rgb = to_ash(da, ash_convention)
|
|
708
|
+
else:
|
|
709
|
+
raise ValueError(f"Color scheme must be 'true' or 'ash', not '{color_scheme}'")
|
|
710
|
+
|
|
711
|
+
np.nan_to_num(rgb, copy=False)
|
|
712
|
+
|
|
713
|
+
x = da["x"].values
|
|
714
|
+
y = da["y"].values
|
|
715
|
+
|
|
716
|
+
# Multiply extremes by the satellite height
|
|
717
|
+
src_extent = h * x.min(), h * x.max(), h * y.min(), h * y.max()
|
|
718
|
+
|
|
719
|
+
return rgb, src_crs, src_extent
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
extract_goes_visualization = extract_visualization # keep for backwards compatibility
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def to_true_color(da: xr.DataArray, gamma: float = 2.2) -> npt.NDArray[np.float32]:
|
|
726
|
+
"""Compute 3d RGB array for the true color scheme.
|
|
727
|
+
|
|
728
|
+
Parameters
|
|
729
|
+
----------
|
|
730
|
+
da : xr.DataArray
|
|
731
|
+
DataArray of GOES data with bands C01, C02, C03.
|
|
732
|
+
gamma : float, optional
|
|
733
|
+
Gamma correction for the RGB bands.
|
|
734
|
+
|
|
735
|
+
Returns
|
|
736
|
+
-------
|
|
737
|
+
npt.NDArray[np.float32]
|
|
738
|
+
3d RGB array with true color scheme.
|
|
739
|
+
|
|
740
|
+
References
|
|
741
|
+
----------
|
|
742
|
+
- `Unidata's true color recipe <https://unidata.github.io/python-gallery/examples/mapping_GOES16_TrueColor.html>`_
|
|
743
|
+
"""
|
|
744
|
+
if not np.all(np.isin([1, 2, 3], da["band_id"])):
|
|
745
|
+
msg = "DataArray must contain bands 1, 2, and 3 for true color"
|
|
746
|
+
raise ValueError(msg)
|
|
747
|
+
|
|
748
|
+
red = da.sel(band_id=2).values
|
|
749
|
+
veggie = da.sel(band_id=3).values
|
|
750
|
+
blue = da.sel(band_id=1).values
|
|
751
|
+
|
|
752
|
+
red = geo_utils._clip_and_scale(red, 0.0, 1.0)
|
|
753
|
+
veggie = geo_utils._clip_and_scale(veggie, 0.0, 1.0)
|
|
754
|
+
blue = geo_utils._clip_and_scale(blue, 0.0, 1.0)
|
|
755
|
+
|
|
756
|
+
red = red ** (1 / gamma)
|
|
757
|
+
veggie = veggie ** (1 / gamma)
|
|
758
|
+
blue = blue ** (1 / gamma)
|
|
759
|
+
|
|
760
|
+
# Calculate synthetic green band
|
|
761
|
+
green = 0.45 * red + 0.1 * veggie + 0.45 * blue
|
|
762
|
+
green = geo_utils._clip_and_scale(green, 0.0, 1.0)
|
|
763
|
+
|
|
764
|
+
return np.dstack([red, green, blue])
|