pycontrails 0.54.12__cp313-cp313-win_amd64.whl → 0.56.0__cp313-cp313-win_amd64.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.cp313-win_amd64.pyd +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/cocip/cocip.py +30 -13
- pycontrails/models/cocip/cocip_params.py +9 -3
- pycontrails/models/cocip/cocip_uncertainty.py +4 -4
- pycontrails/models/cocip/contrail_properties.py +27 -27
- pycontrails/models/cocip/radiative_forcing.py +4 -4
- pycontrails/models/cocip/unterstrasser_wake_vortex.py +5 -5
- pycontrails/models/cocip/wake_vortex.py +6 -6
- pycontrails/models/cocipgrid/cocip_grid.py +60 -32
- pycontrails/models/dry_advection.py +3 -3
- pycontrails/models/extended_k15.py +1327 -0
- pycontrails/physics/constants.py +1 -1
- {pycontrails-0.54.12.dist-info → pycontrails-0.56.0.dist-info}/METADATA +3 -1
- {pycontrails-0.54.12.dist-info → pycontrails-0.56.0.dist-info}/RECORD +34 -29
- /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.54.12.dist-info → pycontrails-0.56.0.dist-info}/WHEEL +0 -0
- {pycontrails-0.54.12.dist-info → pycontrails-0.56.0.dist-info}/licenses/LICENSE +0 -0
- {pycontrails-0.54.12.dist-info → pycontrails-0.56.0.dist-info}/licenses/NOTICE +0 -0
- {pycontrails-0.54.12.dist-info → pycontrails-0.56.0.dist-info}/top_level.txt +0 -0
pycontrails/datalib/sentinel.py
CHANGED
|
@@ -7,12 +7,23 @@ from collections.abc import Iterable
|
|
|
7
7
|
from xml.etree import ElementTree
|
|
8
8
|
|
|
9
9
|
import numpy as np
|
|
10
|
+
import numpy.typing as npt
|
|
10
11
|
import pandas as pd
|
|
11
12
|
import xarray as xr
|
|
12
13
|
|
|
13
14
|
from pycontrails.core import Flight, cache
|
|
14
|
-
from pycontrails.datalib.
|
|
15
|
-
from pycontrails.datalib.
|
|
15
|
+
from pycontrails.datalib.leo_utils import correction, search
|
|
16
|
+
from pycontrails.datalib.leo_utils.sentinel_metadata import (
|
|
17
|
+
_band_id,
|
|
18
|
+
get_detector_id,
|
|
19
|
+
get_time_delay_detectors,
|
|
20
|
+
parse_ephemeris_sentinel,
|
|
21
|
+
parse_high_res_viewing_incidence_angles,
|
|
22
|
+
parse_sensing_time,
|
|
23
|
+
parse_sentinel_crs,
|
|
24
|
+
read_image_coordinates,
|
|
25
|
+
)
|
|
26
|
+
from pycontrails.datalib.leo_utils.vis import equalize, normalize
|
|
16
27
|
from pycontrails.utils import dependencies
|
|
17
28
|
|
|
18
29
|
try:
|
|
@@ -53,7 +64,7 @@ ROI_QUERY_FILENAME = _path_to_static / "sentinel_roi_query.sql"
|
|
|
53
64
|
BQ_TABLE = "bigquery-public-data.cloud_storage_geo_index.sentinel_2_index"
|
|
54
65
|
|
|
55
66
|
#: Default columns to include in queries
|
|
56
|
-
BQ_DEFAULT_COLUMNS = ["base_url", "granule_id", "sensing_time"]
|
|
67
|
+
BQ_DEFAULT_COLUMNS = ["base_url", "granule_id", "sensing_time", "source_url"]
|
|
57
68
|
|
|
58
69
|
#: Default spatial extent for queries
|
|
59
70
|
BQ_DEFAULT_EXTENT = search.GLOBAL_EXTENT
|
|
@@ -84,10 +95,10 @@ def query(
|
|
|
84
95
|
Start of time period for search
|
|
85
96
|
end_time : np.datetime64
|
|
86
97
|
End of time period for search
|
|
87
|
-
extent : str, optional
|
|
98
|
+
extent : str | None, optional
|
|
88
99
|
Spatial region of interest as a GeoJSON string. If not provided, defaults
|
|
89
100
|
to a global extent.
|
|
90
|
-
columns : list[str], optional
|
|
101
|
+
columns : list[str] | None, optional
|
|
91
102
|
Columns to return from Google
|
|
92
103
|
`BigQuery table <https://console.cloud.google.com/bigquery?p=bigquery-public-data&d=cloud_storage_geo_index&t=landsat_index&page=table&_ga=2.90807450.1051800793.1716904050-255800408.1705955196>`__.
|
|
93
104
|
By default, returns imagery base URL, granule ID, and sensing time.
|
|
@@ -124,7 +135,7 @@ def intersect(
|
|
|
124
135
|
----------
|
|
125
136
|
flight : Flight
|
|
126
137
|
Flight for intersection
|
|
127
|
-
columns : list[str], optional
|
|
138
|
+
columns : list[str] | None, optional
|
|
128
139
|
Columns to return from Google
|
|
129
140
|
`BigQuery table <https://console.cloud.google.com/bigquery?p=bigquery-public-data&d=cloud_storage_geo_index&t=landsat_index&page=table&_ga=2.90807450.1051800793.1716904050-255800408.1705955196>`__.
|
|
130
141
|
By default, returns imagery base URL, granule ID, and sensing time.
|
|
@@ -139,18 +150,18 @@ def intersect(
|
|
|
139
150
|
:func:`search.intersect`
|
|
140
151
|
"""
|
|
141
152
|
columns = columns or BQ_DEFAULT_COLUMNS
|
|
142
|
-
|
|
153
|
+
scenes = search.intersect(BQ_TABLE, flight, columns)
|
|
154
|
+
|
|
155
|
+
# overwrite the base_url with source_url.
|
|
156
|
+
# After 2024-03-14 there is a mistake in the Google BigQuery table,
|
|
157
|
+
# such that the base_url is written to the source_url column
|
|
158
|
+
scenes["base_url"] = scenes["base_url"].fillna(scenes["source_url"])
|
|
159
|
+
return scenes
|
|
143
160
|
|
|
144
161
|
|
|
145
162
|
class Sentinel:
|
|
146
163
|
"""Support for Sentinel-2 data handling.
|
|
147
164
|
|
|
148
|
-
This class uses the `PROJ <https://proj.org/en/9.4/index.html>`__ coordinate
|
|
149
|
-
transformation software through the
|
|
150
|
-
`pyproj <https://pyproj4.github.io/pyproj/stable/index.html>`__ python interface.
|
|
151
|
-
pyproj is installed as part of the ``sat`` set of optional dependencies
|
|
152
|
-
(``pip install pycontrails[sat]``), but PROJ must be installed manually.
|
|
153
|
-
|
|
154
165
|
Parameters
|
|
155
166
|
----------
|
|
156
167
|
base_url : str
|
|
@@ -159,7 +170,7 @@ class Sentinel:
|
|
|
159
170
|
granule_id : str
|
|
160
171
|
Granule ID of Sentinel-2 scene. To find URLs for Sentinel-2 scenes at
|
|
161
172
|
specific locations and times, see :func:`query` and :func:`intersect`.
|
|
162
|
-
bands : str |
|
|
173
|
+
bands : str | Iterable[str] | None
|
|
163
174
|
Set of bands to retrieve. The 13 possible bands are represented by
|
|
164
175
|
the string "B01" to "B12" plus "B8A". For the true color scheme, set
|
|
165
176
|
``bands=("B02", "B03", "B04")``. By default, bands for the true color scheme
|
|
@@ -169,7 +180,7 @@ class Sentinel:
|
|
|
169
180
|
- B05-B07, B8A, B11, B12: 20 m
|
|
170
181
|
- B01, B09, B10: 60 m
|
|
171
182
|
|
|
172
|
-
cachestore : cache.CacheStore, optional
|
|
183
|
+
cachestore : cache.CacheStore | None, optional
|
|
173
184
|
Cache store for Landsat data. If None, a :class:`DiskCacheStore` is used.
|
|
174
185
|
|
|
175
186
|
See Also
|
|
@@ -210,23 +221,163 @@ class Sentinel:
|
|
|
210
221
|
|
|
211
222
|
Parameters
|
|
212
223
|
----------
|
|
213
|
-
reflective : str
|
|
214
|
-
|
|
224
|
+
reflective : str, optional
|
|
225
|
+
Set to "raw" to return raw values or "reflectance" for rescaled reflectances.
|
|
215
226
|
By default, return reflectances.
|
|
216
227
|
|
|
217
228
|
Returns
|
|
218
229
|
-------
|
|
219
|
-
xr.
|
|
220
|
-
|
|
230
|
+
xr.Dataset
|
|
231
|
+
Dataset of Sentinel-2 data.
|
|
221
232
|
"""
|
|
222
|
-
|
|
223
|
-
|
|
233
|
+
available = ("raw", "reflectance")
|
|
234
|
+
if reflective not in available:
|
|
235
|
+
msg = f"reflective band processing must be one of {available}"
|
|
224
236
|
raise ValueError(msg)
|
|
225
237
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
238
|
+
data = {band: self._get(band, reflective) for band in self.bands}
|
|
239
|
+
return xr.Dataset(data)
|
|
240
|
+
|
|
241
|
+
def get_viewing_angle_metadata(self, scale: int = 10) -> xr.Dataset:
|
|
242
|
+
"""Return the dataset with viewing angles.
|
|
243
|
+
|
|
244
|
+
See :func:`parse_high_res_viewing_incidence_angles` for details.
|
|
245
|
+
"""
|
|
246
|
+
granule_meta_path, _ = self._get_meta()
|
|
247
|
+
_, detector_band_path = self._get_correction_meta()
|
|
248
|
+
return parse_high_res_viewing_incidence_angles(
|
|
249
|
+
granule_meta_path, detector_band_path, scale=scale
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def get_detector_id(
|
|
253
|
+
self, x: npt.NDArray[np.floating], y: npt.NDArray[np.floating]
|
|
254
|
+
) -> npt.NDArray[np.integer]:
|
|
255
|
+
"""Return the detector_id of the Sentinel-2 detector that imaged the given points.
|
|
256
|
+
|
|
257
|
+
Parameters
|
|
258
|
+
----------
|
|
259
|
+
x : npt.NDArray[np.floating]
|
|
260
|
+
x coordinates of points in the Sentinel-2 image CRS
|
|
261
|
+
y : npt.NDArray[np.floating]
|
|
262
|
+
y coordinates of points in the Sentinel-2 image CRS
|
|
263
|
+
|
|
264
|
+
Returns
|
|
265
|
+
-------
|
|
266
|
+
npt.NDArray[np.integer]
|
|
267
|
+
Detector IDs for each point. If a point is outside the image, the detector ID is 0.
|
|
268
|
+
"""
|
|
269
|
+
granule_sink, _ = self._get_meta()
|
|
270
|
+
_, detector_band_sink = self._get_correction_meta()
|
|
271
|
+
return get_detector_id(detector_band_sink, granule_sink, x, y)
|
|
272
|
+
|
|
273
|
+
def get_time_delay_detector(
|
|
274
|
+
self, detector_id: npt.NDArray[np.integer], band: str = "B03"
|
|
275
|
+
) -> npt.NDArray[np.timedelta64]:
|
|
276
|
+
"""Return the time delay for the given detector IDs."""
|
|
277
|
+
datastrip_sink, _ = self._get_correction_meta()
|
|
278
|
+
delays = get_time_delay_detectors(datastrip_sink, band)
|
|
279
|
+
|
|
280
|
+
# Map the delays to the input array
|
|
281
|
+
unique_ids, inverse = np.unique(detector_id, return_inverse=True)
|
|
282
|
+
|
|
283
|
+
# Get offsets for each unique detector
|
|
284
|
+
offset_list = []
|
|
285
|
+
for did in unique_ids:
|
|
286
|
+
if did == 0:
|
|
287
|
+
offset_list.append(np.timedelta64("NaT"))
|
|
288
|
+
else:
|
|
289
|
+
offset_list.append(delays.get(did, np.timedelta64("NaT")))
|
|
290
|
+
offset_values = np.array(offset_list, dtype="timedelta64[ns]")
|
|
291
|
+
|
|
292
|
+
# Map back to the original array
|
|
293
|
+
return offset_values[inverse]
|
|
294
|
+
|
|
295
|
+
def get_ephemeris(self) -> pd.DataFrame:
|
|
296
|
+
"""Return the satellite ephemeris as a :class:`pandas.DataFrame`."""
|
|
297
|
+
datastrip_sink, _ = self._get_correction_meta()
|
|
298
|
+
|
|
299
|
+
return parse_ephemeris_sentinel(datastrip_sink)
|
|
300
|
+
|
|
301
|
+
def get_crs(self) -> pyproj.CRS:
|
|
302
|
+
"""Return the CRS of the satellite image."""
|
|
303
|
+
granule_meta_path, _ = self._get_meta()
|
|
304
|
+
return parse_sentinel_crs(granule_meta_path)
|
|
305
|
+
|
|
306
|
+
def get_sensing_time(self) -> pd.Timestamp:
|
|
307
|
+
"""Return the sensing_time of the satellite image."""
|
|
308
|
+
granule_meta_path, _ = self._get_meta()
|
|
309
|
+
return parse_sensing_time(granule_meta_path)
|
|
310
|
+
|
|
311
|
+
def colocate_flight(
|
|
312
|
+
self,
|
|
313
|
+
flight: Flight,
|
|
314
|
+
band: str = "B03",
|
|
315
|
+
) -> tuple[float, float, np.datetime64]:
|
|
316
|
+
"""Colocate a flight track with satellite image pixels.
|
|
317
|
+
|
|
318
|
+
This function first projects flight waypoints into the UTM coordinate system
|
|
319
|
+
of the satellite image, then applies a viewing angle correction to estimate
|
|
320
|
+
the actual ground position imaged by the satellite. Next, the scan time for
|
|
321
|
+
each point is estimated based on the satellite meta data. Finally, the
|
|
322
|
+
point along the flight track is found where the flight time matches the
|
|
323
|
+
satellite scan time.
|
|
324
|
+
|
|
325
|
+
Parameters
|
|
326
|
+
----------
|
|
327
|
+
flight : Flight
|
|
328
|
+
The flight object.
|
|
329
|
+
band : str, optional
|
|
330
|
+
Spectral band to use for geometry parsing. Default is "B03".
|
|
331
|
+
|
|
332
|
+
Returns
|
|
333
|
+
-------
|
|
334
|
+
tuple[float, float, np.datetime64]
|
|
335
|
+
A tuple containing the x and y coordinates of the flight position in the
|
|
336
|
+
satellite image CRS, and the corrected sensing_time of the satellite image.
|
|
337
|
+
"""
|
|
338
|
+
utm_crs = self.get_crs()
|
|
339
|
+
|
|
340
|
+
# Project from WGS84 to the x and y coordinates in the UTM coordinate system
|
|
341
|
+
transformer = pyproj.Transformer.from_crs("EPSG:4326", utm_crs, always_xy=True)
|
|
342
|
+
x, y = transformer.transform(flight["longitude"], flight["latitude"])
|
|
343
|
+
z = flight.altitude
|
|
344
|
+
|
|
345
|
+
# Apply sensing angle correction
|
|
346
|
+
ds_angles = self.get_viewing_angle_metadata()
|
|
347
|
+
x_proj, y_proj = correction.scan_angle_correction(ds_angles, x, y, z, maxiter=3)
|
|
348
|
+
|
|
349
|
+
# Get the satellite ephemeris data prepared
|
|
350
|
+
ephemeris = self.get_ephemeris()
|
|
351
|
+
|
|
352
|
+
# Estimate the scan time for each point
|
|
353
|
+
scan_time_no_offset = correction.estimate_scan_time(ephemeris, utm_crs, x_proj, y_proj)
|
|
354
|
+
|
|
355
|
+
detector_id = self.get_detector_id(x_proj, y_proj)
|
|
356
|
+
offset = self.get_time_delay_detector(detector_id, band=band)
|
|
357
|
+
scan_time = scan_time_no_offset + offset
|
|
358
|
+
|
|
359
|
+
# To finish up, find the point along the project flight track at which the satellite
|
|
360
|
+
# scan time equals the flight time
|
|
361
|
+
# It's possible that if the flight has a really fine time resolution, that
|
|
362
|
+
# the diff_times array will have multiple zeros. This is okay, as np.interp
|
|
363
|
+
# will just return the first one.
|
|
364
|
+
valid = np.isfinite(x_proj) & np.isfinite(y_proj) & np.isfinite(scan_time)
|
|
365
|
+
|
|
366
|
+
diff_times = flight["time"][valid] - scan_time[valid]
|
|
367
|
+
if diff_times[0] > np.timedelta64(0) or diff_times[-1] < np.timedelta64(0):
|
|
368
|
+
msg = "Flight track does not overlap with satellite image in time."
|
|
369
|
+
raise ValueError(msg)
|
|
370
|
+
|
|
371
|
+
index = np.arange(len(diff_times))
|
|
372
|
+
frac_idx = np.interp(0, diff_times.astype(int), index)
|
|
373
|
+
|
|
374
|
+
x1 = np.interp(frac_idx, index, x_proj[valid]).item()
|
|
375
|
+
y1 = np.interp(frac_idx, index, y_proj[valid]).item()
|
|
376
|
+
t1 = np.interp(frac_idx, index, scan_time[valid].astype(int)).item()
|
|
377
|
+
|
|
378
|
+
return x1, y1, np.datetime64(int(t1), "ns")
|
|
379
|
+
|
|
380
|
+
# -------------------------------------------------------------------------------------
|
|
230
381
|
|
|
231
382
|
def _get(self, band: str, processing: str) -> xr.DataArray:
|
|
232
383
|
"""Download Sentinel-2 band to the :attr:`cachestore` and return processed data."""
|
|
@@ -278,6 +429,61 @@ class Sentinel:
|
|
|
278
429
|
|
|
279
430
|
return granule_sink, safe_sink
|
|
280
431
|
|
|
432
|
+
def _get_correction_meta(self) -> tuple[str, str]:
|
|
433
|
+
"""Download Sentinel-2 metadata files and return path to cached files.
|
|
434
|
+
|
|
435
|
+
Note that two XML files must be retrieved: one inside the GRANULE
|
|
436
|
+
subdirectory, and one at the top level of the SAFE archive.
|
|
437
|
+
"""
|
|
438
|
+
fs = self.fs
|
|
439
|
+
base_url = self.base_url
|
|
440
|
+
granule_id = self.granule_id
|
|
441
|
+
|
|
442
|
+
# Resolve the unknown subfolder in DATASTRIP using glob
|
|
443
|
+
# Probably there is a better method, but this worked for now
|
|
444
|
+
pattern = f"{base_url}/DATASTRIP/*"
|
|
445
|
+
matches = fs.glob(pattern)
|
|
446
|
+
if not matches:
|
|
447
|
+
raise FileNotFoundError(f"No DATASTRIP MTD_MDS.xml file found at pattern: {pattern}")
|
|
448
|
+
if len(matches) > 2:
|
|
449
|
+
raise RuntimeError(f"Multiple DATASTRIP MTD_MDS.xml files found: {matches}")
|
|
450
|
+
|
|
451
|
+
datastrip_id = matches[0].split("/")[-1].replace("_$folder$", "")
|
|
452
|
+
url = f"{base_url}/DATASTRIP/{datastrip_id}/MTD_DS.xml"
|
|
453
|
+
datastrip_sink = self.cachestore.path(url.removeprefix(GCP_STRIP_PREFIX))
|
|
454
|
+
if not self.cachestore.exists(datastrip_sink):
|
|
455
|
+
fs.get(url, datastrip_sink)
|
|
456
|
+
|
|
457
|
+
# Path to the QI_DATA folder
|
|
458
|
+
qi_data_url = f"{base_url}/GRANULE/{granule_id}/QI_DATA/"
|
|
459
|
+
|
|
460
|
+
# List files in QI_DATA (assuming fs can list directories)
|
|
461
|
+
files = fs.ls(qi_data_url)
|
|
462
|
+
|
|
463
|
+
# Filter for the detector mask files
|
|
464
|
+
mask_files = [f for f in files if "MSK_DETFOO_B03" in f]
|
|
465
|
+
|
|
466
|
+
if not mask_files:
|
|
467
|
+
raise FileNotFoundError(f"No detector mask found in {qi_data_url}")
|
|
468
|
+
|
|
469
|
+
# Choose the first available file (could prioritize .jp2 over .gml if needed)
|
|
470
|
+
mask_file = None
|
|
471
|
+
for ext in [".jp2", ".gml"]:
|
|
472
|
+
for f in mask_files:
|
|
473
|
+
if f.endswith(ext):
|
|
474
|
+
mask_file = f
|
|
475
|
+
break
|
|
476
|
+
if mask_file:
|
|
477
|
+
break
|
|
478
|
+
|
|
479
|
+
if mask_file is not None:
|
|
480
|
+
# Path where the file will be cached
|
|
481
|
+
detector_band_sink = self.cachestore.path(mask_file.removeprefix(GCP_STRIP_PREFIX))
|
|
482
|
+
if not self.cachestore.exists(detector_band_sink):
|
|
483
|
+
fs.get(mask_file, detector_band_sink)
|
|
484
|
+
|
|
485
|
+
return datastrip_sink, detector_band_sink
|
|
486
|
+
|
|
281
487
|
|
|
282
488
|
def _parse_bands(bands: str | Iterable[str] | None) -> set[str]:
|
|
283
489
|
"""Check that the bands are valid and return as a set."""
|
|
@@ -328,7 +534,7 @@ def _read(path: str, granule_meta: str, safe_meta: str, band: str, processing: s
|
|
|
328
534
|
epsg = int(elem.text.split(":")[1])
|
|
329
535
|
crs = pyproj.CRS.from_epsg(epsg)
|
|
330
536
|
|
|
331
|
-
x, y =
|
|
537
|
+
x, y = read_image_coordinates(granule_meta, band)
|
|
332
538
|
|
|
333
539
|
da = xr.DataArray(
|
|
334
540
|
data=img,
|
|
@@ -345,22 +551,6 @@ def _read(path: str, granule_meta: str, safe_meta: str, band: str, processing: s
|
|
|
345
551
|
return da
|
|
346
552
|
|
|
347
553
|
|
|
348
|
-
def _band_resolution(band: str) -> int:
|
|
349
|
-
"""Get band resolution in meters."""
|
|
350
|
-
return (
|
|
351
|
-
60 if band in ("B01", "B09", "B10") else 10 if band in ("B02", "B03", "B04", "B08") else 20
|
|
352
|
-
)
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
def _band_id(band: str) -> int:
|
|
356
|
-
"""Get band ID used in some metadata files."""
|
|
357
|
-
if band in (f"B{i:2d}" for i in range(1, 9)):
|
|
358
|
-
return int(band[1:]) - 1
|
|
359
|
-
if band == "B8A":
|
|
360
|
-
return 8
|
|
361
|
-
return int(band[1:])
|
|
362
|
-
|
|
363
|
-
|
|
364
554
|
def _read_band_reflectance_rescaling(meta: str, band: str) -> tuple[float, float]:
|
|
365
555
|
"""Read reflectance rescaling factors from metadata file.
|
|
366
556
|
|
|
@@ -393,65 +583,18 @@ def _read_band_reflectance_rescaling(meta: str, band: str) -> tuple[float, float
|
|
|
393
583
|
raise ValueError(msg)
|
|
394
584
|
|
|
395
585
|
|
|
396
|
-
def _read_image_coordinates(meta: str, band: str) -> tuple[np.ndarray, np.ndarray]:
|
|
397
|
-
"""Read image x and y coordinates."""
|
|
398
|
-
|
|
399
|
-
# convenience function that satisfies mypy
|
|
400
|
-
def _text_from_tag(parent: ElementTree.Element, tag: str) -> str:
|
|
401
|
-
elem = parent.find(tag)
|
|
402
|
-
if elem is None or elem.text is None:
|
|
403
|
-
msg = f"Could not find text in {tag} element"
|
|
404
|
-
raise ValueError(msg)
|
|
405
|
-
return elem.text
|
|
406
|
-
|
|
407
|
-
resolution = _band_resolution(band)
|
|
408
|
-
|
|
409
|
-
# find coordinates of upper left corner and pixel size
|
|
410
|
-
tree = ElementTree.parse(meta)
|
|
411
|
-
elems = tree.findall(".//Geoposition")
|
|
412
|
-
for elem in elems:
|
|
413
|
-
if int(elem.attrib["resolution"]) == resolution:
|
|
414
|
-
ulx = float(_text_from_tag(elem, "ULX"))
|
|
415
|
-
uly = float(_text_from_tag(elem, "ULY"))
|
|
416
|
-
dx = float(_text_from_tag(elem, "XDIM"))
|
|
417
|
-
dy = float(_text_from_tag(elem, "YDIM"))
|
|
418
|
-
break
|
|
419
|
-
else:
|
|
420
|
-
msg = f"Could not find image geoposition for resolution of {resolution} m"
|
|
421
|
-
raise ValueError(msg)
|
|
422
|
-
|
|
423
|
-
# find image size
|
|
424
|
-
elems = tree.findall(".//Size")
|
|
425
|
-
for elem in elems:
|
|
426
|
-
if int(elem.attrib["resolution"]) == resolution:
|
|
427
|
-
nx = int(_text_from_tag(elem, "NCOLS"))
|
|
428
|
-
ny = int(_text_from_tag(elem, "NROWS"))
|
|
429
|
-
break
|
|
430
|
-
else:
|
|
431
|
-
msg = f"Could not find image size for resolution of {resolution} m"
|
|
432
|
-
raise ValueError(msg)
|
|
433
|
-
|
|
434
|
-
# compute pixel coordinates
|
|
435
|
-
xlim = (ulx, ulx + (nx - 1) * dx)
|
|
436
|
-
ylim = (uly, uly + (ny - 1) * dy) # dy is < 0
|
|
437
|
-
x = np.linspace(xlim[0], xlim[1], nx)
|
|
438
|
-
y = np.linspace(ylim[0], ylim[1], ny)
|
|
439
|
-
|
|
440
|
-
return x, y
|
|
441
|
-
|
|
442
|
-
|
|
443
586
|
def extract_sentinel_visualization(
|
|
444
587
|
ds: xr.Dataset, color_scheme: str = "true"
|
|
445
|
-
) -> tuple[np.
|
|
588
|
+
) -> tuple[npt.NDArray[np.float32], pyproj.CRS, tuple[float, float, float, float]]:
|
|
446
589
|
"""Extract artifacts for visualizing Sentinel data with the given color scheme.
|
|
447
590
|
|
|
448
591
|
Parameters
|
|
449
592
|
----------
|
|
450
593
|
ds : xr.Dataset
|
|
451
594
|
Dataset of Sentinel data as returned by :meth:`Sentinel.get`.
|
|
452
|
-
color_scheme : str
|
|
595
|
+
color_scheme : str, optional
|
|
453
596
|
Color scheme to use for visualization. The true color scheme
|
|
454
|
-
(the only option currently implemented) requires bands B02, B03, and B04.
|
|
597
|
+
(`"true"`, the only option currently implemented) requires bands B02, B03, and B04.
|
|
455
598
|
|
|
456
599
|
Returns
|
|
457
600
|
-------
|
|
@@ -459,7 +602,7 @@ def extract_sentinel_visualization(
|
|
|
459
602
|
3D RGB array of shape ``(height, width, 3)``.
|
|
460
603
|
src_crs : pyproj.CRS
|
|
461
604
|
Imagery projection
|
|
462
|
-
src_extent : tuple[float,float,float,float]
|
|
605
|
+
src_extent : tuple[float, float, float, float]
|
|
463
606
|
Imagery extent in projected coordinates
|
|
464
607
|
"""
|
|
465
608
|
|
|
@@ -27,7 +27,7 @@ from pycontrails.core.met_var import MetVariable
|
|
|
27
27
|
from pycontrails.core.models import Model, interpolate_met
|
|
28
28
|
from pycontrails.core.vector import GeoVectorDataset, VectorDataDict
|
|
29
29
|
from pycontrails.datalib import ecmwf, gfs
|
|
30
|
-
from pycontrails.models import sac, tau_cirrus
|
|
30
|
+
from pycontrails.models import extended_k15, sac, tau_cirrus
|
|
31
31
|
from pycontrails.models.cocip import (
|
|
32
32
|
contrail_properties,
|
|
33
33
|
radiative_forcing,
|
|
@@ -58,7 +58,7 @@ class Cocip(Model):
|
|
|
58
58
|
rad : MetDataset
|
|
59
59
|
Single level dataset containing top of atmosphere radiation fluxes.
|
|
60
60
|
See *Notes* for variable names by data source.
|
|
61
|
-
params : dict[str, Any], optional
|
|
61
|
+
params : dict[str, Any] | None, optional
|
|
62
62
|
Override Cocip model parameters with dictionary.
|
|
63
63
|
See :class:`CocipFlightParams` for model parameters.
|
|
64
64
|
**params_kwargs : Any
|
|
@@ -411,7 +411,7 @@ class Cocip(Model):
|
|
|
411
411
|
|
|
412
412
|
Returns
|
|
413
413
|
-------
|
|
414
|
-
Flight | list[Flight]
|
|
414
|
+
Flight | list[Flight]
|
|
415
415
|
Flight(s) with updated Contrail data. The model parameter "verbose_outputs"
|
|
416
416
|
determines the variables on the return flight object.
|
|
417
417
|
|
|
@@ -489,7 +489,7 @@ class Cocip(Model):
|
|
|
489
489
|
|
|
490
490
|
Returns
|
|
491
491
|
-------
|
|
492
|
-
tuple[MetVariable]
|
|
492
|
+
tuple[MetVariable, ...]
|
|
493
493
|
List of model-agnostic variants of required variables
|
|
494
494
|
"""
|
|
495
495
|
available = set(met_var.MET_VARIABLES)
|
|
@@ -501,7 +501,7 @@ class Cocip(Model):
|
|
|
501
501
|
|
|
502
502
|
Returns
|
|
503
503
|
-------
|
|
504
|
-
tuple[MetVariable]
|
|
504
|
+
tuple[MetVariable, ...]
|
|
505
505
|
List of ECMWF-specific variants of required variables
|
|
506
506
|
"""
|
|
507
507
|
available = set(ecmwf.ECMWF_VARIABLES)
|
|
@@ -513,7 +513,7 @@ class Cocip(Model):
|
|
|
513
513
|
|
|
514
514
|
Returns
|
|
515
515
|
-------
|
|
516
|
-
tuple[MetVariable]
|
|
516
|
+
tuple[MetVariable, ...]
|
|
517
517
|
List of GFS-specific variants of required variables
|
|
518
518
|
"""
|
|
519
519
|
available = set(gfs.GFS_VARIABLES)
|
|
@@ -981,6 +981,30 @@ class Cocip(Model):
|
|
|
981
981
|
)
|
|
982
982
|
iwc_1 = contrail_properties.iwc_post_wake_vortex(iwc, iwc_ad)
|
|
983
983
|
|
|
984
|
+
if self.params["vpm_activation"]:
|
|
985
|
+
# We can add a Cocip parameter for T_exhaust, vpm_ei_n, and particles
|
|
986
|
+
aei = extended_k15.droplet_apparent_emission_index(
|
|
987
|
+
specific_humidity=specific_humidity,
|
|
988
|
+
T_ambient=air_temperature,
|
|
989
|
+
T_exhaust=self.source.attrs.get("T_exhaust", extended_k15.DEFAULT_EXHAUST_T),
|
|
990
|
+
air_pressure=air_pressure,
|
|
991
|
+
nvpm_ei_n=nvpm_ei_n,
|
|
992
|
+
vpm_ei_n=self.source.attrs.get("vpm_ei_n", extended_k15.DEFAULT_VPM_EI_N),
|
|
993
|
+
G=self._sac_flight["G"],
|
|
994
|
+
)
|
|
995
|
+
min_aei = None # don't clip
|
|
996
|
+
|
|
997
|
+
else:
|
|
998
|
+
f_activation = contrail_properties.ice_particle_activation_rate(
|
|
999
|
+
air_temperature, T_critical_sac
|
|
1000
|
+
)
|
|
1001
|
+
aei = nvpm_ei_n * f_activation
|
|
1002
|
+
min_aei = self.params["min_ice_particle_number_nvpm_ei_n"]
|
|
1003
|
+
|
|
1004
|
+
n_ice_per_m_0 = contrail_properties.initial_ice_particle_number(
|
|
1005
|
+
aei=aei, fuel_dist=fuel_dist, min_aei=min_aei
|
|
1006
|
+
)
|
|
1007
|
+
|
|
984
1008
|
if self.params["unterstrasser_ice_survival_fraction"]:
|
|
985
1009
|
wingspan = self._sac_flight.get_data_or_attr("wingspan")
|
|
986
1010
|
rhi_0 = thermo.rhi(specific_humidity, air_temperature, air_pressure)
|
|
@@ -997,13 +1021,6 @@ class Cocip(Model):
|
|
|
997
1021
|
else:
|
|
998
1022
|
f_surv = contrail_properties.ice_particle_survival_fraction(iwc, iwc_1)
|
|
999
1023
|
|
|
1000
|
-
n_ice_per_m_0 = contrail_properties.initial_ice_particle_number(
|
|
1001
|
-
nvpm_ei_n=nvpm_ei_n,
|
|
1002
|
-
fuel_dist=fuel_dist,
|
|
1003
|
-
air_temperature=air_temperature,
|
|
1004
|
-
T_crit_sac=T_critical_sac,
|
|
1005
|
-
min_ice_particle_number_nvpm_ei_n=self.params["min_ice_particle_number_nvpm_ei_n"],
|
|
1006
|
-
)
|
|
1007
1024
|
n_ice_per_m_1 = n_ice_per_m_0 * f_surv
|
|
1008
1025
|
|
|
1009
1026
|
# Check for persistent initial_contrails
|
|
@@ -195,8 +195,8 @@ class CocipParams(AdvectionBuffers):
|
|
|
195
195
|
nvpm_ei_n_enhancement_factor: float = 1.0
|
|
196
196
|
|
|
197
197
|
#: Lower bound for ``nvpm_ei_n`` to account for ambient aerosol
|
|
198
|
-
#: particles for newer engines, [:math:`kg^{-1}`]
|
|
199
|
-
min_ice_particle_number_nvpm_ei_n: float = 1e13
|
|
198
|
+
#: particles for newer engines, [:math:`kg^{-1}`]. Set to None to disable.
|
|
199
|
+
min_ice_particle_number_nvpm_ei_n: float | None = 1e13
|
|
200
200
|
|
|
201
201
|
#: Upper bound for contrail plume depth, constraining it to realistic values.
|
|
202
202
|
#: CoCiP only uses the ambient conditions at the mid-point of the Gaussian plume,
|
|
@@ -223,11 +223,17 @@ class CocipParams(AdvectionBuffers):
|
|
|
223
223
|
#: .. versionadded:: 0.28.9
|
|
224
224
|
radiative_heating_effects: bool = False
|
|
225
225
|
|
|
226
|
+
#: Experimental. Apply the extended K15 model to account for vPM activation.
|
|
227
|
+
#: See the preprint `<https://doi.org/10.5194/egusphere-2025-1717>`_ for details.
|
|
228
|
+
#:
|
|
229
|
+
#: .. versionadded:: 0.55.0
|
|
230
|
+
vpm_activation: bool = False
|
|
231
|
+
|
|
226
232
|
#: Experimental. Radiative effects due to contrail-contrail overlapping
|
|
227
233
|
#: Account for change in local contrail shortwave and longwave radiative forcing
|
|
228
234
|
#: due to contrail-contrail overlapping.
|
|
229
235
|
#:
|
|
230
|
-
#: .. versionadded:: 0.45
|
|
236
|
+
#: .. versionadded:: 0.45.0
|
|
231
237
|
contrail_contrail_overlapping: bool = False
|
|
232
238
|
|
|
233
239
|
#: Experimental. Contrail-contrail overlapping altitude interval
|
|
@@ -40,9 +40,9 @@ class habit_dirichlet(rv_frozen):
|
|
|
40
40
|
|
|
41
41
|
Parameters
|
|
42
42
|
----------
|
|
43
|
-
*args
|
|
43
|
+
*args : Any
|
|
44
44
|
Used to create a number of habit distributions
|
|
45
|
-
**kwds
|
|
45
|
+
**kwds : Any
|
|
46
46
|
Passed through to :func:`scipy.stats.dirichlet.rvs()`
|
|
47
47
|
|
|
48
48
|
Returns
|
|
@@ -190,7 +190,7 @@ class CocipUncertaintyParams(CocipParams):
|
|
|
190
190
|
|
|
191
191
|
Returns
|
|
192
192
|
-------
|
|
193
|
-
dict[str,
|
|
193
|
+
dict[str, rv_frozen]
|
|
194
194
|
Uncertainty parameters and values
|
|
195
195
|
"""
|
|
196
196
|
# handle these differently starting in version 0.27.0
|
|
@@ -230,7 +230,7 @@ class CocipUncertaintyParams(CocipParams):
|
|
|
230
230
|
|
|
231
231
|
Returns
|
|
232
232
|
-------
|
|
233
|
-
dict[str,
|
|
233
|
+
dict[str, np.float64 | npt.NDArray[np.floating]]
|
|
234
234
|
Dictionary of random parameters. Dictionary keys consists of names of parameters in
|
|
235
235
|
`CocipParams` to be overridden by random value.
|
|
236
236
|
|