pycontrails 0.55.0__cp313-cp313-macosx_11_0_arm64.whl → 0.56.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/_version.py +3 -3
- pycontrails/core/airports.py +1 -1
- pycontrails/core/cache.py +3 -3
- pycontrails/core/fleet.py +1 -1
- pycontrails/core/flight.py +47 -43
- pycontrails/core/met_var.py +1 -1
- pycontrails/core/rgi_cython.cpython-313-darwin.so +0 -0
- pycontrails/core/vector.py +28 -30
- pycontrails/datalib/landsat.py +49 -26
- pycontrails/datalib/leo_utils/__init__.py +5 -0
- pycontrails/datalib/leo_utils/correction.py +266 -0
- pycontrails/datalib/leo_utils/landsat_metadata.py +300 -0
- pycontrails/datalib/{_leo_utils → leo_utils}/search.py +1 -1
- pycontrails/datalib/leo_utils/sentinel_metadata.py +748 -0
- pycontrails/datalib/sentinel.py +236 -93
- pycontrails/models/dry_advection.py +1 -1
- pycontrails/models/extended_k15.py +8 -8
- {pycontrails-0.55.0.dist-info → pycontrails-0.56.0.dist-info}/METADATA +3 -1
- {pycontrails-0.55.0.dist-info → pycontrails-0.56.0.dist-info}/RECORD +25 -21
- /pycontrails/datalib/{_leo_utils → leo_utils}/static/bq_roi_query.sql +0 -0
- /pycontrails/datalib/{_leo_utils → leo_utils}/vis.py +0 -0
- {pycontrails-0.55.0.dist-info → pycontrails-0.56.0.dist-info}/WHEEL +0 -0
- {pycontrails-0.55.0.dist-info → pycontrails-0.56.0.dist-info}/licenses/LICENSE +0 -0
- {pycontrails-0.55.0.dist-info → pycontrails-0.56.0.dist-info}/licenses/NOTICE +0 -0
- {pycontrails-0.55.0.dist-info → pycontrails-0.56.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Download and parse Landsat metadata from USGS.
|
|
2
|
+
|
|
3
|
+
This modules requires `GeoPandas <https://geopandas.org/>`_.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import numpy.typing as npt
|
|
10
|
+
import pandas as pd
|
|
11
|
+
import pyproj
|
|
12
|
+
import xarray as xr
|
|
13
|
+
|
|
14
|
+
from pycontrails.core import cache
|
|
15
|
+
from pycontrails.datalib.leo_utils import correction
|
|
16
|
+
from pycontrails.utils import dependencies
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import geopandas as gpd
|
|
20
|
+
except ModuleNotFoundError as exc:
|
|
21
|
+
dependencies.raise_module_not_found_error(
|
|
22
|
+
name="landsat_metadata module",
|
|
23
|
+
package_name="geopandas",
|
|
24
|
+
module_not_found_error=exc,
|
|
25
|
+
pycontrails_optional_package="sat",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
import shapely
|
|
30
|
+
except ModuleNotFoundError as exc:
|
|
31
|
+
dependencies.raise_module_not_found_error(
|
|
32
|
+
name="landsat_metadata module",
|
|
33
|
+
package_name="shapely",
|
|
34
|
+
module_not_found_error=exc,
|
|
35
|
+
pycontrails_optional_package="sat",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _split_antimeridian(polygon: shapely.Polygon) -> shapely.MultiPolygon:
|
|
40
|
+
"""Split a polygon into two polygons at the antimeridian.
|
|
41
|
+
|
|
42
|
+
This implementation assumes that the passed polygon is actually situated
|
|
43
|
+
on the antimeridian and does not simultaneously cross the meridian.
|
|
44
|
+
"""
|
|
45
|
+
# Shift the x-coordinates of the polygon to the right
|
|
46
|
+
# The `valid_poly` will not be valid if the polygon spans the meridian
|
|
47
|
+
valid_poly = shapely.ops.transform(lambda x, y: (x if x >= 0.0 else x + 360.0, y), polygon)
|
|
48
|
+
if not valid_poly.is_valid:
|
|
49
|
+
raise ValueError("Invalid polygon before splitting at the antimeridian.")
|
|
50
|
+
|
|
51
|
+
eastern_hemi = shapely.geometry.box(0.0, -90.0, 180.0, 90.0)
|
|
52
|
+
western_hemi = shapely.geometry.box(180.0, -90.0, 360.0, 90.0)
|
|
53
|
+
|
|
54
|
+
western_poly = valid_poly.intersection(western_hemi)
|
|
55
|
+
western_poly = shapely.ops.transform(lambda x, y: (x - 360.0, y), western_poly) # shift back
|
|
56
|
+
eastern_poly = valid_poly.intersection(eastern_hemi)
|
|
57
|
+
|
|
58
|
+
if not western_poly.is_valid or not eastern_poly.is_valid:
|
|
59
|
+
raise ValueError("Invalid polygon after splitting at the antimeridian.")
|
|
60
|
+
|
|
61
|
+
return shapely.MultiPolygon([western_poly, eastern_poly])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _download_landsat_metadata() -> pd.DataFrame:
|
|
65
|
+
"""Download and parse the Landsat metadata CSV file from USGS.
|
|
66
|
+
|
|
67
|
+
See `the USGS documentation <https://www.usgs.gov/landsat-missions/landsat-collection-2-metadata>`_
|
|
68
|
+
for more details.
|
|
69
|
+
"""
|
|
70
|
+
p = "https://landsat.usgs.gov/landsat/metadata_service/bulk_metadata_files/LANDSAT_OT_C2_L1.csv.gz"
|
|
71
|
+
|
|
72
|
+
usecols = [
|
|
73
|
+
"Display ID",
|
|
74
|
+
"Ordering ID",
|
|
75
|
+
"Collection Category",
|
|
76
|
+
"Start Time",
|
|
77
|
+
"Stop Time",
|
|
78
|
+
"Day/Night Indicator",
|
|
79
|
+
"Satellite",
|
|
80
|
+
"Corner Upper Left Latitude",
|
|
81
|
+
"Corner Upper Left Longitude",
|
|
82
|
+
"Corner Upper Right Latitude",
|
|
83
|
+
"Corner Upper Right Longitude",
|
|
84
|
+
"Corner Lower Left Latitude",
|
|
85
|
+
"Corner Lower Left Longitude",
|
|
86
|
+
"Corner Lower Right Latitude",
|
|
87
|
+
"Corner Lower Right Longitude",
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
df = pd.read_csv(p, compression="gzip", usecols=usecols)
|
|
91
|
+
|
|
92
|
+
# Convert column dtypes
|
|
93
|
+
df["Start Time"] = pd.to_datetime(df["Start Time"], format="ISO8601")
|
|
94
|
+
df["Stop Time"] = pd.to_datetime(df["Stop Time"], format="ISO8601")
|
|
95
|
+
df["Display ID"] = df["Display ID"].astype("string[pyarrow]")
|
|
96
|
+
df["Ordering ID"] = df["Ordering ID"].astype("string[pyarrow]")
|
|
97
|
+
df["Collection Category"] = df["Collection Category"].astype("string[pyarrow]")
|
|
98
|
+
df["Day/Night Indicator"] = df["Day/Night Indicator"].astype("string[pyarrow]")
|
|
99
|
+
|
|
100
|
+
return df
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _landsat_metadata_to_geodataframe(df: pd.DataFrame) -> gpd.GeoDataFrame:
|
|
104
|
+
"""Convert Landsat metadata DataFrame to GeoDataFrame with polygons."""
|
|
105
|
+
polys = shapely.polygons(
|
|
106
|
+
df[
|
|
107
|
+
[
|
|
108
|
+
"Corner Upper Left Longitude",
|
|
109
|
+
"Corner Upper Left Latitude",
|
|
110
|
+
"Corner Upper Right Longitude",
|
|
111
|
+
"Corner Upper Right Latitude",
|
|
112
|
+
"Corner Lower Right Longitude",
|
|
113
|
+
"Corner Lower Right Latitude",
|
|
114
|
+
"Corner Lower Left Longitude",
|
|
115
|
+
"Corner Lower Left Latitude",
|
|
116
|
+
"Corner Upper Left Longitude",
|
|
117
|
+
"Corner Upper Left Latitude",
|
|
118
|
+
]
|
|
119
|
+
]
|
|
120
|
+
.to_numpy()
|
|
121
|
+
.reshape(-1, 5, 2)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
out = gpd.GeoDataFrame(
|
|
125
|
+
df,
|
|
126
|
+
geometry=polys,
|
|
127
|
+
crs="EPSG:4326",
|
|
128
|
+
columns=[
|
|
129
|
+
"Display ID",
|
|
130
|
+
"Ordering ID",
|
|
131
|
+
"Collection Category",
|
|
132
|
+
"Start Time",
|
|
133
|
+
"Stop Time",
|
|
134
|
+
"Day/Night Indicator",
|
|
135
|
+
"Satellite",
|
|
136
|
+
"geometry",
|
|
137
|
+
],
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Split polygons that cross the antimeridian
|
|
141
|
+
invalid = ~out.is_valid
|
|
142
|
+
out.loc[invalid, "geometry"] = out.loc[invalid, "geometry"].apply(_split_antimeridian)
|
|
143
|
+
return out
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def open_landsat_metadata(
|
|
147
|
+
cachestore: cache.CacheStore | None = None, update_cache: bool = False
|
|
148
|
+
) -> gpd.GeoDataFrame:
|
|
149
|
+
"""Download and parse the Landsat metadata CSV file from USGS.
|
|
150
|
+
|
|
151
|
+
By default, the metadata is cached in a disk cache store.
|
|
152
|
+
|
|
153
|
+
Parameters
|
|
154
|
+
----------
|
|
155
|
+
cachestore : cache.CacheStore | None, optional
|
|
156
|
+
Cache store for Landsat metadata.
|
|
157
|
+
Defaults to :class:`cache.DiskCacheStore`.
|
|
158
|
+
update_cache : bool, optional
|
|
159
|
+
Force update to cached Landsat metadata. The remote file is updated
|
|
160
|
+
daily, so this is useful to ensure you have the latest metadata.
|
|
161
|
+
|
|
162
|
+
Returns
|
|
163
|
+
-------
|
|
164
|
+
gpd.GeoDataFrame
|
|
165
|
+
Processed Landsat metadata. The ``geometry`` column contains polygons
|
|
166
|
+
representing the footprints of the Landsat scenes.
|
|
167
|
+
"""
|
|
168
|
+
if cachestore is None:
|
|
169
|
+
cache_root = cache._get_user_cache_dir()
|
|
170
|
+
cache_dir = f"{cache_root}/landsat_metadata"
|
|
171
|
+
cachestore = cache.DiskCacheStore(cache_dir=cache_dir)
|
|
172
|
+
|
|
173
|
+
cache_key = "LANDSAT_OT_C2_L1.pq"
|
|
174
|
+
if cachestore.exists(cache_key) and not update_cache:
|
|
175
|
+
return gpd.read_parquet(cachestore.path(cache_key))
|
|
176
|
+
|
|
177
|
+
df = _download_landsat_metadata()
|
|
178
|
+
gdf = _landsat_metadata_to_geodataframe(df)
|
|
179
|
+
gdf.to_parquet(cachestore.path(cache_key), index=False)
|
|
180
|
+
return gdf
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def parse_ephemeris_landsat(ang_content: str) -> pd.DataFrame:
|
|
184
|
+
"""Find the EPHEMERIS group in a ANG text file and extract the data arrays.
|
|
185
|
+
|
|
186
|
+
Parameters
|
|
187
|
+
----------
|
|
188
|
+
ang_content : str
|
|
189
|
+
The content of the ANG file as a string.
|
|
190
|
+
|
|
191
|
+
Returns
|
|
192
|
+
-------
|
|
193
|
+
pd.DataFrame
|
|
194
|
+
A :class:`pandas.DataFrame` containing the ephemeris track with columns:
|
|
195
|
+
- EPHEMERIS_TIME: Timestamps of the ephemeris data.
|
|
196
|
+
- EPHEMERIS_ECEF_X: ECEF X coordinates.
|
|
197
|
+
- EPHEMERIS_ECEF_Y: ECEF Y coordinates.
|
|
198
|
+
- EPHEMERIS_ECEF_Z: ECEF Z coordinates.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
# Find GROUP = EPHEMERIS, capture everything non-greedily (.*?) until END_GROUP = EPHEMERIS
|
|
202
|
+
pattern = r"GROUP\s*=\s*EPHEMERIS\s*(.*?)\s*END_GROUP\s*=\s*EPHEMERIS"
|
|
203
|
+
match = re.search(pattern, ang_content, flags=re.DOTALL)
|
|
204
|
+
if match is None:
|
|
205
|
+
raise ValueError("No data found for EPHEMERIS group in the ANG content.")
|
|
206
|
+
ephemeris_content = match.group(1)
|
|
207
|
+
|
|
208
|
+
pattern = r"EPHEMERIS_EPOCH_YEAR\s*=\s*(\d+)"
|
|
209
|
+
match = re.search(pattern, ephemeris_content)
|
|
210
|
+
if match is None:
|
|
211
|
+
raise ValueError("No data found for EPHEMERIS_EPOCH_YEAR in the ANG content.")
|
|
212
|
+
year = int(match.group(1))
|
|
213
|
+
|
|
214
|
+
pattern = r"EPHEMERIS_EPOCH_DAY\s*=\s*(\d+)"
|
|
215
|
+
match = re.search(pattern, ephemeris_content)
|
|
216
|
+
if match is None:
|
|
217
|
+
raise ValueError("No data found for EPHEMERIS_EPOCH_DAY in the ANG content.")
|
|
218
|
+
day = int(match.group(1))
|
|
219
|
+
|
|
220
|
+
pattern = r"EPHEMERIS_EPOCH_SECONDS\s*=\s*(\d+\.\d+)"
|
|
221
|
+
match = re.search(pattern, ephemeris_content)
|
|
222
|
+
if match is None:
|
|
223
|
+
raise ValueError("No data found for EPHEMERIS_EPOCH_SECONDS in the ANG content.")
|
|
224
|
+
seconds = float(match.group(1))
|
|
225
|
+
|
|
226
|
+
t0 = (
|
|
227
|
+
pd.Timestamp(year=year, month=1, day=1)
|
|
228
|
+
+ pd.Timedelta(days=day - 1)
|
|
229
|
+
+ pd.Timedelta(seconds=seconds)
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Find all the EPHEMERIS_* arrays
|
|
233
|
+
array_patterns = {
|
|
234
|
+
"EPHEMERIS_TIME": r"EPHEMERIS_TIME\s*=\s*\((.*?)\)",
|
|
235
|
+
"EPHEMERIS_ECEF_X": r"EPHEMERIS_ECEF_X\s*=\s*\((.*?)\)",
|
|
236
|
+
"EPHEMERIS_ECEF_Y": r"EPHEMERIS_ECEF_Y\s*=\s*\((.*?)\)",
|
|
237
|
+
"EPHEMERIS_ECEF_Z": r"EPHEMERIS_ECEF_Z\s*=\s*\((.*?)\)",
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
arrays = {}
|
|
241
|
+
for key, pattern in array_patterns.items():
|
|
242
|
+
match = re.search(pattern, ephemeris_content, flags=re.DOTALL)
|
|
243
|
+
if match is None:
|
|
244
|
+
raise ValueError(f"No data found for {key} in the ANG content.")
|
|
245
|
+
data_str = match.group(1)
|
|
246
|
+
|
|
247
|
+
data_list = [float(x.strip()) for x in data_str.split(",")]
|
|
248
|
+
if key == "EPHEMERIS_TIME":
|
|
249
|
+
data_list = [t0 + pd.Timedelta(seconds=t) for t in data_list]
|
|
250
|
+
arrays[key] = data_list
|
|
251
|
+
|
|
252
|
+
return pd.DataFrame(arrays)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def get_time_delay_detector(
|
|
256
|
+
ds: xr.Dataset,
|
|
257
|
+
ephemeris: pd.DataFrame,
|
|
258
|
+
utm_crs: pyproj.CRS,
|
|
259
|
+
x: npt.NDArray[np.floating],
|
|
260
|
+
y: npt.NDArray[np.floating],
|
|
261
|
+
) -> npt.NDArray[np.timedelta64]:
|
|
262
|
+
"""Return the detector time delay at the given (x, y) coordinates.
|
|
263
|
+
|
|
264
|
+
Parameters
|
|
265
|
+
----------
|
|
266
|
+
ds : xr.Dataset
|
|
267
|
+
The Landsat dataset containing the VAA variable.
|
|
268
|
+
ephemeris : pd.DataFrame
|
|
269
|
+
The ephemeris DataFrame containing the EPHEMERIS_TIME and ECEF coordinates.
|
|
270
|
+
utm_crs : pyproj.CRS
|
|
271
|
+
The UTM coordinate reference system for the Landsat scene.
|
|
272
|
+
x : npt.NDArray[np.floating]
|
|
273
|
+
The x-coordinates of the pixels in the dataset's coordinate system.
|
|
274
|
+
y : npt.NDArray[np.floating]
|
|
275
|
+
The y-coordinates of the pixels in the dataset's coordinate system.
|
|
276
|
+
|
|
277
|
+
Returns
|
|
278
|
+
-------
|
|
279
|
+
npt.NDArray[np.timedelta64]
|
|
280
|
+
The time delay for each (x, y) coordinate as a timedelta64 array.
|
|
281
|
+
|
|
282
|
+
"""
|
|
283
|
+
x, y = np.atleast_1d(x, y)
|
|
284
|
+
|
|
285
|
+
ephemeris_utm = correction.ephemeris_ecef_to_utm(ephemeris, utm_crs)
|
|
286
|
+
eph_angle_radians = -np.arctan2(ephemeris_utm["y"].diff(), ephemeris_utm["x"].diff())
|
|
287
|
+
avg_eph_angle = (eph_angle_radians * 180.0 / np.pi).mean()
|
|
288
|
+
|
|
289
|
+
vaa = ds["VAA"].interp(x=xr.DataArray(x, dims="points"), y=xr.DataArray(y, dims="points"))
|
|
290
|
+
|
|
291
|
+
is_odd = np.isfinite(vaa) & ((vaa > avg_eph_angle) | (vaa < avg_eph_angle - 180.0))
|
|
292
|
+
is_even = np.isfinite(vaa) & ~is_odd
|
|
293
|
+
|
|
294
|
+
out = np.full(x.shape, fill_value=np.timedelta64("NaT", "ns"), dtype="timedelta64[ns]")
|
|
295
|
+
# We use an offset of +/- 2 seconds as a very rough estimate of the time delay
|
|
296
|
+
# This may only be accurate up to 1 second, but it's better than nothing
|
|
297
|
+
out[is_even] = np.timedelta64(-2000000000, "ns") # -2 seconds
|
|
298
|
+
out[is_odd] = np.timedelta64(2000000000, "ns") # 2 seconds
|
|
299
|
+
|
|
300
|
+
return out
|
|
@@ -13,7 +13,7 @@ try:
|
|
|
13
13
|
import geojson
|
|
14
14
|
except ModuleNotFoundError as exc:
|
|
15
15
|
dependencies.raise_module_not_found_error(
|
|
16
|
-
name="datalib.
|
|
16
|
+
name="datalib.leo_utils module",
|
|
17
17
|
package_name="geojson",
|
|
18
18
|
module_not_found_error=exc,
|
|
19
19
|
pycontrails_optional_package="sat",
|