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