pycontrails 0.54.1__cp310-cp310-win_amd64.whl → 0.54.3__cp310-cp310-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.

Files changed (43) hide show
  1. pycontrails/_version.py +2 -2
  2. pycontrails/core/aircraft_performance.py +24 -5
  3. pycontrails/core/cache.py +14 -10
  4. pycontrails/core/fleet.py +22 -12
  5. pycontrails/core/flight.py +25 -15
  6. pycontrails/core/met.py +34 -22
  7. pycontrails/core/rgi_cython.cp310-win_amd64.pyd +0 -0
  8. pycontrails/core/vector.py +38 -38
  9. pycontrails/datalib/ecmwf/arco_era5.py +10 -5
  10. pycontrails/datalib/ecmwf/common.py +7 -2
  11. pycontrails/datalib/ecmwf/era5.py +9 -4
  12. pycontrails/datalib/ecmwf/era5_model_level.py +9 -5
  13. pycontrails/datalib/ecmwf/hres.py +12 -7
  14. pycontrails/datalib/ecmwf/hres_model_level.py +10 -5
  15. pycontrails/datalib/ecmwf/ifs.py +11 -6
  16. pycontrails/datalib/ecmwf/variables.py +1 -0
  17. pycontrails/datalib/gfs/gfs.py +52 -34
  18. pycontrails/datalib/gfs/variables.py +6 -2
  19. pycontrails/datalib/landsat.py +5 -8
  20. pycontrails/datalib/sentinel.py +7 -11
  21. pycontrails/ext/bada.py +3 -2
  22. pycontrails/ext/synthetic_flight.py +3 -2
  23. pycontrails/models/accf.py +40 -19
  24. pycontrails/models/apcemm/apcemm.py +2 -1
  25. pycontrails/models/cocip/cocip.py +8 -4
  26. pycontrails/models/cocipgrid/cocip_grid.py +25 -20
  27. pycontrails/models/dry_advection.py +50 -54
  28. pycontrails/models/humidity_scaling/humidity_scaling.py +12 -7
  29. pycontrails/models/ps_model/__init__.py +2 -1
  30. pycontrails/models/ps_model/ps_aircraft_params.py +3 -2
  31. pycontrails/models/ps_model/ps_grid.py +187 -1
  32. pycontrails/models/ps_model/ps_model.py +12 -10
  33. pycontrails/models/ps_model/ps_operational_limits.py +39 -52
  34. pycontrails/physics/geo.py +149 -0
  35. pycontrails/physics/jet.py +141 -11
  36. pycontrails/physics/static/iata-cargo-load-factors-20241115.csv +71 -0
  37. pycontrails/physics/static/iata-passenger-load-factors-20241115.csv +71 -0
  38. {pycontrails-0.54.1.dist-info → pycontrails-0.54.3.dist-info}/METADATA +12 -11
  39. {pycontrails-0.54.1.dist-info → pycontrails-0.54.3.dist-info}/RECORD +43 -41
  40. {pycontrails-0.54.1.dist-info → pycontrails-0.54.3.dist-info}/WHEEL +1 -1
  41. {pycontrails-0.54.1.dist-info → pycontrails-0.54.3.dist-info}/LICENSE +0 -0
  42. {pycontrails-0.54.1.dist-info → pycontrails-0.54.3.dist-info}/NOTICE +0 -0
  43. {pycontrails-0.54.1.dist-info → pycontrails-0.54.3.dist-info}/top_level.txt +0 -0
@@ -9,18 +9,24 @@ References
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import contextlib
12
13
  import hashlib
13
14
  import logging
14
15
  import pathlib
16
+ import sys
15
17
  import warnings
16
18
  from collections.abc import Callable
17
19
  from datetime import datetime
18
20
  from typing import TYPE_CHECKING, Any
19
21
 
22
+ if sys.version_info >= (3, 12):
23
+ from typing import override
24
+ else:
25
+ from typing_extensions import override
26
+
20
27
  import numpy as np
21
28
  import pandas as pd
22
29
  import xarray as xr
23
- from overrides import overrides
24
30
 
25
31
  import pycontrails
26
32
  from pycontrails.core import cache, met
@@ -82,6 +88,9 @@ class GFSForecast(metsource.MetDataSource):
82
88
  show_progress : bool, optional
83
89
  Show progress when downloading files from GFS AWS Bucket.
84
90
  Defaults to False
91
+ cache_download: bool, optional
92
+ If True, cache downloaded grib files rather than storing them in a temporary file.
93
+ By default, False.
85
94
 
86
95
  Examples
87
96
  --------
@@ -116,7 +125,7 @@ class GFSForecast(metsource.MetDataSource):
116
125
  - `GFS Documentation <https://www.emc.ncep.noaa.gov/emc/pages/numerical_forecast_systems/gfs/documentation.php>`_
117
126
  """
118
127
 
119
- __slots__ = ("client", "grid", "cachestore", "show_progress", "forecast_time")
128
+ __slots__ = ("client", "grid", "cachestore", "show_progress", "forecast_time", "cache_download")
120
129
 
121
130
  #: S3 client for accessing GFS bucket
122
131
  client: botocore.client.S3
@@ -142,7 +151,8 @@ class GFSForecast(metsource.MetDataSource):
142
151
  forecast_time: DatetimeLike | None = None,
143
152
  cachestore: cache.CacheStore | None = __marker, # type: ignore[assignment]
144
153
  show_progress: bool = False,
145
- ):
154
+ cache_download: bool = False,
155
+ ) -> None:
146
156
  try:
147
157
  import boto3
148
158
  except ModuleNotFoundError as e:
@@ -169,6 +179,7 @@ class GFSForecast(metsource.MetDataSource):
169
179
  cachestore = cache.DiskCacheStore()
170
180
  self.cachestore = cachestore
171
181
  self.show_progress = show_progress
182
+ self.cache_download = cache_download
172
183
 
173
184
  if time is None and paths is None:
174
185
  raise ValueError("Time input is required when paths is None")
@@ -349,7 +360,7 @@ class GFSForecast(metsource.MetDataSource):
349
360
  forecast_hour = str(self.forecast_time.hour).zfill(2)
350
361
  return f"gfs.t{forecast_hour}z.pgrb2.{self._grid_string}.f{step_hour}"
351
362
 
352
- @overrides
363
+ @override
353
364
  def create_cachepath(self, t: datetime) -> str:
354
365
  if self.cachestore is None:
355
366
  raise ValueError("self.cachestore attribute must be defined to create cache path")
@@ -366,7 +377,7 @@ class GFSForecast(metsource.MetDataSource):
366
377
  # return cache path
367
378
  return self.cachestore.path(f"{datestr}-{step}-{suffix}.nc")
368
379
 
369
- @overrides
380
+ @override
370
381
  def download_dataset(self, times: list[datetime]) -> None:
371
382
  # get step relative to forecast forecast_time
372
383
  logger.debug(
@@ -377,7 +388,7 @@ class GFSForecast(metsource.MetDataSource):
377
388
  for t in times:
378
389
  self._download_file(t)
379
390
 
380
- @overrides
391
+ @override
381
392
  def cache_dataset(self, dataset: xr.Dataset) -> None:
382
393
  # if self.cachestore is None:
383
394
  # LOG.debug("Cache is turned off, skipping")
@@ -385,7 +396,7 @@ class GFSForecast(metsource.MetDataSource):
385
396
 
386
397
  raise NotImplementedError("GFS caching only implemented with download")
387
398
 
388
- @overrides
399
+ @override
389
400
  def open_metdataset(
390
401
  self,
391
402
  dataset: xr.Dataset | None = None,
@@ -429,7 +440,7 @@ class GFSForecast(metsource.MetDataSource):
429
440
  # run the same GFS-specific processing on the dataset
430
441
  return self._process_dataset(ds, **kwargs)
431
442
 
432
- @overrides
443
+ @override
433
444
  def set_metadata(self, ds: xr.Dataset | met.MetDataset) -> None:
434
445
  ds.attrs.update(
435
446
  provider="NCEP",
@@ -462,24 +473,30 @@ class GFSForecast(metsource.MetDataSource):
462
473
  filename = self.filename(t)
463
474
  aws_key = f"{self.forecast_path}/{filename}"
464
475
 
476
+ stack = contextlib.ExitStack()
477
+ if self.cache_download:
478
+ target = self.cachestore.path(aws_key.replace("/", "-"))
479
+ else:
480
+ target = stack.enter_context(temp.temp_file())
481
+
465
482
  # Hold downloaded file in named temp file
466
- with temp.temp_file() as temp_grib_filename:
483
+ with stack:
467
484
  # retrieve data from AWS S3
468
- logger.debug(f"Downloading GFS file {filename} from AWS bucket to {temp_grib_filename}")
469
- if self.show_progress:
470
- _download_with_progress(
471
- self.client, GFS_FORECAST_BUCKET, aws_key, temp_grib_filename, filename
472
- )
473
- else:
474
- self.client.download_file(
475
- Bucket=GFS_FORECAST_BUCKET, Key=aws_key, Filename=temp_grib_filename
476
- )
485
+ logger.debug(f"Downloading GFS file {filename} from AWS bucket to {target}")
486
+ if not self.cache_download or not self.cachestore.exists(target):
487
+ self._make_download(aws_key, target, filename)
477
488
 
478
- ds = self._open_gfs_dataset(temp_grib_filename, t)
489
+ ds = self._open_gfs_dataset(target, t)
479
490
 
480
491
  cache_path = self.create_cachepath(t)
481
492
  ds.to_netcdf(cache_path)
482
493
 
494
+ def _make_download(self, aws_key: str, target: str, filename: str) -> None:
495
+ if self.show_progress:
496
+ _download_with_progress(self.client, GFS_FORECAST_BUCKET, aws_key, target, filename)
497
+ else:
498
+ self.client.download_file(Bucket=GFS_FORECAST_BUCKET, Key=aws_key, Filename=target)
499
+
483
500
  def _open_gfs_dataset(self, filepath: str | pathlib.Path, t: datetime) -> xr.Dataset:
484
501
  """Open GFS grib file for one forecast timestep.
485
502
 
@@ -502,7 +519,7 @@ class GFSForecast(metsource.MetDataSource):
502
519
  step = pd.Timedelta(t - self.forecast_time) // pd.Timedelta(1, "h")
503
520
 
504
521
  # open file for each variable short name individually
505
- ds: xr.Dataset | None = None
522
+ da_dict = {}
506
523
  for variable in self.variables:
507
524
  # Radiation data is not available in the 0th step
508
525
  is_radiation_step_zero = step == 0 and variable in (
@@ -519,23 +536,24 @@ class GFSForecast(metsource.MetDataSource):
519
536
  else:
520
537
  v = variable
521
538
 
522
- tmpds = xr.open_dataset(
523
- filepath,
524
- filter_by_keys={"typeOfLevel": v.level_type, "shortName": v.short_name},
525
- engine="cfgrib",
526
- )
527
-
528
- if ds is None:
529
- ds = tmpds
530
- else:
531
- ds[v.short_name] = tmpds[v.short_name]
539
+ try:
540
+ da = xr.open_dataarray(
541
+ filepath,
542
+ filter_by_keys={"typeOfLevel": v.level_type, "shortName": v.short_name},
543
+ engine="cfgrib",
544
+ )
545
+ except ValueError as exc:
546
+ # To debug this situation, you can use:
547
+ # import cfgrib
548
+ # cfgrib.open_datasets(filepath)
549
+ msg = f"Variable {v.short_name} not found in {filepath}"
550
+ raise ValueError(msg) from exc
532
551
 
533
- # set all radiation data to np.nan in the 0th step
534
552
  if is_radiation_step_zero:
535
- ds = ds.rename({Visibility.short_name: variable.short_name})
536
- ds[variable.short_name] = np.nan
553
+ da = xr.full_like(da, np.nan) # set all radiation data to np.nan in the 0th step
554
+ da_dict[variable.short_name] = da
537
555
 
538
- assert ds is not None, "No variables were loaded from grib file"
556
+ ds = xr.Dataset(da_dict)
539
557
 
540
558
  # for pressure levels, need to rename "level" field and downselect
541
559
  if self.pressure_levels != [-1]:
@@ -43,7 +43,9 @@ CloudIceWaterMixingRatio = MetVariable(
43
43
 
44
44
 
45
45
  TOAUpwardShortwaveRadiation = MetVariable(
46
- short_name="uswrf",
46
+ # Note the variable in the GFS Grib file is "uswrf" for the "nominalTop" level
47
+ # eccodes > 2.38 rewrites to `suswrf` on loading
48
+ short_name="suswrf",
47
49
  standard_name="toa_upward_shortwave_flux",
48
50
  long_name="Top of atmosphere upward shortwave radiation",
49
51
  units="W m**-2",
@@ -56,7 +58,9 @@ TOAUpwardShortwaveRadiation = MetVariable(
56
58
  )
57
59
 
58
60
  TOAUpwardLongwaveRadiation = MetVariable(
59
- short_name="ulwrf",
61
+ # Note the variable in the GFS Grib file is "ulwrf" for the "nominalTop" level
62
+ # eccodes > 2.38 rewrites to `sulwrf` on loading
63
+ short_name="sulwrf",
60
64
  standard_name="toa_upward_longwave_flux",
61
65
  long_name="Top of atmosphere upward longwave radiation",
62
66
  units="W m**-2",
@@ -152,7 +152,7 @@ class Landsat:
152
152
  are used. Bands must share a common resolution. The resolutions of each band are:
153
153
 
154
154
  - B1-B7, B9: 30 m
155
- - B9: 15 m
155
+ - B8: 15 m
156
156
  - B10, B11: 30 m (upsampled from true resolution of 100 m)
157
157
 
158
158
  cachestore : cache.CacheStore, optional
@@ -291,9 +291,7 @@ def _check_band_resolution(bands: set[str]) -> None:
291
291
  there are two valid cases: only band 8, or any bands except band 8.
292
292
  """
293
293
  groups = [
294
- {
295
- "B8",
296
- }, # 15 m
294
+ {"B8"}, # 15 m
297
295
  {f"B{i}" for i in range(1, 12) if i != 8}, # 30 m
298
296
  ]
299
297
  if not any(bands.issubset(group) for group in groups):
@@ -313,10 +311,9 @@ def _read(path: str, meta: str, band: str, processing: str) -> xr.DataArray:
313
311
  pycontrails_optional_package="sat",
314
312
  )
315
313
 
316
- src = rasterio.open(path)
317
- img = src.read(1)
318
- crs = pyproj.CRS.from_epsg(src.crs.to_epsg())
319
- src.close()
314
+ with rasterio.open(path) as src:
315
+ img = src.read(1)
316
+ crs = pyproj.CRS.from_epsg(src.crs.to_epsg())
320
317
 
321
318
  if processing == "reflectance":
322
319
  mult, add = _read_band_reflectance_rescaling(meta, band)
@@ -313,9 +313,8 @@ def _check_band_resolution(bands: set[str]) -> None:
313
313
  def _read(path: str, granule_meta: str, safe_meta: str, band: str, processing: str) -> xr.DataArray:
314
314
  """Read imagery data from Sentinel-2 files."""
315
315
  Image.MAX_IMAGE_PIXELS = None # avoid decompression bomb warning
316
- src = Image.open(path)
317
- img = np.asarray(src)
318
- src.close()
316
+ with Image.open(path) as src:
317
+ img = np.asarray(src)
319
318
 
320
319
  if processing == "reflectance":
321
320
  gain, offset = _read_band_reflectance_rescaling(safe_meta, band)
@@ -357,10 +356,9 @@ def _band_id(band: str) -> int:
357
356
  """Get band ID used in some metadata files."""
358
357
  if band in (f"B{i:2d}" for i in range(1, 9)):
359
358
  return int(band[1:]) - 1
360
- elif band == "B8A":
359
+ if band == "B8A":
361
360
  return 8
362
- else:
363
- return int(band[1:])
361
+ return int(band[1:])
364
362
 
365
363
 
366
364
  def _read_band_reflectance_rescaling(meta: str, band: str) -> tuple[float, float]:
@@ -389,12 +387,10 @@ def _read_band_reflectance_rescaling(meta: str, band: str) -> tuple[float, float
389
387
  for elem in elems:
390
388
  if int(elem.attrib["band_id"]) == band_id and elem.text is not None:
391
389
  offset = float(elem.text)
392
- break
393
- else:
394
- msg = f"Could not find reflectance offset for band {band} (band ID {band_id})"
395
- raise ValueError(msg)
390
+ return gain, offset
396
391
 
397
- return gain, offset
392
+ msg = f"Could not find reflectance offset for band {band} (band ID {band_id})"
393
+ raise ValueError(msg)
398
394
 
399
395
 
400
396
  def _read_image_coordinates(meta: str, band: str) -> tuple[np.ndarray, np.ndarray]:
pycontrails/ext/bada.py CHANGED
@@ -22,8 +22,9 @@ try:
22
22
 
23
23
  except ImportError as e:
24
24
  raise ImportError(
25
- "Failed to import the 'pycontrails-bada' package. Install with 'pip install"
26
- ' "pycontrails-bada @ git+ssh://git@github.com/contrailcirrus/pycontrails-bada.git"\'.'
25
+ "Failed to import the 'pycontrails-bada' package. Install with 'pip install "
26
+ "--index-url https://us-central1-python.pkg.dev/contrails-301217/pycontrails/simple "
27
+ "pycontrails-bada'."
27
28
  ) from e
28
29
  else:
29
30
  __all__ = [
@@ -20,8 +20,9 @@ try:
20
20
  from pycontrails.ext.bada import bada_model
21
21
  except ImportError as e:
22
22
  raise ImportError(
23
- 'SyntheticFlight requires BADA extension. Install with `pip install "pycontrails-bada @'
24
- ' git+ssh://git@github.com/contrailcirrus/pycontrails-bada.git"`'
23
+ "Failed to import the 'pycontrails-bada' package. Install with 'pip install "
24
+ "--index-url https://us-central1-python.pkg.dev/contrails-301217/pycontrails/simple "
25
+ "pycontrails-bada'."
25
26
  ) from e
26
27
 
27
28
  logger = logging.getLogger(__name__)
@@ -88,13 +88,16 @@ class ACCFParams(ModelParams):
88
88
  h2o_scaling: float = 1.0
89
89
  o3_scaling: float = 1.0
90
90
 
91
- forecast_step: float = 6.0
91
+ forecast_step: float | None = None
92
92
 
93
93
  sep_ri_rw: bool = False
94
94
 
95
95
  climate_indicator: str = "ATR"
96
96
 
97
- horizontal_resolution: float = 0.5
97
+ #: The horizontal resolution of the meteorological data in degrees.
98
+ #: If None, it will be inferred from the ``met`` dataset for :class:`MetDataset`
99
+ #: source, otherwise it will be set to 0.5.
100
+ horizontal_resolution: float | None = None
98
101
 
99
102
  emission_scenario: str = "pulse"
100
103
 
@@ -116,6 +119,8 @@ class ACCFParams(ModelParams):
116
119
 
117
120
  PMO: bool = False
118
121
 
122
+ unit_K_per_kg_fuel: bool = False
123
+
119
124
 
120
125
  class ACCF(Model):
121
126
  """Compute Algorithmic Climate Change Functions (ACCF).
@@ -146,16 +151,13 @@ class ACCF(Model):
146
151
  SpecificHumidity,
147
152
  ecmwf.PotentialVorticity,
148
153
  Geopotential,
149
- RelativeHumidity,
154
+ (RelativeHumidity, ecmwf.RelativeHumidity),
150
155
  NorthwardWind,
151
156
  EastwardWind,
152
- ecmwf.PotentialVorticity,
153
157
  )
154
158
  sur_variables = (ecmwf.SurfaceSolarDownwardRadiation, ecmwf.TopNetThermalRadiation)
155
159
  default_params = ACCFParams
156
160
 
157
- short_vars = frozenset(v.short_name for v in (*met_variables, *sur_variables))
158
-
159
161
  # This variable won't get used since we are not writing the output
160
162
  # anywhere, but the library will complain if it's not defined
161
163
  path_lib = "./"
@@ -168,7 +170,13 @@ class ACCF(Model):
168
170
  **params_kwargs: Any,
169
171
  ) -> None:
170
172
  # Normalize ECMWF variables
171
- met = standardize_variables(met, self.met_variables)
173
+ variables = (v[0] if isinstance(v, tuple) else v for v in self.met_variables)
174
+ met = standardize_variables(met, variables)
175
+
176
+ # If relative humidity is in percentage, convert to a proportion
177
+ if met["relative_humidity"].attrs.get("units") == "%":
178
+ met.data["relative_humidity"] /= 100.0
179
+ met.data["relative_humidity"].attrs["units"] = "1"
172
180
 
173
181
  # Ignore humidity scaling warning
174
182
  with warnings.catch_warnings():
@@ -231,18 +239,21 @@ class ACCF(Model):
231
239
  if hasattr(self, "surface"):
232
240
  self.surface = self.source.downselect_met(self.surface)
233
241
 
234
- if isinstance(self.source, MetDataset):
235
- # Overwrite horizontal resolution to match met
236
- longitude = self.source.data["longitude"].values
237
- if longitude.size > 1:
238
- hres = abs(longitude[1] - longitude[0])
239
- self.params["horizontal_resolution"] = float(hres)
240
-
241
- else:
242
+ if self.params["horizontal_resolution"] is None:
243
+ if isinstance(self.source, MetDataset):
244
+ # Overwrite horizontal resolution to match met
245
+ longitude = self.source.data["longitude"].values
242
246
  latitude = self.source.data["latitude"].values
243
- if latitude.size > 1:
247
+ if longitude.size > 1:
248
+ hres = abs(longitude[1] - longitude[0])
249
+ self.params["horizontal_resolution"] = float(hres)
250
+ elif latitude.size > 1:
244
251
  hres = abs(latitude[1] - latitude[0])
245
252
  self.params["horizontal_resolution"] = float(hres)
253
+ else:
254
+ self.params["horizontal_resolution"] = 0.5
255
+ else:
256
+ self.params["horizontal_resolution"] = 0.5
246
257
 
247
258
  p_settings = _get_accf_config(self.params)
248
259
 
@@ -267,10 +278,14 @@ class ACCF(Model):
267
278
  aCCFs, _ = clim_imp.get_xarray()
268
279
 
269
280
  # assign ACCF outputs to source
281
+ skip = {
282
+ v[0].short_name if isinstance(v, tuple) else v.short_name
283
+ for v in (*self.met_variables, *self.sur_variables)
284
+ }
270
285
  maCCFs = MetDataset(aCCFs)
271
286
  for key, arr in maCCFs.data.items():
272
287
  # skip met variables
273
- if key in self.short_vars:
288
+ if key in skip:
274
289
  continue
275
290
 
276
291
  assert isinstance(key, str)
@@ -292,7 +307,12 @@ class ACCF(Model):
292
307
  # It also needs variables to have the ECMWF short name
293
308
  if isinstance(self.met, MetDataset):
294
309
  ds_met = self.met.data.transpose("time", "level", "latitude", "longitude")
295
- name_dict = {v.standard_name: v.short_name for v in self.met_variables}
310
+ name_dict = {
311
+ v[0].standard_name if isinstance(v, tuple) else v.standard_name: v[0].short_name
312
+ if isinstance(v, tuple)
313
+ else v.short_name
314
+ for v in self.met_variables
315
+ }
296
316
  ds_met = ds_met.rename(name_dict)
297
317
  else:
298
318
  ds_met = None
@@ -340,7 +360,7 @@ def _get_accf_config(params: dict[str, Any]) -> dict[str, Any]:
340
360
  "horizontal_resolution": params["horizontal_resolution"],
341
361
  "forecast_step": params["forecast_step"],
342
362
  "NOx_aCCF": True,
343
- "NOx&inverse_EIs": params["nox_ei"],
363
+ "NOx_EI&F_km": params["nox_ei"],
344
364
  "output_format": "netCDF",
345
365
  "mean": False,
346
366
  "std": False,
@@ -361,6 +381,7 @@ def _get_accf_config(params: dict[str, Any]) -> dict[str, Any]:
361
381
  "H2O": params["h2o_scaling"],
362
382
  "O3": params["o3_scaling"],
363
383
  },
384
+ "unit_K/kg(fuel)": params["unit_K_per_kg_fuel"],
364
385
  "PCFA": params["pfca"],
365
386
  "PCFA-ISSR": {
366
387
  "rhi_threshold": params["issr_rhi_threshold"],
@@ -28,6 +28,7 @@ from pycontrails.core.met_var import (
28
28
  SpecificHumidity,
29
29
  VerticalVelocity,
30
30
  )
31
+ from pycontrails.core.vector import GeoVectorDataset
31
32
  from pycontrails.models.apcemm import utils
32
33
  from pycontrails.models.apcemm.inputs import APCEMMInput
33
34
  from pycontrails.models.dry_advection import DryAdvection
@@ -314,7 +315,7 @@ class APCEMM(models.Model):
314
315
  source: Flight
315
316
 
316
317
  #: Output from trajectory calculation
317
- trajectories: Flight | None
318
+ trajectories: GeoVectorDataset | None
318
319
 
319
320
  #: Time series output from the APCEMM early plume model
320
321
  vortex: pd.DataFrame | None
@@ -3,15 +3,20 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ import sys
6
7
  import warnings
7
8
  from collections.abc import Sequence
8
9
  from typing import Any, Literal, NoReturn, overload
9
10
 
11
+ if sys.version_info >= (3, 12):
12
+ from typing import override
13
+ else:
14
+ from typing_extensions import override
15
+
10
16
  import numpy as np
11
17
  import numpy.typing as npt
12
18
  import pandas as pd
13
19
  import xarray as xr
14
- from overrides import overrides
15
20
 
16
21
  from pycontrails.core import met_var
17
22
  from pycontrails.core.aircraft_performance import AircraftPerformance
@@ -1218,7 +1223,7 @@ class Cocip(Model):
1218
1223
 
1219
1224
  return self._downwash_contrail.filter(filt)
1220
1225
 
1221
- @overrides
1226
+ @override
1222
1227
  def _cleanup_indices(self) -> None:
1223
1228
  """Cleanup interpolation artifacts."""
1224
1229
 
@@ -2291,8 +2296,7 @@ def calc_timestep_contrail_evolution(
2291
2296
  dt = time_2_array - time_1
2292
2297
 
2293
2298
  # get new contrail location & segment properties after t_step
2294
- longitude_2 = geo.advect_longitude(longitude_1, latitude_1, u_wind_1, dt)
2295
- latitude_2 = geo.advect_latitude(latitude_1, v_wind_1, dt)
2299
+ longitude_2, latitude_2 = geo.advect_horizontal(longitude_1, latitude_1, u_wind_1, v_wind_1, dt)
2296
2300
  level_2 = geo.advect_level(level_1, vertical_velocity_1, rho_air_1, terminal_fall_speed_1, dt)
2297
2301
  altitude_2 = units.pl_to_m(level_2)
2298
2302
 
@@ -816,6 +816,9 @@ class CocipGrid(models.Model):
816
816
  """
817
817
  Shortcut to create a :class:`MetDataset` source from coordinate arrays.
818
818
 
819
+ .. versionchanged:: 0.54.3
820
+ By default, the returned latitude values now extend to the poles.
821
+
819
822
  Parameters
820
823
  ----------
821
824
  level : level: npt.NDArray[np.float64] | list[float] | float
@@ -829,8 +832,6 @@ class CocipGrid(models.Model):
829
832
  longitude, latitude : npt.NDArray[np.float64] | list[float], optional
830
833
  Longitude and latitude arrays, by default None. If not specified, values of
831
834
  ``lon_step`` and ``lat_step`` are used to define ``longitude`` and ``latitude``.
832
- To avoid model degradation at the poles, latitude values are expected to be
833
- between -80 and 80 degrees.
834
835
  lon_step, lat_step : float, optional
835
836
  Longitude and latitude resolution, by default 1.0.
836
837
  Only used if parameter ``longitude`` (respective ``latitude``) not specified.
@@ -847,15 +848,11 @@ class CocipGrid(models.Model):
847
848
  if longitude is None:
848
849
  longitude = np.arange(-180, 180, lon_step, dtype=float)
849
850
  if latitude is None:
850
- latitude = np.arange(-80, 80.000001, lat_step, dtype=float)
851
-
852
- out = MetDataset.from_coords(longitude=longitude, latitude=latitude, level=level, time=time)
853
-
854
- if np.any(out.data.latitude > 80.0001) or np.any(out.data.latitude < -80.0001):
855
- msg = "Model only supports latitude between -80 and 80."
856
- raise ValueError(msg)
851
+ latitude = np.arange(-90, 90.000001, lat_step, dtype=float)
857
852
 
858
- return out
853
+ return MetDataset.from_coords(
854
+ longitude=longitude, latitude=latitude, level=level, time=time
855
+ )
859
856
 
860
857
 
861
858
  ################################
@@ -2054,10 +2051,13 @@ def advect(
2054
2051
  time_t2 = time + dt
2055
2052
  age_t2 = age + dt
2056
2053
 
2057
- longitude_t2 = geo.advect_longitude(
2058
- longitude=longitude, latitude=latitude, u_wind=u_wind, dt=dt
2054
+ longitude_t2, latitude_t2 = geo.advect_horizontal(
2055
+ longitude=longitude,
2056
+ latitude=latitude,
2057
+ u_wind=u_wind,
2058
+ v_wind=v_wind,
2059
+ dt=dt,
2059
2060
  )
2060
- latitude_t2 = geo.advect_latitude(latitude=latitude, v_wind=v_wind, dt=dt)
2061
2061
  level_t2 = geo.advect_level(level, vertical_velocity, rho_air, terminal_fall_speed, dt)
2062
2062
  altitude_t2 = units.pl_to_m(level_t2)
2063
2063
 
@@ -2089,15 +2089,20 @@ def advect(
2089
2089
  u_wind_tail = contrail["eastward_wind_tail"]
2090
2090
  v_wind_tail = contrail["northward_wind_tail"]
2091
2091
 
2092
- longitude_head_t2 = geo.advect_longitude(
2093
- longitude=longitude_head, latitude=latitude_head, u_wind=u_wind_head, dt=dt_head
2092
+ longitude_head_t2, latitude_head_t2 = geo.advect_horizontal(
2093
+ longitude=longitude_head,
2094
+ latitude=latitude_head,
2095
+ u_wind=u_wind_head,
2096
+ v_wind=v_wind_head,
2097
+ dt=dt_head,
2094
2098
  )
2095
- latitude_head_t2 = geo.advect_latitude(latitude=latitude_head, v_wind=v_wind_head, dt=dt_head)
2096
-
2097
- longitude_tail_t2 = geo.advect_longitude(
2098
- longitude=longitude_tail, latitude=latitude_tail, u_wind=u_wind_tail, dt=dt_tail
2099
+ longitude_tail_t2, latitude_tail_t2 = geo.advect_horizontal(
2100
+ longitude=longitude_tail,
2101
+ latitude=latitude_tail,
2102
+ u_wind=u_wind_tail,
2103
+ v_wind=v_wind_tail,
2104
+ dt=dt_tail,
2099
2105
  )
2100
- latitude_tail_t2 = geo.advect_latitude(latitude=latitude_tail, v_wind=v_wind_tail, dt=dt_tail)
2101
2106
 
2102
2107
  segment_length_t2 = geo.haversine(
2103
2108
  lons0=longitude_head_t2,