pycontrails 0.54.8__cp313-cp313-macosx_10_13_x86_64.whl → 0.54.10__cp313-cp313-macosx_10_13_x86_64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pycontrails might be problematic. Click here for more details.

pycontrails/_version.py CHANGED
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '0.54.8'
21
- __version_tuple__ = version_tuple = (0, 54, 8)
20
+ __version__ = version = '0.54.10'
21
+ __version_tuple__ = version_tuple = (0, 54, 10)
@@ -4,15 +4,9 @@ from __future__ import annotations
4
4
 
5
5
  import abc
6
6
  import dataclasses
7
- import sys
8
7
  import warnings
9
8
  from typing import Any, Generic, NoReturn, overload
10
9
 
11
- if sys.version_info >= (3, 12):
12
- from typing import override
13
- else:
14
- from typing_extensions import override
15
-
16
10
  import numpy as np
17
11
  import numpy.typing as npt
18
12
 
@@ -20,6 +14,7 @@ from pycontrails.core import flight, fuel
20
14
  from pycontrails.core.fleet import Fleet
21
15
  from pycontrails.core.flight import Flight
22
16
  from pycontrails.core.met import MetDataset
17
+ from pycontrails.core.met_var import AirTemperature, EastwardWind, MetVariable, NorthwardWind
23
18
  from pycontrails.core.models import Model, ModelParams, interpolate_met
24
19
  from pycontrails.core.vector import GeoVectorDataset
25
20
  from pycontrails.physics import jet
@@ -97,6 +92,9 @@ class AircraftPerformance(Model):
97
92
  """
98
93
 
99
94
  source: Flight
95
+ met_variables: tuple[MetVariable, ...] = ()
96
+ optional_met_variables: tuple[MetVariable, ...] = (AirTemperature, EastwardWind, NorthwardWind)
97
+ default_params = AircraftPerformanceParams
100
98
 
101
99
  @overload
102
100
  def eval(self, source: Fleet, **params: Any) -> Fleet: ...
@@ -129,7 +127,8 @@ class AircraftPerformance(Model):
129
127
  self.set_source_met()
130
128
  self._cleanup_indices()
131
129
 
132
- # Calculate true airspeed if not included on source
130
+ # Calculate temperature and true airspeed if not included on source
131
+ self.ensure_air_temperature_on_source()
133
132
  self.ensure_true_airspeed_on_source()
134
133
 
135
134
  if isinstance(self.source, Fleet):
@@ -162,23 +161,6 @@ class AircraftPerformance(Model):
162
161
  - ``total_fuel_burn``: total fuel burn, [:math:`kg`]
163
162
  """
164
163
 
165
- @override
166
- def set_source_met(self, *args: Any, **kwargs: Any) -> None:
167
- fill_with_isa = self.params["fill_low_altitude_with_isa_temperature"]
168
- if fill_with_isa and (self.met is None or "air_temperature" not in self.met):
169
- if "air_temperature" in self.source:
170
- _fill_low_altitude_with_isa_temperature(self.source, 0.0)
171
- else:
172
- self.source["air_temperature"] = self.source.T_isa()
173
- fill_with_isa = False # we've just filled it
174
-
175
- super().set_source_met(*args, **kwargs)
176
- if not fill_with_isa:
177
- return
178
-
179
- met_level_0 = self.met.data["level"][-1].item() # type: ignore[union-attr]
180
- _fill_low_altitude_with_isa_temperature(self.source, met_level_0)
181
-
182
164
  def simulate_fuel_and_performance(
183
165
  self,
184
166
  *,
@@ -491,25 +473,57 @@ class AircraftPerformance(Model):
491
473
  Derived performance metrics at each waypoint.
492
474
  """
493
475
 
494
- def ensure_true_airspeed_on_source(self) -> npt.NDArray[np.floating]:
476
+ def ensure_air_temperature_on_source(self) -> None:
477
+ """Add ``air_temperature`` field to :attr:`source` data if not already present.
478
+
479
+ This function operates in-place. If ``air_temperature`` is not already present
480
+ on :attr:`source`, it is calculated by interpolation from met data.
481
+ """
482
+ fill_with_isa = self.params["fill_low_altitude_with_isa_temperature"]
483
+
484
+ if "air_temperature" in self.source:
485
+ if not fill_with_isa:
486
+ return
487
+ _fill_low_altitude_with_isa_temperature(self.source, 0.0)
488
+ return
489
+
490
+ temp_available = self.met is not None and "air_temperature" in self.met
491
+
492
+ if not temp_available:
493
+ if fill_with_isa:
494
+ self.source["air_temperature"] = self.source.T_isa()
495
+ return
496
+ msg = (
497
+ "Cannot compute air temperature without providing met data that includes an "
498
+ "'air_temperature' variable. Either include met data with 'air_temperature' "
499
+ "in the model constructor, define 'air_temperature' data on the flight, or set "
500
+ "'fill_low_altitude_with_isa_temperature' to True."
501
+ )
502
+ raise ValueError(msg)
503
+
504
+ interpolate_met(self.met, self.source, "air_temperature", **self.interp_kwargs)
505
+
506
+ if not fill_with_isa:
507
+ return
508
+
509
+ met_level_0 = self.met.data["level"][-1].item() # type: ignore[union-attr]
510
+ _fill_low_altitude_with_isa_temperature(self.source, met_level_0)
511
+
512
+ def ensure_true_airspeed_on_source(self) -> None:
495
513
  """Add ``true_airspeed`` field to :attr:`source` data if not already present.
496
514
 
497
- Returns
498
- -------
499
- npt.NDArray[np.floating]
500
- True airspeed, [:math:`m s^{-1}`]. If ``true_airspeed`` is already present
501
- on :attr:`source`, this is returned directly. Otherwise, it is calculated
502
- using :meth:`Flight.segment_true_airspeed`.
515
+ This function operates in-place. If ``true_airspeed`` is not already present
516
+ on :attr:`source`, it is calculated using :meth:`Flight.segment_true_airspeed`.
503
517
  """
504
518
  tas = self.source.get("true_airspeed")
505
519
  fill_with_groundspeed = self.params["fill_low_altitude_with_zero_wind"]
506
520
 
507
521
  if tas is not None:
508
522
  if not fill_with_groundspeed:
509
- return tas
523
+ return
510
524
  cond = np.isnan(tas)
511
525
  tas[cond] = self.source.segment_groundspeed()[cond]
512
- return tas
526
+ return
513
527
 
514
528
  # Use current cocip convention: eastward_wind on met, u_wind on source
515
529
  wind_available = ("u_wind" in self.source and "v_wind" in self.source) or (
@@ -520,7 +534,7 @@ class AircraftPerformance(Model):
520
534
  if fill_with_groundspeed:
521
535
  tas = self.source.segment_groundspeed()
522
536
  self.source["true_airspeed"] = tas
523
- return tas
537
+ return
524
538
  msg = (
525
539
  "Cannot compute 'true_airspeed' without 'eastward_wind' and 'northward_wind' "
526
540
  "met data. Either include met data in the model constructor, define "
@@ -545,7 +559,6 @@ class AircraftPerformance(Model):
545
559
 
546
560
  out = self.source.segment_true_airspeed(u, v)
547
561
  self.source["true_airspeed"] = out
548
- return out
549
562
 
550
563
 
551
564
  @dataclasses.dataclass
@@ -543,7 +543,7 @@ class Model(ABC):
543
543
 
544
544
  See Also
545
545
  --------
546
- - :meth:`eval`
546
+ eval
547
547
  """
548
548
  self.source = self._get_source(source)
549
549
 
@@ -706,17 +706,20 @@ class Model(ABC):
706
706
  # https://github.com/python/cpython/blob/618b7a8260bb40290d6551f24885931077309590/Lib/collections/__init__.py#L231
707
707
  __marker = object()
708
708
 
709
- def get_source_param(self, key: str, default: Any = __marker, *, set_attr: bool = True) -> Any:
710
- """Get source data with default set by parameter key.
709
+ def get_data_param(
710
+ self, other: SourceType, key: str, default: Any = __marker, *, set_attr: bool = True
711
+ ) -> Any:
712
+ """Get data from other source-compatible object with default set by model parameter key.
711
713
 
712
714
  Retrieves data with the following hierarchy:
713
715
 
714
- 1. :attr:`source.data[key]`. Returns ``np.ndarray | xr.DataArray``.
715
- 2. :attr:`source.attrs[key]`
716
+ 1. :attr:`other.data[key]`. Returns ``np.ndarray | xr.DataArray``.
717
+ 2. :attr:`other.attrs[key]`
716
718
  3. :attr:`params[key]`
717
719
  4. ``default``
718
720
 
719
- In case 3., the value of :attr:`params[key]` is attached to :attr:`source.attrs[key]`.
721
+ In case 3., the value of :attr:`params[key]` is attached to :attr:`other.attrs[key]`
722
+ unless ``set_attr`` is set to False.
720
723
 
721
724
  Parameters
722
725
  ----------
@@ -731,31 +734,33 @@ class Model(ABC):
731
734
  Returns
732
735
  -------
733
736
  Any
734
- Value(s) found for key in source data, source attrs, or model params
737
+ Value(s) found for key in ``other`` data, ``other`` attrs, or model params
735
738
 
736
739
  Raises
737
740
  ------
738
741
  KeyError
739
742
  Raises KeyError if key is not found in any location and ``default`` is not provided.
740
743
 
744
+
741
745
  See Also
742
746
  --------
743
- - GeoVectorDataset.get_data_or_attr
747
+ get_source_param
748
+ pycontrails.core.vector.GeoVectorDataset.get_data_or_attr
744
749
  """
745
750
  marker = self.__marker
746
751
 
747
- out = self.source.data.get(key, marker)
752
+ out = other.data.get(key, marker)
748
753
  if out is not marker:
749
754
  return out
750
755
 
751
- out = self.source.attrs.get(key, marker)
756
+ out = other.attrs.get(key, marker)
752
757
  if out is not marker:
753
758
  return out
754
759
 
755
760
  out = self.params.get(key, marker)
756
761
  if out is not marker:
757
762
  if set_attr:
758
- self.source.attrs[key] = out
763
+ other.attrs[key] = out
759
764
 
760
765
  return out
761
766
 
@@ -765,6 +770,46 @@ class Model(ABC):
765
770
  msg = f"Key '{key}' not found in source data, attrs, or model params"
766
771
  raise KeyError(msg)
767
772
 
773
+ def get_source_param(self, key: str, default: Any = __marker, *, set_attr: bool = True) -> Any:
774
+ """Get source data with default set by parameter key.
775
+
776
+ Retrieves data with the following hierarchy:
777
+
778
+ 1. :attr:`source.data[key]`. Returns ``np.ndarray | xr.DataArray``.
779
+ 2. :attr:`source.attrs[key]`
780
+ 3. :attr:`params[key]`
781
+ 4. ``default``
782
+
783
+ In case 3., the value of :attr:`params[key]` is attached to :attr:`source.attrs[key]`
784
+ unless ``set_attr`` is set to False.
785
+
786
+ Parameters
787
+ ----------
788
+ key : str
789
+ Key to retrieve
790
+ default : Any, optional
791
+ Default value if key is not found.
792
+ set_attr : bool, optional
793
+ If True (default), set :attr:`source.attrs[key]` to :attr:`params[key]` if found.
794
+ This allows for better post model evaluation tracking.
795
+
796
+ Returns
797
+ -------
798
+ Any
799
+ Value(s) found for key in source data, source attrs, or model params
800
+
801
+ Raises
802
+ ------
803
+ KeyError
804
+ Raises KeyError if key is not found in any location and ``default`` is not provided.
805
+
806
+ See Also
807
+ --------
808
+ get_data_param
809
+ pycontrails.core.vector.GeoVectorDataset.get_data_or_attr
810
+ """
811
+ return self.get_data_param(self.source, key, default, set_attr=set_attr)
812
+
768
813
  def _cleanup_indices(self) -> None:
769
814
  """Cleanup indices artifacts if ``params["interpolation_use_indices"]`` is True."""
770
815
  if self.params["interpolation_use_indices"] and isinstance(self.source, GeoVectorDataset):
@@ -16,6 +16,7 @@ from __future__ import annotations
16
16
 
17
17
  import datetime
18
18
  import enum
19
+ import os
19
20
  import tempfile
20
21
  from collections.abc import Iterable
21
22
 
@@ -58,6 +59,22 @@ DEFAULT_CHANNELS = "C11", "C14", "C15"
58
59
  #: See `GOES ABI scan information <https://www.goes-r.gov/users/abiScanModeInfo.html>`_.
59
60
  GOES_SCAN_MODE_CHANGE = datetime.datetime(2019, 4, 2, 16)
60
61
 
62
+ #: The date at which GOES-19 data started being available. This is used to
63
+ #: determine the source (GOES-16 or GOES-19) of requested. In particular,
64
+ #: Mesoscale images are only available for GOES-East from GOES-19 after this date.
65
+ #: See the `NOAA press release <https://www.noaa.gov/news-release/noaas-goes-19-satellite-now-operational-providing-critical-new-data-to-forecasters>`_.
66
+ GOES_16_19_SWITCH_DATE = datetime.datetime(2025, 4, 4)
67
+
68
+ #: The GCS bucket for GOES-East data before ``GOES_16_19_SWITCH_DATE``.
69
+ GOES_16_BUCKET = "gcp-public-data-goes-16"
70
+
71
+ #: The GCS bucket for GOES-West data. Note that GOES-17 has degraded data quality
72
+ #: and is not recommended for use. This bucket isn't used by the ``GOES`` handler by default.
73
+ GOES_18_BUCKET = "gcp-public-data-goes-18"
74
+
75
+ #: The GCS bucket for GOES-East data after ``GOES_16_19_SWITCH_DATE``.
76
+ GOES_19_BUCKET = "gcp-public-data-goes-19"
77
+
61
78
 
62
79
  class GOESRegion(enum.Enum):
63
80
  """GOES Region of interest.
@@ -186,7 +203,7 @@ def gcs_goes_path(
186
203
  time: datetime.datetime,
187
204
  region: GOESRegion,
188
205
  channels: str | Iterable[str] | None = None,
189
- bucket: str = "gcp-public-data-goes-16",
206
+ bucket: str | None = None,
190
207
  fs: gcsfs.GCSFileSystem | None = None,
191
208
  ) -> list[str]:
192
209
  """Return GCS paths to GOES data at the given time for the given region and channels.
@@ -207,6 +224,12 @@ def gcs_goes_path(
207
224
  set ``channels=("C11", "C14", "C15")``. For the true color scheme,
208
225
  set ``channels=("C01", "C02", "C03")``. By default, the channels
209
226
  required by the SEVIRI ash color scheme are used.
227
+ bucket : str | None
228
+ GCS bucket for GOES data. If None, the bucket is automatically
229
+ set to ``GOES_16_BUCKET`` if ``time`` is before
230
+ ``GOES_16_19_SWITCH_DATE`` and ``GOES_19_BUCKET`` otherwise.
231
+ fs : gcsfs.GCSFileSystem | None
232
+ GCS file system instance. If None, a default anonymous instance is created.
210
233
 
211
234
  Returns
212
235
  -------
@@ -235,6 +258,11 @@ def gcs_goes_path(
235
258
  >>> pprint(paths)
236
259
  ['gcp-public-data-goes-16/ABI-L2-CMIPM/2023/093/02/OR_ABI-L2-CMIPM1-M6C01_G16_s20230930211249_e20230930211309_c20230930211386.nc']
237
260
 
261
+ >>> t = datetime.datetime(2025, 5, 4, 3, 2)
262
+ >>> paths = gcs_goes_path(t, GOESRegion.M2, channels="C01")
263
+ >>> pprint(paths)
264
+ ['gcp-public-data-goes-19/ABI-L2-CMIPM/2025/124/03/OR_ABI-L2-CMIPM2-M6C01_G19_s20251240302557_e20251240303014_c20251240303092.nc']
265
+
238
266
  """
239
267
  time = _check_time_resolution(time, region)
240
268
  year = time.strftime("%Y")
@@ -246,7 +274,10 @@ def gcs_goes_path(
246
274
  product_name = "CMIP" # Cloud and Moisture Imagery
247
275
  product = f"{sensor}-{level}-{product_name}{region.name[0]}"
248
276
 
249
- bucket = bucket.removeprefix("gs://")
277
+ if bucket is None:
278
+ bucket = GOES_16_BUCKET if time < GOES_16_19_SWITCH_DATE else GOES_19_BUCKET
279
+ else:
280
+ bucket = bucket.removeprefix("gs://")
250
281
 
251
282
  path_prefix = f"gs://{bucket}/{product}/{year}/{yday}/{hour}/"
252
283
 
@@ -266,7 +297,13 @@ def gcs_goes_path(
266
297
  time_str = f"{time_str[:-1]}6"
267
298
 
268
299
  name_prefix = f"OR_{product[:-1]}{region.name}-{mode}"
269
- name_suffix = f"_G16_s{time_str}*"
300
+
301
+ try:
302
+ satellite_number = int(bucket[-2:]) # 16 or 18 or 19 -- this may fail for custom buckets
303
+ except (ValueError, IndexError) as exc:
304
+ msg = f"Bucket name {bucket} does not end with a valid satellite number."
305
+ raise ValueError(msg) from exc
306
+ name_suffix = f"_G{satellite_number}_s{time_str}*"
270
307
 
271
308
  channels = _parse_channels(channels)
272
309
 
@@ -322,8 +359,12 @@ class GOES:
322
359
  cachestore : cache.CacheStore | None
323
360
  Cache store for GOES data. If None, data is downloaded directly into
324
361
  memory. By default, a :class:`cache.DiskCacheStore` is used.
325
- goes_bucket : str = "gcp-public-data-goes-16"
326
- GCP bucket for GOES data. AWS access is not supported.
362
+ goes_bucket : str | None = None
363
+ GCP bucket for GOES data. If None, the bucket is automatically
364
+ set to ``GOES_16_BUCKET`` if the requested time is before
365
+ ``GOES_16_19_SWITCH_DATE`` and ``GOES_19_BUCKET`` otherwise.
366
+ The satellite number used for filename construction is derived from the
367
+ last two characters of this bucket name.
327
368
 
328
369
  See Also
329
370
  --------
@@ -395,7 +436,7 @@ class GOES:
395
436
  region: GOESRegion | str = GOESRegion.F,
396
437
  channels: str | Iterable[str] | None = None,
397
438
  cachestore: cache.CacheStore | None = __marker, # type: ignore[assignment]
398
- goes_bucket: str = "gcp-public-data-goes-16",
439
+ goes_bucket: str | None = None,
399
440
  ) -> None:
400
441
  self.region = _parse_region(region)
401
442
  self.channels = _parse_channels(channels)
@@ -434,7 +475,7 @@ class GOES:
434
475
  List of GCS paths to GOES data.
435
476
  """
436
477
  channels = channels or self.channels
437
- return gcs_goes_path(time, self.region, channels, self.goes_bucket)
478
+ return gcs_goes_path(time, self.region, channels, bucket=self.goes_bucket, fs=self.fs)
438
479
 
439
480
  def _lpaths(self, time: datetime.datetime) -> dict[str, str]:
440
481
  """Construct names for local netcdf files using the :attr:`cachestore`.
@@ -566,9 +607,12 @@ class GOES:
566
607
 
567
608
  def _load_via_tempfile(data: bytes) -> xr.Dataset:
568
609
  """Load xarray dataset via temporary file."""
569
- with tempfile.NamedTemporaryFile(buffering=0) as tmp:
610
+ with tempfile.NamedTemporaryFile(delete=False) as tmp:
570
611
  tmp.write(data)
612
+ try:
571
613
  return xr.load_dataset(tmp.name)
614
+ finally:
615
+ os.remove(tmp.name)
572
616
 
573
617
 
574
618
  def _concat_c02(ds1: XArrayType, ds2: XArrayType) -> XArrayType:
@@ -660,6 +704,10 @@ def to_true_color(da: xr.DataArray, gamma: float = 2.2) -> npt.NDArray[np.float3
660
704
  ----------
661
705
  - `Unidata's true color recipe <https://unidata.github.io/python-gallery/examples/mapping_GOES16_TrueColor.html>`_
662
706
  """
707
+ if not np.all(np.isin([1, 2, 3], da["band_id"])):
708
+ msg = "DataArray must contain bands 1, 2, and 3 for true color"
709
+ raise ValueError(msg)
710
+
663
711
  red = da.sel(band_id=2).values
664
712
  green = da.sel(band_id=3).values
665
713
  blue = da.sel(band_id=1).values
@@ -716,6 +764,9 @@ def to_ash(da: xr.DataArray, convention: str = "SEVIRI") -> npt.NDArray[np.float
716
764
  array([0.0127004 , 0.22793579, 0.3930847 ], dtype=float32)
717
765
  """
718
766
  if convention == "standard":
767
+ if not np.all(np.isin([11, 13, 14, 15], da["band_id"])):
768
+ msg = "DataArray must contain bands 11, 13, 14, and 15 for standard ash"
769
+ raise ValueError(msg)
719
770
  c11 = da.sel(band_id=11).values # 8.44
720
771
  c13 = da.sel(band_id=13).values # 10.33
721
772
  c14 = da.sel(band_id=14).values # 11.19
@@ -725,7 +776,10 @@ def to_ash(da: xr.DataArray, convention: str = "SEVIRI") -> npt.NDArray[np.float
725
776
  green = c14 - c11
726
777
  blue = c13
727
778
 
728
- elif convention in ["SEVIRI", "MIT"]: # retain MIT for backwards compatibility
779
+ elif convention in ("SEVIRI", "MIT"): # retain MIT for backwards compatibility
780
+ if not np.all(np.isin([11, 14, 15], da["band_id"])):
781
+ msg = "DataArray must contain bands 11, 14, and 15 for SEVIRI ash"
782
+ raise ValueError(msg)
729
783
  c11 = da.sel(band_id=11).values # 8.44
730
784
  c14 = da.sel(band_id=14).values # 11.19
731
785
  c15 = da.sel(band_id=15).values # 12.27
@@ -770,3 +824,133 @@ def _clip_and_scale(
770
824
  Clipped and scaled array.
771
825
  """
772
826
  return (arr.clip(low, high) - low) / (high - low)
827
+
828
+
829
+ def parallax_correct(
830
+ longitude: npt.NDArray[np.floating],
831
+ latitude: npt.NDArray[np.floating],
832
+ altitude: npt.NDArray[np.floating],
833
+ goes_da: xr.DataArray,
834
+ ) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]:
835
+ r"""Apply parallax correction to WGS84 geodetic coordinates based on satellite perspective.
836
+
837
+ This function considers the ray from the satellite to the points of interest and finds
838
+ the intersection of this ray with the WGS84 ellipsoid. The intersection point is then
839
+ returned as the corrected longitude and latitude coordinates.
840
+
841
+ ::
842
+
843
+ @ satellite
844
+ \
845
+ \
846
+ \
847
+ \
848
+ \
849
+ * aircraft
850
+ \
851
+ \
852
+ x parallax corrected aircraft
853
+ ------------------------- surface
854
+
855
+ If the point of interest is not visible from the satellite (ie, on the opposite side of the
856
+ earth), the function returns nan for the corrected coordinates.
857
+
858
+ This function requires the :mod:`pyproj` package to be installed.
859
+
860
+ Parameters
861
+ ----------
862
+ longitude : npt.NDArray[np.floating]
863
+ A 1D array of longitudes in degrees.
864
+ latitude : npt.NDArray[np.floating]
865
+ A 1D array of latitudes in degrees.
866
+ altitude : npt.NDArray[np.floating]
867
+ A 1D array of altitudes in meters.
868
+ goes_da : xr.DataArray
869
+ DataArray containing the GOES projection information. Only the ``goes_imager_projection``
870
+ field of the :attr:`xr.DataArray.attrs` is used.
871
+
872
+ Returns
873
+ -------
874
+ tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]
875
+ A tuple containing the corrected longitude and latitude coordinates.
876
+
877
+ """
878
+ goes_imager_projection = goes_da.attrs["goes_imager_projection"]
879
+ sat_lon = goes_imager_projection["longitude_of_projection_origin"]
880
+ sat_lat = goes_imager_projection["latitude_of_projection_origin"]
881
+ sat_alt = goes_imager_projection["perspective_point_height"]
882
+
883
+ try:
884
+ import pyproj
885
+ except ModuleNotFoundError as exc:
886
+ dependencies.raise_module_not_found_error(
887
+ name="parallax_correct function",
888
+ package_name="pyproj",
889
+ module_not_found_error=exc,
890
+ pycontrails_optional_package="pyproj",
891
+ )
892
+
893
+ # Convert from WGS84 to ECEF coordinates
894
+ ecef_crs = pyproj.CRS("EPSG:4978")
895
+ transformer = pyproj.Transformer.from_crs("WGS84", ecef_crs, always_xy=True)
896
+
897
+ p0 = np.array(transformer.transform([sat_lon], [sat_lat], [sat_alt]))
898
+ p1 = np.array(transformer.transform(longitude, latitude, altitude))
899
+
900
+ # Major and minor axes of the ellipsoid
901
+ a = ecef_crs.ellipsoid.semi_major_metre # type: ignore[union-attr]
902
+ b = ecef_crs.ellipsoid.semi_minor_metre # type: ignore[union-attr]
903
+ intersection = _intersection_with_ellipsoid(p0, p1, a, b)
904
+
905
+ # Convert back to WGS84 coordinates
906
+ inv_transformer = pyproj.Transformer.from_crs(ecef_crs, "WGS84", always_xy=True)
907
+ return inv_transformer.transform(*intersection)[:2] # final coord is (close to) 0
908
+
909
+
910
+ def _intersection_with_ellipsoid(
911
+ p0: npt.NDArray[np.floating],
912
+ p1: npt.NDArray[np.floating],
913
+ a: float,
914
+ b: float,
915
+ ) -> npt.NDArray[np.floating]:
916
+ """Find the intersection of a line with the surface of an ellipsoid."""
917
+ # Calculate the direction vector
918
+ px, py, pz = p0
919
+ v = p1 - p0
920
+ vx, vy, vz = v
921
+
922
+ # The line between p0 and p1 in parametric form is p(t) = p0 + t * v
923
+ # We need to find t such that p(t) lies on the ellipsoid
924
+ # x^2 / a^2 + y^2 / a^2 + z^2 / b^2 = 1
925
+ # (px + t * vx)^2 / a^2 + (py + t * vy)^2 / a^2 + (pz + t * vz)^2 / b^2 = 1
926
+ # Rearranging gives a quadratic in t
927
+
928
+ # Calculate the coefficients of this quadratic equation
929
+ A = vx**2 / a**2 + vy**2 / a**2 + vz**2 / b**2
930
+ B = 2 * (px * vx / a**2 + py * vy / a**2 + pz * vz / b**2)
931
+ C = px**2 / a**2 + py**2 / a**2 + pz**2 / b**2 - 1.0
932
+
933
+ # Calculate the discriminant
934
+ D = B**2 - 4 * A * C
935
+ sqrtD = np.sqrt(D, where=D >= 0, out=np.full_like(D, np.nan))
936
+
937
+ # Calculate the two possible solutions for t
938
+ t0 = (-B + sqrtD) / (2.0 * A)
939
+ t1 = (-B - sqrtD) / (2.0 * A)
940
+
941
+ # Calculate the intersection points
942
+ intersection0 = p0 + t0 * v
943
+ intersection1 = p0 + t1 * v
944
+
945
+ # Pick the intersection point that is closer to the aircraft (p1)
946
+ d0 = np.linalg.norm(intersection0 - p1, axis=0)
947
+ d1 = np.linalg.norm(intersection1 - p1, axis=0)
948
+ out = np.where(d0 < d1, intersection0, intersection1)
949
+
950
+ # Fill the points in which the aircraft is not visible by the satellite with nan
951
+ # This occurs when the earth is between the satellite and the aircraft
952
+ # In other words, we can check for t0 < 1 (or t1 < 1)
953
+ opposite_side = t0 < 1.0
954
+ out[:, opposite_side] = np.nan
955
+
956
+ return out
@@ -207,7 +207,7 @@ class SyntheticFlight:
207
207
 
208
208
  def _id(self) -> int:
209
209
  """Get random flight ID."""
210
- return self.rng.integers(100_000, 999_999)
210
+ return self.rng.integers(100_000, 999_999).item()
211
211
 
212
212
  def _define_aircraft(self) -> None:
213
213
  """Define or update instance variables pertaining to flight aircrafts.
@@ -217,6 +217,8 @@ class DryAdvection(models.Model):
217
217
  - ``age``: Age of plume.
218
218
  - ``waypoint``: Identifier for each waypoint.
219
219
 
220
+ If ``flight_id`` is present in :attr:`source`, it is retained.
221
+
220
222
  If `"azimuth"` is present in :attr:`source`, `source.attrs`, or :attr:`params`,
221
223
  the following variables will also be added:
222
224
 
@@ -236,6 +238,9 @@ class DryAdvection(models.Model):
236
238
  self.source.setdefault("waypoint", np.arange(self.source.size))
237
239
 
238
240
  columns = ["longitude", "latitude", "level", "time", "age", "waypoint"]
241
+ if "flight_id" in self.source:
242
+ columns.append("flight_id")
243
+
239
244
  azimuth = self.get_source_param("azimuth", set_attr=False)
240
245
  if azimuth is None:
241
246
  # Early exit for pointwise only simulation
@@ -541,6 +546,10 @@ def _evolve_one_step(
541
546
  }
542
547
  )
543
548
 
549
+ flight_id = vector.get("flight_id")
550
+ if flight_id is not None:
551
+ out["flight_id"] = flight_id
552
+
544
553
  azimuth = vector.get("azimuth")
545
554
  if azimuth is None:
546
555
  # Early exit for "pointwise only" simulation
@@ -27,7 +27,6 @@ from pycontrails.core.aircraft_performance import (
27
27
  )
28
28
  from pycontrails.core.flight import Flight
29
29
  from pycontrails.core.met import MetDataset
30
- from pycontrails.core.met_var import AirTemperature, EastwardWind, MetVariable, NorthwardWind
31
30
  from pycontrails.models.ps_model import ps_operational_limits as ps_lims
32
31
  from pycontrails.models.ps_model.ps_aircraft_params import (
33
32
  PSAircraftEngineParams,
@@ -70,8 +69,6 @@ class PSFlight(AircraftPerformance):
70
69
 
71
70
  name = "PSFlight"
72
71
  long_name = "Poll-Schumann Aircraft Performance Model"
73
- met_variables: tuple[MetVariable, ...] = (AirTemperature,)
74
- optional_met_variables = EastwardWind, NorthwardWind
75
72
  default_params = PSFlightParams
76
73
 
77
74
  aircraft_engine_params: Mapping[str, PSAircraftEngineParams]
@@ -160,10 +157,10 @@ class PSFlight(AircraftPerformance):
160
157
  time=fl["time"],
161
158
  true_airspeed=true_airspeed,
162
159
  air_temperature=fl["air_temperature"],
163
- aircraft_mass=self.get_source_param("aircraft_mass", None),
164
- thrust=self.get_source_param("thrust", None),
165
- engine_efficiency=self.get_source_param("engine_efficiency", None),
166
- fuel_flow=self.get_source_param("fuel_flow", None),
160
+ aircraft_mass=self.get_data_param(fl, "aircraft_mass", None),
161
+ thrust=self.get_data_param(fl, "thrust", None),
162
+ engine_efficiency=self.get_data_param(fl, "engine_efficiency", None),
163
+ fuel_flow=self.get_data_param(fl, "fuel_flow", None),
167
164
  q_fuel=q_fuel,
168
165
  n_iter=self.params["n_iter"],
169
166
  amass_oew=amass_oew,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycontrails
3
- Version: 0.54.8
3
+ Version: 0.54.10
4
4
  Summary: Python library for modeling aviation climate impacts
5
5
  Author-email: "Contrails.org" <py@contrails.org>
6
6
  License: Apache-2.0
@@ -1,14 +1,14 @@
1
- pycontrails-0.54.8.dist-info/RECORD,,
2
- pycontrails-0.54.8.dist-info/WHEEL,sha256=D5oYZcu7xsHPQ1lvRcEfimRZkYr_RmvD7J5S2QmS8s0,138
3
- pycontrails-0.54.8.dist-info/top_level.txt,sha256=Z8J1R_AiBAyCVjNw6jYLdrA68PrQqTg0t3_Yek_IZ0Q,29
4
- pycontrails-0.54.8.dist-info/METADATA,sha256=RmMykD8fNfp0FkT9-7ZQKcrfKNVtrfQYj9WKJGdRMqM,9131
5
- pycontrails-0.54.8.dist-info/licenses/LICENSE,sha256=gJ-h7SFFD1mCfR6a7HILvEtodDT6Iig8bLXdgqR6ucA,10175
6
- pycontrails-0.54.8.dist-info/licenses/NOTICE,sha256=fiBPdjYibMpDzf8hqcn7TvAQ-yeK10q_Nqq24DnskYg,1962
7
- pycontrails/_version.py,sha256=toc4afFEmuid8E3AkMqVXqnNfmxWW94KE_GHaPTt9lg,513
1
+ pycontrails-0.54.10.dist-info/RECORD,,
2
+ pycontrails-0.54.10.dist-info/WHEEL,sha256=7serL2wuoHsjl6sqYTxAO9RZ3KPeoUy7VZm2_MBalZ8,138
3
+ pycontrails-0.54.10.dist-info/top_level.txt,sha256=Z8J1R_AiBAyCVjNw6jYLdrA68PrQqTg0t3_Yek_IZ0Q,29
4
+ pycontrails-0.54.10.dist-info/METADATA,sha256=6_jv8g-Jc_JE5J9qHUY3iv9S2ZrggE_4zOg-7nbtJ98,9132
5
+ pycontrails-0.54.10.dist-info/licenses/LICENSE,sha256=gJ-h7SFFD1mCfR6a7HILvEtodDT6Iig8bLXdgqR6ucA,10175
6
+ pycontrails-0.54.10.dist-info/licenses/NOTICE,sha256=fiBPdjYibMpDzf8hqcn7TvAQ-yeK10q_Nqq24DnskYg,1962
7
+ pycontrails/_version.py,sha256=XWNQstht0_G88RBCuMbHwa-c0eQNLQAijV1bVbgzzW8,515
8
8
  pycontrails/__init__.py,sha256=9ypSB2fKZlKghTvSrjWo6OHm5qfASwiTIvlMew3Olu4,2037
9
9
  pycontrails/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
10
  pycontrails/core/vector.py,sha256=N-3VhPaUEyFSJWjplMKFcv9GLvEqAibKn1zqJWuNZQU,73601
11
- pycontrails/core/models.py,sha256=iuGy9pQU-OI-AqPQyROxshSn2LbLcKNjXQcMEZw2IzA,42340
11
+ pycontrails/core/models.py,sha256=gpG0hnXZox5caojierunxKkgFLQt-6nHRhzQZJKNrzo,43845
12
12
  pycontrails/core/interpolation.py,sha256=wovjj3TAf3xonVxjarclpvZLyLq6N7wZQQXsI9hT3YA,25713
13
13
  pycontrails/core/fleet.py,sha256=0hi_N4R93St-7iD29SE0EnadpBEl_p9lSGtDwpWvGkk,16704
14
14
  pycontrails/core/flight.py,sha256=QZTGeZVnZ14UUWHSqgCSU49g_EGQZel-hzKwm_9dcFY,80653
@@ -16,14 +16,14 @@ pycontrails/core/fuel.py,sha256=kJZ3P1lPm1L6rdPREM55XQ-VfJ_pt35cP4sO2Nnvmjs,4332
16
16
  pycontrails/core/polygon.py,sha256=EmfHPj0e58whsHvR-3YvDgMWkvMFgp_BgwaoG8IZ4n0,18044
17
17
  pycontrails/core/cache.py,sha256=IIyx726zN7JzNSKV0JJDksMI9OhCLdnJShmBVStRqzI,28154
18
18
  pycontrails/core/__init__.py,sha256=p0O09HxdeXU0X5Z3zrHMlTfXa92YumT3fJ8wJBI5ido,856
19
- pycontrails/core/rgi_cython.cpython-313-darwin.so,sha256=rDVTDKQiRog8OqjwwwEqDOcXEoKh0FWbvwGKfwztAWs,308768
19
+ pycontrails/core/rgi_cython.cpython-313-darwin.so,sha256=LBb1akRp3WQ9HrkFKhXZ1suhpQgT0Rhs9YJoswxXY2Q,302408
20
20
  pycontrails/core/flightplan.py,sha256=xgyYLi36OlNKtIFuOHaifcDM6XMBYTyMQlXAtfd-6Js,7519
21
21
  pycontrails/core/met.py,sha256=4XQAJrKWBN0SZQSeBpMUnkLn87vYpn2VMiY3dQyFRIw,103992
22
- pycontrails/core/aircraft_performance.py,sha256=ww5YBZkCPiUxRZERI5bUmxBeiF6rIrS2AsZyv8mVjvE,27400
22
+ pycontrails/core/aircraft_performance.py,sha256=Kk_Rb61jDOWPmCQHwn2jR5vMPmB8b3aq1iTWfiUMj9U,28232
23
23
  pycontrails/core/airports.py,sha256=ubYo-WvxKPd_dUcADx6yew9Tqh1a4VJDgX7aFqLYwB8,6775
24
24
  pycontrails/core/met_var.py,sha256=lAbp3cko_rzMk_u0kq-F27sUXUxUKikUvCNycwp9ILY,12020
25
25
  pycontrails/core/coordinates.py,sha256=0ySsHtqTon7GMbuwmmxMbI92j3ueMteJZh4xxNm5zto,5391
26
- pycontrails/datalib/goes.py,sha256=mCEuDYdt1GIBA-sbDq5LdC6ZRvWJ28uaaBTnsXE4syc,26555
26
+ pycontrails/datalib/goes.py,sha256=4bKtu1l3IVsjKv7mLAWhStRzOVaY9Wi_cZxvL_g-V3w,34081
27
27
  pycontrails/datalib/landsat.py,sha256=r6366rEF7fOA7mT5KySCPGJplgGE5LvBw5fMqk-U1oM,19697
28
28
  pycontrails/datalib/__init__.py,sha256=hW9NWdFPC3y_2vHMteQ7GgQdop3917MkDaf5ZhU2RBY,369
29
29
  pycontrails/datalib/sentinel.py,sha256=hYSxIlQnyJHqtHWlKn73HOK_1pm-_IbGebmkHnh4UcA,17172
@@ -48,7 +48,7 @@ pycontrails/datalib/gfs/__init__.py,sha256=pXNjb9cJC6ngpuCnoHnmVZ2RHzbHZ0AlsyGvg
48
48
  pycontrails/datalib/spire/spire.py,sha256=h25BVgSr7E71Ox3-y9WgqFvp-54L08yzb2Ou-iMl7wM,24242
49
49
  pycontrails/datalib/spire/__init__.py,sha256=3-My8yQItS6PL0DqXgNaltLqvN6T7nbnNnLD-sy7kt4,186
50
50
  pycontrails/datalib/spire/exceptions.py,sha256=U0V_nZTLhxJwrzldvU9PdESx8-zLddRH3FmzkJyFyrI,1714
51
- pycontrails/ext/synthetic_flight.py,sha256=ByuJDfpuK5WaGMj41wflfzH6zwI1nejVcQXC4JoMvSI,16795
51
+ pycontrails/ext/synthetic_flight.py,sha256=wROBQErfr_IhEPndC97fuWbnZQega2Z89VhzoXzZMO8,16802
52
52
  pycontrails/ext/cirium.py,sha256=DFPfRwLDwddpucAPRQhyT4bDGh0VvvoViMUd3pidam8,415
53
53
  pycontrails/ext/empirical_grid.py,sha256=FPNQA0x4nVwBXFlbs3DgIapSrXFYhoc8b8IX0M4xhBc,4363
54
54
  pycontrails/ext/bada.py,sha256=YlQq4nnFyWza1Am2e2ZucpaICHDuUFRTrtVzIKMzf9s,1091
@@ -64,7 +64,7 @@ pycontrails/models/__init__.py,sha256=dQTOLQb7RdUdUwslt5se__5y_ymbInBexQmNrmAeOd
64
64
  pycontrails/models/issr.py,sha256=AYLYLHxtG8je5UG6x1zLV0ul89MJPqe5Xk0oWIyZ7b0,7378
65
65
  pycontrails/models/sac.py,sha256=lV1Or0AaLxuS1Zo5V8h5c1fkSKC-hKEgiFm7bmmusWw,15946
66
66
  pycontrails/models/accf.py,sha256=egdBa4_G3BUaoUQYWvVlTlAIWpLEuNdtCxlK3eckLOc,13599
67
- pycontrails/models/dry_advection.py,sha256=FqUvRFbnwe4esHBYDayn3iu7R2UUuaQwY8x2oToxNI0,19164
67
+ pycontrails/models/dry_advection.py,sha256=BlOQeap3rXKRhRlvhFfpOLIX3bFgYE_bJg2LlPRHIas,19424
68
68
  pycontrails/models/pcr.py,sha256=ZzbEuTOuDdUmmL5T3Wk3HL-O8XzX3HMnn98WcPbASaU,5348
69
69
  pycontrails/models/emissions/__init__.py,sha256=CZB2zIkLUI3NGNmq2ddvRYjEtiboY6PWJjiEiXj_zII,478
70
70
  pycontrails/models/emissions/ffm2.py,sha256=mAvBHnp-p3hIn2fjKGq50eaMHi0jcb5hA5uXbJGeE9I,12068
@@ -94,7 +94,7 @@ pycontrails/models/cocip/radiative_heating.py,sha256=1U4SQWwogtyQ2u6J996kAHP0Ofp
94
94
  pycontrails/models/cocip/contrail_properties.py,sha256=qdvFykCYBee17G1jDklzyoeYWDMzoOXCP-A6p9P_4sE,56053
95
95
  pycontrails/models/cocip/unterstrasser_wake_vortex.py,sha256=edMHuWKzFN1P4EMWC2HRv5ZS_rUI7Q5Nw3LsYkrI0mE,18936
96
96
  pycontrails/models/ps_model/__init__.py,sha256=Fuum5Rq8ya8qkvbeq2wh6NDo-42RCRnK1Y-2syYy0Ck,553
97
- pycontrails/models/ps_model/ps_model.py,sha256=U_xWn1b5CxmatvRSuGegfI-ANXscYq2s_7IROyK1p84,32131
97
+ pycontrails/models/ps_model/ps_model.py,sha256=fgFekJpGuAu73KvpfLhlAbIwR7JJGwQpLILWmrONywc,31925
98
98
  pycontrails/models/ps_model/ps_aircraft_params.py,sha256=I2nBkdnRo9YGMn-0k35ooYpzPNJkHyEH5cU3K-Cz8b0,13350
99
99
  pycontrails/models/ps_model/ps_operational_limits.py,sha256=XwMHO8yu8EZUWtxRgjRKwxmCrmKGoHO7Ob6nlfkrthI,16441
100
100
  pycontrails/models/ps_model/ps_grid.py,sha256=rBsCEQkGY4cTf57spF0sqCfOo4oC4auE8ngS_mcl0VM,26207
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (78.1.0)
2
+ Generator: setuptools (80.7.1)
3
3
  Root-Is-Purelib: false
4
4
  Tag: cp313-cp313-macosx_10_13_x86_64
5
5
  Generator: delocate 0.13.0