pycontrails 0.54.7__cp311-cp311-macosx_10_9_x86_64.whl → 0.54.9__cp311-cp311-macosx_10_9_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.

Files changed (32) hide show
  1. pycontrails/_version.py +9 -4
  2. pycontrails/core/aircraft_performance.py +48 -35
  3. pycontrails/core/cache.py +4 -0
  4. pycontrails/core/flightplan.py +10 -2
  5. pycontrails/core/met.py +12 -7
  6. pycontrails/core/models.py +54 -9
  7. pycontrails/core/rgi_cython.cpython-311-darwin.so +0 -0
  8. pycontrails/core/vector.py +11 -3
  9. pycontrails/datalib/goes.py +146 -2
  10. pycontrails/datalib/spire/spire.py +6 -8
  11. pycontrails/ext/synthetic_flight.py +1 -1
  12. pycontrails/models/cocip/cocip.py +14 -5
  13. pycontrails/models/cocip/contrail_properties.py +4 -6
  14. pycontrails/models/cocip/output_formats.py +12 -4
  15. pycontrails/models/cocip/radiative_forcing.py +2 -8
  16. pycontrails/models/cocipgrid/cocip_grid.py +11 -11
  17. pycontrails/models/humidity_scaling/humidity_scaling.py +49 -4
  18. pycontrails/models/ps_model/ps_aircraft_params.py +1 -1
  19. pycontrails/models/ps_model/ps_grid.py +22 -22
  20. pycontrails/models/ps_model/ps_model.py +7 -13
  21. pycontrails/models/ps_model/static/{ps-aircraft-params-20240524.csv → ps-aircraft-params-20250328.csv} +58 -57
  22. pycontrails/models/ps_model/static/{ps-synonym-list-20240524.csv → ps-synonym-list-20250328.csv} +1 -0
  23. pycontrails/models/tau_cirrus.py +1 -0
  24. pycontrails/physics/jet.py +5 -4
  25. pycontrails/physics/static/{iata-cargo-load-factors-20241115.csv → iata-cargo-load-factors-20250221.csv} +3 -0
  26. pycontrails/physics/static/{iata-passenger-load-factors-20241115.csv → iata-passenger-load-factors-20250221.csv} +3 -0
  27. {pycontrails-0.54.7.dist-info → pycontrails-0.54.9.dist-info}/METADATA +3 -2
  28. {pycontrails-0.54.7.dist-info → pycontrails-0.54.9.dist-info}/RECORD +32 -32
  29. {pycontrails-0.54.7.dist-info → pycontrails-0.54.9.dist-info}/WHEEL +2 -1
  30. {pycontrails-0.54.7.dist-info → pycontrails-0.54.9.dist-info/licenses}/LICENSE +0 -0
  31. {pycontrails-0.54.7.dist-info → pycontrails-0.54.9.dist-info/licenses}/NOTICE +0 -0
  32. {pycontrails-0.54.7.dist-info → pycontrails-0.54.9.dist-info}/top_level.txt +0 -0
pycontrails/_version.py CHANGED
@@ -1,8 +1,13 @@
1
- # file generated by setuptools_scm
1
+ # file generated by setuptools-scm
2
2
  # don't change, don't track in version control
3
+
4
+ __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
5
+
3
6
  TYPE_CHECKING = False
4
7
  if TYPE_CHECKING:
5
- from typing import Tuple, Union
8
+ from typing import Tuple
9
+ from typing import Union
10
+
6
11
  VERSION_TUPLE = Tuple[Union[int, str], ...]
7
12
  else:
8
13
  VERSION_TUPLE = object
@@ -12,5 +17,5 @@ __version__: str
12
17
  __version_tuple__: VERSION_TUPLE
13
18
  version_tuple: VERSION_TUPLE
14
19
 
15
- __version__ = version = '0.54.7'
16
- __version_tuple__ = version_tuple = (0, 54, 7)
20
+ __version__ = version = '0.54.9'
21
+ __version_tuple__ = version_tuple = (0, 54, 9)
@@ -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
pycontrails/core/cache.py CHANGED
@@ -266,6 +266,8 @@ class DiskCacheStore(CacheStore):
266
266
  >>> # put a file directly
267
267
  >>> disk_cache.put("README.md", "test/file.md")
268
268
  'test/file.md'
269
+
270
+ >>> disk_cache.clear() # cleanup
269
271
  """
270
272
 
271
273
  if not pathlib.Path(data_path).is_file():
@@ -312,6 +314,8 @@ class DiskCacheStore(CacheStore):
312
314
  >>> # returns a path
313
315
  >>> disk_cache.get("test/file.md")
314
316
  'cache/test/file.md'
317
+
318
+ >>> disk_cache.clear() # cleanup
315
319
  """
316
320
  return self.path(cache_path)
317
321
 
@@ -38,10 +38,13 @@ def to_atc_plan(plan: dict[str, Any]) -> str:
38
38
  if "second_alt_icao" in plan:
39
39
  ret += f" {plan['second_alt_icao']}"
40
40
  ret += "\n"
41
- ret += f"-{plan['other_info']})\n"
41
+ ret += f"-{plan['other_info']}"
42
42
  if "supplementary_info" in plan:
43
+ ret += "\n-"
43
44
  ret += " ".join([f"{i[0]}/{i[1]}" for i in plan["supplementary_info"].items()])
44
45
 
46
+ ret += ")"
47
+
45
48
  if ret[-1] == "\n":
46
49
  ret = ret[:-1]
47
50
 
@@ -194,7 +197,12 @@ def parse_atc_plan(atc_plan: str) -> dict[str, str]:
194
197
 
195
198
  # Other info
196
199
  if len(basic) > 8:
197
- flightplan["other_info"] = basic[8]
200
+ info = basic[8]
201
+ idx = info.find("DOF")
202
+ if idx != -1:
203
+ flightplan["departure_date"] = info[idx + 4 : idx + 10]
204
+
205
+ flightplan["other_info"] = info.strip()
198
206
 
199
207
  # Supl. Info
200
208
  if len(basic) > 9:
pycontrails/core/met.py CHANGED
@@ -2643,19 +2643,24 @@ def downselect(data: XArrayType, bbox: tuple[float, ...]) -> XArrayType:
2643
2643
  "or length 6 [west, south, min-level, east, north, max-level]"
2644
2644
  )
2645
2645
 
2646
+ if west <= east:
2647
+ # Return a view of the data
2648
+ # If data is lazy, this will not load the data
2649
+ return data.sel(
2650
+ longitude=slice(west, east),
2651
+ latitude=slice(south, north),
2652
+ level=slice(level_min, level_max),
2653
+ )
2654
+
2655
+ # In this case, the bbox spans the antimeridian
2656
+ # If data is lazy, this will load the data (data.where is not lazy AFAIK)
2646
2657
  cond = (
2647
2658
  (data["latitude"] >= south)
2648
2659
  & (data["latitude"] <= north)
2649
2660
  & (data["level"] >= level_min)
2650
2661
  & (data["level"] <= level_max)
2662
+ & ((data["longitude"] >= west) | (data["longitude"] <= east))
2651
2663
  )
2652
-
2653
- # wrapping longitude
2654
- if west <= east:
2655
- cond = cond & (data["longitude"] >= west) & (data["longitude"] <= east)
2656
- else:
2657
- cond = cond & ((data["longitude"] >= west) | (data["longitude"] <= east))
2658
-
2659
2664
  return data.where(cond, drop=True)
2660
2665
 
2661
2666
 
@@ -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
  --------
747
+ - get_source_param
743
748
  - 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
+ - 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):
@@ -1067,9 +1067,17 @@ class VectorDataset:
1067
1067
  if out is not marker:
1068
1068
  return out
1069
1069
 
1070
- out = self.data.get(key, marker)
1071
- if out is not marker:
1072
- vals = np.unique(out)
1070
+ arr: np.ndarray = self.data.get(key, marker) # type: ignore[arg-type]
1071
+ if arr is not marker:
1072
+ try:
1073
+ vals = np.unique(arr)
1074
+ except TypeError:
1075
+ # A TypeError can occur if the arr has object dtype and contains None
1076
+ # Handle this case by returning None
1077
+ if arr.dtype == object and np.all(arr == None): # noqa: E711
1078
+ return None
1079
+ raise
1080
+
1073
1081
  if len(vals) == 1:
1074
1082
  return vals[0]
1075
1083
 
@@ -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
 
@@ -566,9 +567,12 @@ class GOES:
566
567
 
567
568
  def _load_via_tempfile(data: bytes) -> xr.Dataset:
568
569
  """Load xarray dataset via temporary file."""
569
- with tempfile.NamedTemporaryFile(buffering=0) as tmp:
570
+ with tempfile.NamedTemporaryFile(delete=False) as tmp:
570
571
  tmp.write(data)
572
+ try:
571
573
  return xr.load_dataset(tmp.name)
574
+ finally:
575
+ os.remove(tmp.name)
572
576
 
573
577
 
574
578
  def _concat_c02(ds1: XArrayType, ds2: XArrayType) -> XArrayType:
@@ -660,6 +664,10 @@ def to_true_color(da: xr.DataArray, gamma: float = 2.2) -> npt.NDArray[np.float3
660
664
  ----------
661
665
  - `Unidata's true color recipe <https://unidata.github.io/python-gallery/examples/mapping_GOES16_TrueColor.html>`_
662
666
  """
667
+ if not np.all(np.isin([1, 2, 3], da["band_id"])):
668
+ msg = "DataArray must contain bands 1, 2, and 3 for true color"
669
+ raise ValueError(msg)
670
+
663
671
  red = da.sel(band_id=2).values
664
672
  green = da.sel(band_id=3).values
665
673
  blue = da.sel(band_id=1).values
@@ -716,6 +724,9 @@ def to_ash(da: xr.DataArray, convention: str = "SEVIRI") -> npt.NDArray[np.float
716
724
  array([0.0127004 , 0.22793579, 0.3930847 ], dtype=float32)
717
725
  """
718
726
  if convention == "standard":
727
+ if not np.all(np.isin([11, 13, 14, 15], da["band_id"])):
728
+ msg = "DataArray must contain bands 11, 13, 14, and 15 for standard ash"
729
+ raise ValueError(msg)
719
730
  c11 = da.sel(band_id=11).values # 8.44
720
731
  c13 = da.sel(band_id=13).values # 10.33
721
732
  c14 = da.sel(band_id=14).values # 11.19
@@ -725,7 +736,10 @@ def to_ash(da: xr.DataArray, convention: str = "SEVIRI") -> npt.NDArray[np.float
725
736
  green = c14 - c11
726
737
  blue = c13
727
738
 
728
- elif convention in ["SEVIRI", "MIT"]: # retain MIT for backwards compatibility
739
+ elif convention in ("SEVIRI", "MIT"): # retain MIT for backwards compatibility
740
+ if not np.all(np.isin([11, 14, 15], da["band_id"])):
741
+ msg = "DataArray must contain bands 11, 14, and 15 for SEVIRI ash"
742
+ raise ValueError(msg)
729
743
  c11 = da.sel(band_id=11).values # 8.44
730
744
  c14 = da.sel(band_id=14).values # 11.19
731
745
  c15 = da.sel(band_id=15).values # 12.27
@@ -770,3 +784,133 @@ def _clip_and_scale(
770
784
  Clipped and scaled array.
771
785
  """
772
786
  return (arr.clip(low, high) - low) / (high - low)
787
+
788
+
789
+ def parallax_correct(
790
+ longitude: npt.NDArray[np.floating],
791
+ latitude: npt.NDArray[np.floating],
792
+ altitude: npt.NDArray[np.floating],
793
+ goes_da: xr.DataArray,
794
+ ) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]:
795
+ r"""Apply parallax correction to WGS84 geodetic coordinates based on satellite perspective.
796
+
797
+ This function considers the ray from the satellite to the points of interest and finds
798
+ the intersection of this ray with the WGS84 ellipsoid. The intersection point is then
799
+ returned as the corrected longitude and latitude coordinates.
800
+
801
+ ::
802
+
803
+ @ satellite
804
+ \
805
+ \
806
+ \
807
+ \
808
+ \
809
+ * aircraft
810
+ \
811
+ \
812
+ x parallax corrected aircraft
813
+ ------------------------- surface
814
+
815
+ If the point of interest is not visible from the satellite (ie, on the opposite side of the
816
+ earth), the function returns nan for the corrected coordinates.
817
+
818
+ This function requires the :mod:`pyproj` package to be installed.
819
+
820
+ Parameters
821
+ ----------
822
+ longitude : npt.NDArray[np.floating]
823
+ A 1D array of longitudes in degrees.
824
+ latitude : npt.NDArray[np.floating]
825
+ A 1D array of latitudes in degrees.
826
+ altitude : npt.NDArray[np.floating]
827
+ A 1D array of altitudes in meters.
828
+ goes_da : xr.DataArray
829
+ DataArray containing the GOES projection information. Only the ``goes_imager_projection``
830
+ field of the :attr:`xr.DataArray.attrs` is used.
831
+
832
+ Returns
833
+ -------
834
+ tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]
835
+ A tuple containing the corrected longitude and latitude coordinates.
836
+
837
+ """
838
+ goes_imager_projection = goes_da.attrs["goes_imager_projection"]
839
+ sat_lon = goes_imager_projection["longitude_of_projection_origin"]
840
+ sat_lat = goes_imager_projection["latitude_of_projection_origin"]
841
+ sat_alt = goes_imager_projection["perspective_point_height"]
842
+
843
+ try:
844
+ import pyproj
845
+ except ModuleNotFoundError as exc:
846
+ dependencies.raise_module_not_found_error(
847
+ name="parallax_correct function",
848
+ package_name="pyproj",
849
+ module_not_found_error=exc,
850
+ pycontrails_optional_package="pyproj",
851
+ )
852
+
853
+ # Convert from WGS84 to ECEF coordinates
854
+ ecef_crs = pyproj.CRS("EPSG:4978")
855
+ transformer = pyproj.Transformer.from_crs("WGS84", ecef_crs, always_xy=True)
856
+
857
+ p0 = np.array(transformer.transform([sat_lon], [sat_lat], [sat_alt]))
858
+ p1 = np.array(transformer.transform(longitude, latitude, altitude))
859
+
860
+ # Major and minor axes of the ellipsoid
861
+ a = ecef_crs.ellipsoid.semi_major_metre # type: ignore[union-attr]
862
+ b = ecef_crs.ellipsoid.semi_minor_metre # type: ignore[union-attr]
863
+ intersection = _intersection_with_ellipsoid(p0, p1, a, b)
864
+
865
+ # Convert back to WGS84 coordinates
866
+ inv_transformer = pyproj.Transformer.from_crs(ecef_crs, "WGS84", always_xy=True)
867
+ return inv_transformer.transform(*intersection)[:2] # final coord is (close to) 0
868
+
869
+
870
+ def _intersection_with_ellipsoid(
871
+ p0: npt.NDArray[np.floating],
872
+ p1: npt.NDArray[np.floating],
873
+ a: float,
874
+ b: float,
875
+ ) -> npt.NDArray[np.floating]:
876
+ """Find the intersection of a line with the surface of an ellipsoid."""
877
+ # Calculate the direction vector
878
+ px, py, pz = p0
879
+ v = p1 - p0
880
+ vx, vy, vz = v
881
+
882
+ # The line between p0 and p1 in parametric form is p(t) = p0 + t * v
883
+ # We need to find t such that p(t) lies on the ellipsoid
884
+ # x^2 / a^2 + y^2 / a^2 + z^2 / b^2 = 1
885
+ # (px + t * vx)^2 / a^2 + (py + t * vy)^2 / a^2 + (pz + t * vz)^2 / b^2 = 1
886
+ # Rearranging gives a quadratic in t
887
+
888
+ # Calculate the coefficients of this quadratic equation
889
+ A = vx**2 / a**2 + vy**2 / a**2 + vz**2 / b**2
890
+ B = 2 * (px * vx / a**2 + py * vy / a**2 + pz * vz / b**2)
891
+ C = px**2 / a**2 + py**2 / a**2 + pz**2 / b**2 - 1.0
892
+
893
+ # Calculate the discriminant
894
+ D = B**2 - 4 * A * C
895
+ sqrtD = np.sqrt(D, where=D >= 0, out=np.full_like(D, np.nan))
896
+
897
+ # Calculate the two possible solutions for t
898
+ t0 = (-B + sqrtD) / (2.0 * A)
899
+ t1 = (-B - sqrtD) / (2.0 * A)
900
+
901
+ # Calculate the intersection points
902
+ intersection0 = p0 + t0 * v
903
+ intersection1 = p0 + t1 * v
904
+
905
+ # Pick the intersection point that is closer to the aircraft (p1)
906
+ d0 = np.linalg.norm(intersection0 - p1, axis=0)
907
+ d1 = np.linalg.norm(intersection1 - p1, axis=0)
908
+ out = np.where(d0 < d1, intersection0, intersection1)
909
+
910
+ # Fill the points in which the aircraft is not visible by the satellite with nan
911
+ # This occurs when the earth is between the satellite and the aircraft
912
+ # In other words, we can check for t0 < 1 (or t1 < 1)
913
+ opposite_side = t0 < 1.0
914
+ out[:, opposite_side] = np.nan
915
+
916
+ return out
@@ -75,7 +75,7 @@ class ValidateTrajectoryHandler:
75
75
  <LINK HERE TO HOSTED REFERENCE EXAMPLE(S)>.
76
76
  """
77
77
 
78
- CRUISE_ROCD_THRESHOLD_FPS = 4.2 # 4.2 ft/sec ~= 250 ft/min
78
+ ROCD_THRESHOLD_FPS = 83.25 # 83.25 ft/sec ~= 5000 ft/min
79
79
  CRUISE_LOW_ALTITUDE_THRESHOLD_FT = 15000.0 # lowest expected cruise altitude
80
80
  INSTANTANEOUS_HIGH_GROUND_SPEED_THRESHOLD_MPS = 350.0 # 350m/sec ~= 780mph ~= 1260kph
81
81
  INSTANTANEOUS_LOW_GROUND_SPEED_THRESHOLD_MPS = 45.0 # 45m/sec ~= 100mph ~= 160kph
@@ -473,12 +473,12 @@ class ValidateTrajectoryHandler:
473
473
  Evaluate flight altitude profile.
474
474
 
475
475
  Failure modes include:
476
- RocdError
476
+ FlightAltitudeProfileError
477
477
  1) flight climbs above alt threshold,
478
478
  then descends below that threshold one or more times,
479
479
  before making final descent to land.
480
480
 
481
- FlightAltitudeProfileError
481
+ RocdError
482
482
  2) rate of instantaneous (between consecutive waypoint) climb or descent is above threshold,
483
483
  while aircraft is above the cruise altitude.
484
484
  """
@@ -488,15 +488,13 @@ class ValidateTrajectoryHandler:
488
488
 
489
489
  violations: list[FlightAltitudeProfileError | ROCDError] = []
490
490
 
491
- # only evaluate rocd errors when at cruising altitude
492
- rocd_above_thres = (self._df["rocd_fps"].abs() >= self.CRUISE_ROCD_THRESHOLD_FPS) & (
493
- self._df["altitude_baro"] >= self.CRUISE_LOW_ALTITUDE_THRESHOLD_FT
494
- )
491
+ # evaluate ROCD
492
+ rocd_above_thres = self._df["rocd_fps"].abs() >= self.ROCD_THRESHOLD_FPS
495
493
  if rocd_above_thres.any():
496
494
  msg = (
497
495
  "Flight trajectory has rate of climb/descent values "
498
496
  "between consecutive waypoints that exceed threshold "
499
- f"of {self.CRUISE_ROCD_THRESHOLD_FPS:.3f}ft/sec. "
497
+ f"of {self.ROCD_THRESHOLD_FPS:.3f}ft/sec. "
500
498
  f"Max value found: {self._df['rocd_fps'].abs().max():.3f}ft/sec"
501
499
  )
502
500
  violations.append(ROCDError(msg))
@@ -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.