pycontrails 0.56.0__cp310-cp310-macosx_10_9_x86_64.whl → 0.57.0__cp310-cp310-macosx_10_9_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/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.56.0'
32
- __version_tuple__ = version_tuple = (0, 56, 0)
31
+ __version__ = version = '0.57.0'
32
+ __version_tuple__ = version_tuple = (0, 57, 0)
33
33
 
34
- __commit_id__ = commit_id = 'gec7f244f4'
34
+ __commit_id__ = commit_id = 'g7b8b60b87'
@@ -0,0 +1,261 @@
1
+ """Tooling and support for GEO satellites."""
2
+
3
+ import numpy as np
4
+ import numpy.typing as npt
5
+ import xarray as xr
6
+
7
+ from pycontrails.utils import dependencies
8
+
9
+
10
+ def parallax_correct(
11
+ longitude: npt.NDArray[np.floating],
12
+ latitude: npt.NDArray[np.floating],
13
+ altitude: npt.NDArray[np.floating],
14
+ goes_da: xr.DataArray,
15
+ ) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]:
16
+ r"""Apply parallax correction to WGS84 geodetic coordinates based on satellite perspective.
17
+
18
+ This function considers the ray from the satellite to the points of interest and finds
19
+ the intersection of this ray with the WGS84 ellipsoid. The intersection point is then
20
+ returned as the corrected longitude and latitude coordinates.
21
+
22
+ ::
23
+
24
+ @ satellite
25
+ \
26
+ \
27
+ \
28
+ \
29
+ \
30
+ * aircraft
31
+ \
32
+ \
33
+ x parallax corrected aircraft
34
+ ------------------------- surface
35
+
36
+ If the point of interest is not visible from the satellite (ie, on the opposite side of the
37
+ earth), the function returns nan for the corrected coordinates.
38
+
39
+ This function requires the :mod:`pyproj` package to be installed.
40
+
41
+ Parameters
42
+ ----------
43
+ longitude : npt.NDArray[np.floating]
44
+ A 1D array of longitudes in degrees.
45
+ latitude : npt.NDArray[np.floating]
46
+ A 1D array of latitudes in degrees.
47
+ altitude : npt.NDArray[np.floating]
48
+ A 1D array of altitudes in meters.
49
+ goes_da : xr.DataArray
50
+ DataArray containing the GOES projection information. Only the ``goes_imager_projection``
51
+ field of the :attr:`xr.DataArray.attrs` is used.
52
+
53
+ Returns
54
+ -------
55
+ tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]
56
+ A tuple containing the corrected longitude and latitude coordinates.
57
+
58
+ """
59
+ goes_imager_projection = goes_da.attrs["goes_imager_projection"]
60
+ sat_lon = goes_imager_projection["longitude_of_projection_origin"]
61
+ sat_lat = goes_imager_projection["latitude_of_projection_origin"]
62
+ sat_alt = goes_imager_projection["perspective_point_height"]
63
+
64
+ try:
65
+ import pyproj
66
+ except ModuleNotFoundError as exc:
67
+ dependencies.raise_module_not_found_error(
68
+ name="parallax_correct function",
69
+ package_name="pyproj",
70
+ module_not_found_error=exc,
71
+ pycontrails_optional_package="pyproj",
72
+ )
73
+
74
+ # Convert from WGS84 to ECEF coordinates
75
+ ecef_crs = pyproj.CRS("EPSG:4978")
76
+ wgs84_crs = pyproj.CRS("EPSG:4326")
77
+ transformer = pyproj.Transformer.from_crs(wgs84_crs, ecef_crs, always_xy=True)
78
+
79
+ p0 = np.array(transformer.transform([sat_lon], [sat_lat], [sat_alt]))
80
+ p1 = np.array(transformer.transform(longitude, latitude, altitude))
81
+
82
+ # Major and minor axes of the ellipsoid
83
+ a = ecef_crs.ellipsoid.semi_major_metre # type: ignore[union-attr]
84
+ b = ecef_crs.ellipsoid.semi_minor_metre # type: ignore[union-attr]
85
+ intersection = _intersection_with_ellipsoid(p0, p1, a, b)
86
+
87
+ # Convert back to WGS84 coordinates
88
+ inv_transformer = pyproj.Transformer.from_crs(ecef_crs, wgs84_crs, always_xy=True)
89
+ return inv_transformer.transform(*intersection)[:2] # final coord is (close to) 0
90
+
91
+
92
+ def _intersection_with_ellipsoid(
93
+ p0: npt.NDArray[np.floating],
94
+ p1: npt.NDArray[np.floating],
95
+ a: float,
96
+ b: float,
97
+ ) -> npt.NDArray[np.floating]:
98
+ """Find the intersection of a line with the surface of an ellipsoid."""
99
+ # Calculate the direction vector
100
+ px, py, pz = p0
101
+ v = p1 - p0
102
+ vx, vy, vz = v
103
+
104
+ # The line between p0 and p1 in parametric form is p(t) = p0 + t * v
105
+ # We need to find t such that p(t) lies on the ellipsoid
106
+ # x^2 / a^2 + y^2 / a^2 + z^2 / b^2 = 1
107
+ # (px + t * vx)^2 / a^2 + (py + t * vy)^2 / a^2 + (pz + t * vz)^2 / b^2 = 1
108
+ # Rearranging gives a quadratic in t
109
+
110
+ # Calculate the coefficients of this quadratic equation
111
+ A = vx**2 / a**2 + vy**2 / a**2 + vz**2 / b**2
112
+ B = 2 * (px * vx / a**2 + py * vy / a**2 + pz * vz / b**2)
113
+ C = px**2 / a**2 + py**2 / a**2 + pz**2 / b**2 - 1.0
114
+
115
+ # Calculate the discriminant
116
+ D = B**2 - 4 * A * C
117
+ sqrtD = np.sqrt(D, where=D >= 0, out=np.full_like(D, np.nan))
118
+
119
+ # Calculate the two possible solutions for t
120
+ t0 = (-B + sqrtD) / (2.0 * A)
121
+ t1 = (-B - sqrtD) / (2.0 * A)
122
+
123
+ # Calculate the intersection points
124
+ intersection0 = p0 + t0 * v
125
+ intersection1 = p0 + t1 * v
126
+
127
+ # Pick the intersection point that is closer to the aircraft (p1)
128
+ d0 = np.linalg.norm(intersection0 - p1, axis=0)
129
+ d1 = np.linalg.norm(intersection1 - p1, axis=0)
130
+ out = np.where(d0 < d1, intersection0, intersection1)
131
+
132
+ # Fill the points in which the aircraft is not visible by the satellite with nan
133
+ # This occurs when the earth is between the satellite and the aircraft
134
+ # In other words, we can check for t0 < 1 (or t1 < 1)
135
+ opposite_side = t0 < 1.0
136
+ out[:, opposite_side] = np.nan
137
+
138
+ return out
139
+
140
+
141
+ def to_ash(da: xr.DataArray, convention: str = "SEVIRI") -> npt.NDArray[np.float32]:
142
+ """Compute 3d RGB array for the ASH color scheme.
143
+
144
+ Parameters
145
+ ----------
146
+ da : xr.DataArray
147
+ DataArray of GOES data with appropriate bands.
148
+ convention : str, optional
149
+ Convention for color space.
150
+
151
+ - SEVIRI convention requires bands C11, C14, C15.
152
+ Used in :cite:`kulikSatellitebasedDetectionContrails2019`.
153
+ - Standard convention requires bands C11, C13, C14, C15
154
+
155
+ Returns
156
+ -------
157
+ npt.NDArray[np.float32]
158
+ 3d RGB array with ASH color scheme according to convention.
159
+
160
+ References
161
+ ----------
162
+ - `Ash RGB quick guide (the color space and color interpretations) <https://rammb.cira.colostate.edu/training/visit/quick_guides/GOES_Ash_RGB.pdf>`_
163
+ - :cite:`SEVIRIRGBCal`
164
+ - :cite:`kulikSatellitebasedDetectionContrails2019`
165
+
166
+ Examples
167
+ --------
168
+ >>> from pycontrails.datalib.goes import GOES
169
+ >>> goes = GOES(region="M2", bands=("C11", "C14", "C15"))
170
+ >>> da = goes.get("2022-10-03 04:34:00")
171
+ >>> rgb = to_ash(da)
172
+ >>> rgb.shape
173
+ (500, 500, 3)
174
+
175
+ >>> rgb[0, 0, :]
176
+ array([0.0127004 , 0.22793579, 0.3930847 ], dtype=float32)
177
+ """
178
+ if convention == "standard":
179
+ if not np.all(np.isin([11, 13, 14, 15], da["band_id"])):
180
+ msg = "DataArray must contain bands 11, 13, 14, and 15 for standard ash"
181
+ raise ValueError(msg)
182
+ c11 = da.sel(band_id=11).values # 8.44
183
+ c13 = da.sel(band_id=13).values # 10.33
184
+ c14 = da.sel(band_id=14).values # 11.19
185
+ c15 = da.sel(band_id=15).values # 12.27
186
+
187
+ red = c15 - c13
188
+ green = c14 - c11
189
+ blue = c13
190
+
191
+ elif convention in ("SEVIRI", "MIT"): # retain MIT for backwards compatibility
192
+ if not np.all(np.isin([11, 14, 15], da["band_id"])):
193
+ msg = "DataArray must contain bands 11, 14, and 15 for SEVIRI ash"
194
+ raise ValueError(msg)
195
+ c11 = da.sel(band_id=11).values # 8.44
196
+ c14 = da.sel(band_id=14).values # 11.19
197
+ c15 = da.sel(band_id=15).values # 12.27
198
+
199
+ red = c15 - c14
200
+ green = c14 - c11
201
+ blue = c14
202
+
203
+ else:
204
+ raise ValueError("Convention must be either 'SEVIRI' or 'standard'")
205
+
206
+ # See colostate pdf for slightly wider values
207
+ red = _clip_and_scale(red, -4.0, 2.0)
208
+ green = _clip_and_scale(green, -4.0, 5.0)
209
+ blue = _clip_and_scale(blue, 243.0, 303.0)
210
+ return np.dstack([red, green, blue])
211
+
212
+
213
+ def _clip_and_scale(
214
+ arr: npt.NDArray[np.floating], low: float, high: float
215
+ ) -> npt.NDArray[np.floating]:
216
+ """Clip array and rescale to the interval [0, 1].
217
+
218
+ Array is first clipped to the interval [low, high] and then linearly rescaled
219
+ to the interval [0, 1] so that::
220
+
221
+ low -> 0
222
+ high -> 1
223
+
224
+ Parameters
225
+ ----------
226
+ arr : npt.NDArray[np.floating]
227
+ Array to clip and scale.
228
+ low : float
229
+ Lower clipping bound.
230
+ high : float
231
+ Upper clipping bound.
232
+
233
+ Returns
234
+ -------
235
+ npt.NDArray[np.floating]
236
+ Clipped and scaled array.
237
+ """
238
+ return (arr.clip(low, high) - low) / (high - low)
239
+
240
+
241
+ def _coarsen_then_concat(da1: xr.DataArray, da2: xr.DataArray) -> xr.DataArray:
242
+ """Concatenate two DataArrays, averaging da2 to da1's resolution.
243
+
244
+ This function is hacky and should not be used publicly. It is used in goes.py
245
+ and himawari.py to combine data from different resolutions.
246
+
247
+ The function assumes that da2 has exactly twice the resolution of da1 in both
248
+ the x and y dimensions.
249
+ """
250
+ da2 = da2.coarsen(x=2, y=2, boundary="exact").mean() # type: ignore[attr-defined]
251
+
252
+ # Gut check
253
+ np.testing.assert_allclose(da1["x"], da2["x"], atol=2e-5)
254
+ np.testing.assert_allclose(da1["y"], da2["y"], atol=2e-5)
255
+
256
+ # Assign the coarser coords to the coarsened coords to account for any small differences
257
+ da2["x"] = da1["x"]
258
+ da2["y"] = da1["y"]
259
+
260
+ # Finally, combine the datasets
261
+ return xr.concat([da1, da2], dim="band_id", coords="different", compat="equals")
@@ -15,7 +15,6 @@ import logging
15
15
  import pathlib
16
16
  import sys
17
17
  import warnings
18
- from collections.abc import Callable
19
18
  from datetime import datetime
20
19
  from typing import TYPE_CHECKING, Any
21
20
 
@@ -41,9 +40,8 @@ from pycontrails.datalib.gfs.variables import (
41
40
  from pycontrails.utils import dependencies, temp
42
41
  from pycontrails.utils.types import DatetimeLike
43
42
 
44
- # optional imports
45
43
  if TYPE_CHECKING:
46
- import botocore
44
+ import s3fs
47
45
 
48
46
  logger = logging.getLogger(__name__)
49
47
 
@@ -125,10 +123,10 @@ class GFSForecast(metsource.MetDataSource):
125
123
  - `GFS Documentation <https://www.emc.ncep.noaa.gov/emc/pages/numerical_forecast_systems/gfs/documentation.php>`_
126
124
  """
127
125
 
128
- __slots__ = ("cache_download", "cachestore", "client", "forecast_time", "grid", "show_progress")
126
+ __slots__ = ("cache_download", "cachestore", "forecast_time", "fs", "grid", "show_progress")
129
127
 
130
- #: S3 client for accessing GFS bucket
131
- client: botocore.client.S3
128
+ #: s3fs filesystem for anonymous access to GFS bucket
129
+ fs: s3fs.S3FileSystem | None
132
130
 
133
131
  #: Lat / Lon grid spacing. One of [0.25, 0.5, 1]
134
132
  grid: float
@@ -153,26 +151,6 @@ class GFSForecast(metsource.MetDataSource):
153
151
  show_progress: bool = False,
154
152
  cache_download: bool = False,
155
153
  ) -> None:
156
- try:
157
- import boto3
158
- except ModuleNotFoundError as e:
159
- dependencies.raise_module_not_found_error(
160
- name="GFSForecast class",
161
- package_name="boto3",
162
- module_not_found_error=e,
163
- pycontrails_optional_package="gfs",
164
- )
165
-
166
- try:
167
- import botocore
168
- except ModuleNotFoundError as e:
169
- dependencies.raise_module_not_found_error(
170
- name="GFSForecast class",
171
- package_name="botocore",
172
- module_not_found_error=e,
173
- pycontrails_optional_package="gfs",
174
- )
175
-
176
154
  # inputs
177
155
  self.paths = paths
178
156
  if cachestore is self.__marker:
@@ -196,13 +174,10 @@ class GFSForecast(metsource.MetDataSource):
196
174
  self.variables = metsource.parse_variables(variables, self.supported_variables)
197
175
  self.grid = metsource.parse_grid(grid, (0.25, 0.5, 1))
198
176
 
199
- # note GFS allows unsigned requests (no credentials)
200
- # https://stackoverflow.com/questions/34865927/can-i-use-boto3-anonymously/34866092#34866092
201
- self.client = boto3.client(
202
- "s3", config=botocore.client.Config(signature_version=botocore.UNSIGNED)
203
- )
177
+ # s3 filesystem (created on first download)
178
+ self.fs = None
204
179
 
205
- # set specific forecast time is requested
180
+ # set specific forecast time if requested, otherwise compute from timesteps
206
181
  if forecast_time is not None:
207
182
  forecast_time_pd = pd.to_datetime(forecast_time)
208
183
  if forecast_time_pd.hour % 6:
@@ -492,10 +467,35 @@ class GFSForecast(metsource.MetDataSource):
492
467
  ds.to_netcdf(cache_path)
493
468
 
494
469
  def _make_download(self, aws_key: str, target: str, filename: str) -> None:
470
+ """Download a single GRIB file using s3fs.
471
+
472
+ Parameters
473
+ ----------
474
+ aws_key : str
475
+ Key under GFS bucket forecast path.
476
+ target : str
477
+ Local filename to write.
478
+ filename : str
479
+ Original filename (used for progress label).
480
+ """
481
+ # Lazily import s3fs and create filesystem if needed
482
+ if self.fs is None:
483
+ try:
484
+ import s3fs
485
+ except ModuleNotFoundError as exc:
486
+ dependencies.raise_module_not_found_error(
487
+ name="GFSForecast class",
488
+ package_name="s3fs",
489
+ module_not_found_error=exc,
490
+ pycontrails_optional_package="gfs",
491
+ )
492
+ self.fs = s3fs.S3FileSystem(anon=True)
493
+
494
+ s3_path = f"s3://{GFS_FORECAST_BUCKET}/{aws_key}"
495
495
  if self.show_progress:
496
- _download_with_progress(self.client, GFS_FORECAST_BUCKET, aws_key, target, filename)
496
+ _download_with_progress(self.fs, s3_path, target, filename)
497
497
  else:
498
- self.client.download_file(Bucket=GFS_FORECAST_BUCKET, Key=aws_key, Filename=target)
498
+ self.fs.get(s3_path, target)
499
499
 
500
500
  def _open_gfs_dataset(self, filepath: str | pathlib.Path, t: datetime) -> xr.Dataset:
501
501
  """Open GFS grib file for one forecast timestep.
@@ -615,30 +615,20 @@ class GFSForecast(metsource.MetDataSource):
615
615
  return met.MetDataset(ds, **kwargs)
616
616
 
617
617
 
618
- def _download_with_progress(
619
- client: botocore.client.S3, bucket: str, key: str, filename: str, label: str
620
- ) -> None:
621
- """Download with `tqdm` progress bar.
618
+ def _download_with_progress(fs: s3fs.S3FileSystem, s3_path: str, target: str, label: str) -> None:
619
+ """Download with tqdm progress bar using s3fs.
622
620
 
623
621
  Parameters
624
622
  ----------
625
- client : botocore.client.S3
626
- S3 Client
627
- bucket : str
628
- AWS Bucket
629
- key : str
630
- Key within bucket to download
631
- filename : str
632
- Local filename to download to
623
+ fs : s3fs.S3FileSystem
624
+ Filesystem instance.
625
+ s3_path : str
626
+ Full s3 path (s3://bucket/key).
627
+ target : str
628
+ Local file path to write.
633
629
  label : str
634
- Progress label
635
-
636
- Raises
637
- ------
638
- ModuleNotFoundError
639
- Raises if tqdm can't be found
630
+ Progress bar label.
640
631
  """
641
-
642
632
  try:
643
633
  from tqdm import tqdm
644
634
  except ModuleNotFoundError as e:
@@ -649,14 +639,18 @@ def _download_with_progress(
649
639
  pycontrails_optional_package="gfs",
650
640
  )
651
641
 
652
- meta = client.head_object(Bucket=bucket, Key=key)
653
- filesize = meta["ContentLength"]
654
-
655
- def hook(t: Any) -> Callable:
656
- def inner(bytes_amount: Any) -> None:
657
- t.update(bytes_amount)
658
-
659
- return inner
660
-
661
- with tqdm(total=filesize, unit="B", unit_scale=True, desc=label) as t:
662
- client.download_file(Bucket=bucket, Key=key, Filename=filename, Callback=hook(t))
642
+ # get object size via simple info call
643
+ info = fs.info(s3_path)
644
+ filesize = info.get("Size") or info.get("size")
645
+
646
+ with (
647
+ fs.open(s3_path, "rb") as fsrc,
648
+ open(target, "wb") as fdst,
649
+ tqdm(total=filesize, unit="B", unit_scale=True, desc=label) as t,
650
+ ):
651
+ # stream in chunks
652
+ chunk = fsrc.read(1024 * 1024)
653
+ while chunk:
654
+ fdst.write(chunk)
655
+ t.update(len(chunk))
656
+ chunk = fsrc.read(1024 * 1024)