pycontrails 0.54.12__cp310-cp310-macosx_11_0_arm64.whl → 0.56.0__cp310-cp310-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.

Files changed (34) hide show
  1. pycontrails/_version.py +3 -3
  2. pycontrails/core/airports.py +1 -1
  3. pycontrails/core/cache.py +3 -3
  4. pycontrails/core/fleet.py +1 -1
  5. pycontrails/core/flight.py +47 -43
  6. pycontrails/core/met_var.py +1 -1
  7. pycontrails/core/rgi_cython.cpython-310-darwin.so +0 -0
  8. pycontrails/core/vector.py +28 -30
  9. pycontrails/datalib/landsat.py +49 -26
  10. pycontrails/datalib/leo_utils/__init__.py +5 -0
  11. pycontrails/datalib/leo_utils/correction.py +266 -0
  12. pycontrails/datalib/leo_utils/landsat_metadata.py +300 -0
  13. pycontrails/datalib/{_leo_utils → leo_utils}/search.py +1 -1
  14. pycontrails/datalib/leo_utils/sentinel_metadata.py +748 -0
  15. pycontrails/datalib/sentinel.py +236 -93
  16. pycontrails/models/cocip/cocip.py +30 -13
  17. pycontrails/models/cocip/cocip_params.py +9 -3
  18. pycontrails/models/cocip/cocip_uncertainty.py +4 -4
  19. pycontrails/models/cocip/contrail_properties.py +27 -27
  20. pycontrails/models/cocip/radiative_forcing.py +4 -4
  21. pycontrails/models/cocip/unterstrasser_wake_vortex.py +5 -5
  22. pycontrails/models/cocip/wake_vortex.py +6 -6
  23. pycontrails/models/cocipgrid/cocip_grid.py +60 -32
  24. pycontrails/models/dry_advection.py +3 -3
  25. pycontrails/models/extended_k15.py +1327 -0
  26. pycontrails/physics/constants.py +1 -1
  27. {pycontrails-0.54.12.dist-info → pycontrails-0.56.0.dist-info}/METADATA +3 -1
  28. {pycontrails-0.54.12.dist-info → pycontrails-0.56.0.dist-info}/RECORD +34 -29
  29. /pycontrails/datalib/{_leo_utils → leo_utils}/static/bq_roi_query.sql +0 -0
  30. /pycontrails/datalib/{_leo_utils → leo_utils}/vis.py +0 -0
  31. {pycontrails-0.54.12.dist-info → pycontrails-0.56.0.dist-info}/WHEEL +0 -0
  32. {pycontrails-0.54.12.dist-info → pycontrails-0.56.0.dist-info}/licenses/LICENSE +0 -0
  33. {pycontrails-0.54.12.dist-info → pycontrails-0.56.0.dist-info}/licenses/NOTICE +0 -0
  34. {pycontrails-0.54.12.dist-info → pycontrails-0.56.0.dist-info}/top_level.txt +0 -0
@@ -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._leo_utils import search
15
- from pycontrails.datalib._leo_utils.vis import equalize, normalize
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
- return search.intersect(BQ_TABLE, flight, columns)
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 | set[str] | None
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 = {"raw", "reflectance"}, optional
214
- Whether to return raw values or rescaled reflectances for reflective bands.
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.DataArray
220
- DataArray of Sentinel-2 data.
230
+ xr.Dataset
231
+ Dataset of Sentinel-2 data.
221
232
  """
222
- if reflective not in ["raw", "reflectance"]:
223
- msg = "reflective band processing must be one of ['raw', 'radiance', 'reflectance']"
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
- ds = xr.Dataset()
227
- for band in self.bands:
228
- ds[band] = self._get(band, reflective)
229
- return ds
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 = _read_image_coordinates(granule_meta, band)
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.ndarray, pyproj.CRS, tuple[float, float, float, float]]:
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 = {"true"}
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] | NoReturn
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, dict[str, Any]]
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, float | npt.NDArray[np.floating]]
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