pycontrails 0.53.0__cp313-cp313-macosx_11_0_arm64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of pycontrails might be problematic. Click here for more details.
- pycontrails/__init__.py +70 -0
- pycontrails/_version.py +16 -0
- pycontrails/core/__init__.py +30 -0
- pycontrails/core/aircraft_performance.py +641 -0
- pycontrails/core/airports.py +226 -0
- pycontrails/core/cache.py +881 -0
- pycontrails/core/coordinates.py +174 -0
- pycontrails/core/fleet.py +470 -0
- pycontrails/core/flight.py +2312 -0
- pycontrails/core/flightplan.py +220 -0
- pycontrails/core/fuel.py +140 -0
- pycontrails/core/interpolation.py +721 -0
- pycontrails/core/met.py +2833 -0
- pycontrails/core/met_var.py +307 -0
- pycontrails/core/models.py +1181 -0
- pycontrails/core/polygon.py +549 -0
- pycontrails/core/rgi_cython.cpython-313-darwin.so +0 -0
- pycontrails/core/vector.py +2191 -0
- pycontrails/datalib/__init__.py +12 -0
- pycontrails/datalib/_leo_utils/search.py +250 -0
- pycontrails/datalib/_leo_utils/static/bq_roi_query.sql +6 -0
- pycontrails/datalib/_leo_utils/vis.py +59 -0
- pycontrails/datalib/_met_utils/metsource.py +743 -0
- pycontrails/datalib/ecmwf/__init__.py +53 -0
- pycontrails/datalib/ecmwf/arco_era5.py +527 -0
- pycontrails/datalib/ecmwf/common.py +109 -0
- pycontrails/datalib/ecmwf/era5.py +538 -0
- pycontrails/datalib/ecmwf/era5_model_level.py +482 -0
- pycontrails/datalib/ecmwf/hres.py +782 -0
- pycontrails/datalib/ecmwf/hres_model_level.py +495 -0
- pycontrails/datalib/ecmwf/ifs.py +284 -0
- pycontrails/datalib/ecmwf/model_levels.py +79 -0
- pycontrails/datalib/ecmwf/static/model_level_dataframe_v20240418.csv +139 -0
- pycontrails/datalib/ecmwf/variables.py +256 -0
- pycontrails/datalib/gfs/__init__.py +28 -0
- pycontrails/datalib/gfs/gfs.py +646 -0
- pycontrails/datalib/gfs/variables.py +100 -0
- pycontrails/datalib/goes.py +772 -0
- pycontrails/datalib/landsat.py +568 -0
- pycontrails/datalib/sentinel.py +512 -0
- pycontrails/datalib/spire.py +739 -0
- pycontrails/ext/bada.py +41 -0
- pycontrails/ext/cirium.py +14 -0
- pycontrails/ext/empirical_grid.py +140 -0
- pycontrails/ext/synthetic_flight.py +426 -0
- pycontrails/models/__init__.py +1 -0
- pycontrails/models/accf.py +406 -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 +2617 -0
- pycontrails/models/cocip/cocip_params.py +299 -0
- pycontrails/models/cocip/cocip_uncertainty.py +285 -0
- pycontrails/models/cocip/contrail_properties.py +1517 -0
- pycontrails/models/cocip/output_formats.py +2261 -0
- pycontrails/models/cocip/radiative_forcing.py +1262 -0
- pycontrails/models/cocip/radiative_heating.py +520 -0
- pycontrails/models/cocip/unterstrasser_wake_vortex.py +403 -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 +2573 -0
- pycontrails/models/cocipgrid/cocip_grid_params.py +138 -0
- pycontrails/models/dry_advection.py +486 -0
- pycontrails/models/emissions/__init__.py +21 -0
- pycontrails/models/emissions/black_carbon.py +594 -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/humidity_scaling/__init__.py +37 -0
- pycontrails/models/humidity_scaling/humidity_scaling.py +1025 -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 +327 -0
- pycontrails/models/pcr.py +154 -0
- pycontrails/models/ps_model/__init__.py +17 -0
- pycontrails/models/ps_model/ps_aircraft_params.py +376 -0
- pycontrails/models/ps_model/ps_grid.py +505 -0
- pycontrails/models/ps_model/ps_model.py +1017 -0
- pycontrails/models/ps_model/ps_operational_limits.py +540 -0
- pycontrails/models/ps_model/static/ps-aircraft-params-20240524.csv +68 -0
- pycontrails/models/ps_model/static/ps-synonym-list-20240524.csv +103 -0
- pycontrails/models/sac.py +459 -0
- pycontrails/models/tau_cirrus.py +168 -0
- pycontrails/physics/__init__.py +1 -0
- pycontrails/physics/constants.py +116 -0
- pycontrails/physics/geo.py +989 -0
- pycontrails/physics/jet.py +837 -0
- pycontrails/physics/thermo.py +451 -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 +188 -0
- pycontrails/utils/temp.py +50 -0
- pycontrails/utils/types.py +165 -0
- pycontrails-0.53.0.dist-info/LICENSE +178 -0
- pycontrails-0.53.0.dist-info/METADATA +181 -0
- pycontrails-0.53.0.dist-info/NOTICE +43 -0
- pycontrails-0.53.0.dist-info/RECORD +109 -0
- pycontrails-0.53.0.dist-info/WHEEL +5 -0
- pycontrails-0.53.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)
|