pycontrails 0.41.0__cp39-cp39-macosx_11_0_arm64.whl → 0.42.2__cp39-cp39-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 (40) hide show
  1. pycontrails/_version.py +2 -2
  2. pycontrails/core/airports.py +228 -0
  3. pycontrails/core/cache.py +4 -6
  4. pycontrails/core/datalib.py +13 -6
  5. pycontrails/core/fleet.py +72 -20
  6. pycontrails/core/flight.py +485 -134
  7. pycontrails/core/flightplan.py +238 -0
  8. pycontrails/core/interpolation.py +11 -15
  9. pycontrails/core/met.py +5 -5
  10. pycontrails/core/models.py +4 -0
  11. pycontrails/core/rgi_cython.cpython-39-darwin.so +0 -0
  12. pycontrails/core/vector.py +80 -63
  13. pycontrails/datalib/__init__.py +1 -1
  14. pycontrails/datalib/ecmwf/common.py +14 -19
  15. pycontrails/datalib/spire/__init__.py +19 -0
  16. pycontrails/datalib/spire/spire.py +739 -0
  17. pycontrails/ext/bada/__init__.py +6 -6
  18. pycontrails/ext/cirium/__init__.py +2 -2
  19. pycontrails/models/cocip/cocip.py +37 -39
  20. pycontrails/models/cocip/cocip_params.py +37 -30
  21. pycontrails/models/cocip/cocip_uncertainty.py +47 -58
  22. pycontrails/models/cocip/radiative_forcing.py +220 -193
  23. pycontrails/models/cocip/wake_vortex.py +96 -91
  24. pycontrails/models/cocip/wind_shear.py +2 -2
  25. pycontrails/models/emissions/emissions.py +1 -1
  26. pycontrails/models/humidity_scaling.py +266 -9
  27. pycontrails/models/issr.py +2 -2
  28. pycontrails/models/pcr.py +1 -1
  29. pycontrails/models/quantiles/era5_ensemble_quantiles.npy +0 -0
  30. pycontrails/models/quantiles/iagos_quantiles.npy +0 -0
  31. pycontrails/models/sac.py +7 -5
  32. pycontrails/physics/geo.py +5 -3
  33. pycontrails/physics/jet.py +66 -113
  34. pycontrails/utils/json.py +3 -3
  35. {pycontrails-0.41.0.dist-info → pycontrails-0.42.2.dist-info}/METADATA +4 -7
  36. {pycontrails-0.41.0.dist-info → pycontrails-0.42.2.dist-info}/RECORD +40 -34
  37. {pycontrails-0.41.0.dist-info → pycontrails-0.42.2.dist-info}/LICENSE +0 -0
  38. {pycontrails-0.41.0.dist-info → pycontrails-0.42.2.dist-info}/NOTICE +0 -0
  39. {pycontrails-0.41.0.dist-info → pycontrails-0.42.2.dist-info}/WHEEL +0 -0
  40. {pycontrails-0.41.0.dist-info → pycontrails-0.42.2.dist-info}/top_level.txt +0 -0
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import dataclasses
6
+ import enum
6
7
  import logging
7
8
  import warnings
8
9
  from typing import TYPE_CHECKING, Any
@@ -26,6 +27,31 @@ if TYPE_CHECKING:
26
27
  import traffic
27
28
 
28
29
 
30
+ class FlightPhase(enum.IntEnum):
31
+ """Flight phase enumeration."""
32
+
33
+ CLIMB = enum.auto()
34
+ CRUISE = enum.auto()
35
+ DESCENT = enum.auto()
36
+ LEVEL_FLIGHT = enum.auto()
37
+ NAN = enum.auto()
38
+
39
+
40
+ #: Max airport elevation, [:math:`ft`]
41
+ #: See `Daocheng_Yading_Airport <https://en.wikipedia.org/wiki/Daocheng_Yading_Airport>`_
42
+ MAX_AIRPORT_ELEVATION = 15_000.0
43
+
44
+ #: Min estimated cruise altitude, [:math:`ft`]
45
+ MIN_CRUISE_ALTITUDE = 20_000.0
46
+
47
+ #: Short haul duration cutoff, [:math:`s`]
48
+ SHORT_HAUL_DURATION = 3600.0
49
+
50
+ #: Set maximum speed compatible with "on_ground" indicator, [:math:`mph`]
51
+ #: Thresholds assessed based on scatter plot (150 knots = 278 km/h)
52
+ MAX_ON_GROUND_SPEED = 150.0
53
+
54
+
29
55
  @dataclasses.dataclass
30
56
  class Aircraft:
31
57
  """Base class for the physical parameters of the aircraft."""
@@ -52,51 +78,12 @@ class Aircraft:
52
78
  max_altitude: float | None = None
53
79
 
54
80
 
55
- @dataclasses.dataclass
56
- class FlightPhase:
57
- """Container for boolean arrays describing phase of the flight.
58
-
59
- Each array is expected to have the same shape. Arrays should be pairwise disjoint and cover
60
- each waypoint (for each waypoint, exactly one of the four arrays should be True.)
61
-
62
- .. todo::
63
-
64
- Refactor to include this data as property in :class:`Flight` with enum for FlightPhase
65
- """
66
-
67
- cruise: np.ndarray
68
- climb: np.ndarray
69
- descent: np.ndarray
70
- nan: np.ndarray
71
-
72
- @classmethod
73
- def all_cruise(cls, size: int) -> FlightPhase:
74
- """Generate `FlightPhase` instance in which all waypoints are at cruise.
75
-
76
- Parameters
77
- ----------
78
- size : int
79
- Number of waypoints
80
-
81
- Returns
82
- -------
83
- FlightPhase
84
- All waypoints are given a cruise state..
85
- """
86
- return cls(
87
- cruise=np.ones(size).astype(bool),
88
- climb=np.zeros(size).astype(bool),
89
- descent=np.zeros(size).astype(bool),
90
- nan=np.zeros(size).astype(bool),
91
- )
92
-
93
-
94
81
  class Flight(GeoVectorDataset):
95
82
  """A single flight trajectory.
96
83
 
97
- Expect latitude-longitude CRS in WGS 84.
84
+ Expect latitude-longitude coordinates in WGS 84.
98
85
  Expect altitude in [:math:`m`].
99
- Expect level in [:math:`hPa`].
86
+ Expect pressure level (`level`) in [:math:`hPa`].
100
87
 
101
88
  Use the attribute :attr:`attrs["crs"]` to specify coordinate reference system
102
89
  using `PROJ <https://proj.org/>`_ or `EPSG <https://epsg.org/home.html>`_ syntax.
@@ -110,19 +97,19 @@ class Flight(GeoVectorDataset):
110
97
  will override ``data`` inputs. Expects ``altitude`` in meters and ``time`` as a
111
98
  DatetimeLike (or array that can processed with :func:`pd.to_datetime`).
112
99
  Additional waypoint-specific data can be included as additional keys/columns.
113
- longitude : np.ndarray, optional
100
+ longitude : npt.ArrayLike, optional
114
101
  Flight trajectory waypoint longitude.
115
102
  Defaults to None.
116
- latitude : np.ndarray, optional
103
+ latitude : npt.ArrayLike, optional
117
104
  Flight trajectory waypoint latitude.
118
105
  Defaults to None.
119
- altitude : np.ndarray, optional
106
+ altitude : npt.ArrayLike, optional
120
107
  Flight trajectory waypoint altitude, [:math:`m`].
121
108
  Defaults to None.
122
- level : np.ndarray, optional
109
+ level : npt.ArrayLike, optional
123
110
  Flight trajectory waypoint pressure level, [:math:`hPa`].
124
111
  Defaults to None.
125
- time : np.ndarray, optional
112
+ time : npt.ArrayLike, optional
126
113
  Flight trajectory waypoint time.
127
114
  Defaults to None.
128
115
  attrs : dict[str, Any], optional
@@ -149,6 +136,12 @@ class Flight(GeoVectorDataset):
149
136
  Raises if ``data`` input does not contain at least ``time``, ``latitude``, ``longitude``,
150
137
  (``altitude`` or ``level``).
151
138
 
139
+ Notes
140
+ -----
141
+ The `Traffic <https://traffic-viz.github.io/index.html>`_ library has many helpful
142
+ flight processing utilities.
143
+
144
+ See :class:`traffic.core.Flight` for more information.
152
145
 
153
146
  Examples
154
147
  --------
@@ -207,11 +200,15 @@ class Flight(GeoVectorDataset):
207
200
 
208
201
  def __init__(
209
202
  self,
210
- data: dict[str, np.ndarray] | pd.DataFrame | VectorDataDict | VectorDataset | None = None,
211
- longitude: np.ndarray | None = None,
212
- latitude: np.ndarray | None = None,
213
- altitude: np.ndarray | None = None,
214
- time: np.ndarray | None = None,
203
+ data: dict[str, npt.ArrayLike]
204
+ | pd.DataFrame
205
+ | VectorDataDict
206
+ | VectorDataset
207
+ | None = None,
208
+ longitude: npt.ArrayLike | None = None,
209
+ latitude: npt.ArrayLike | None = None,
210
+ altitude: npt.ArrayLike | None = None,
211
+ time: npt.ArrayLike | None = None,
215
212
  attrs: dict[str, Any] | AttrDict | None = None,
216
213
  copy: bool = True,
217
214
  aircraft: Aircraft | None = None,
@@ -245,7 +242,7 @@ class Flight(GeoVectorDataset):
245
242
  self.fuel = fuel or JetA()
246
243
 
247
244
  # Check flight data for possible errors
248
- if np.any(self.altitude > 16000):
245
+ if np.any(self.altitude > 16000.0):
249
246
  flight_id = self.attrs.get("flight_id", "")
250
247
  flight_id = flight_id and f" for flight {flight_id}"
251
248
  warnings.warn(
@@ -260,35 +257,36 @@ class Flight(GeoVectorDataset):
260
257
  "with segment-based methods (e.g. 'segment_true_airspeed')."
261
258
  )
262
259
 
263
- diff_ = np.diff(self["time"])
260
+ time_diff = np.diff(self["time"])
264
261
 
265
262
  # Ensure that time is sorted
266
- if self and np.any(diff_ < np.timedelta64(0)):
263
+ if self and np.any(time_diff < np.timedelta64(0)):
267
264
  if not copy:
268
265
  raise ValueError(
269
- "`time` data must be sorted if `copy` is False on creation. "
266
+ "The 'time' array must be sorted if 'copy=False' on creation. "
270
267
  "Set copy=False, or sort data before creating Flight."
271
268
  )
272
- warnings.warn("Sorting Flight data by `time`.")
269
+ warnings.warn("Sorting Flight data by time.")
273
270
 
274
271
  sorted_flight = self.sort("time")
275
272
  self.data = sorted_flight.data
276
- # Update diff_ ... we use it again below
277
- diff_ = np.diff(self["time"])
273
+
274
+ # Update time_diff ... we use it again below
275
+ time_diff = np.diff(self["time"])
278
276
 
279
277
  # Check for duplicate times. If dropping duplicates,
280
- # keep the *first* occurrence of each time. This is achieved with
281
- # the np.insert (rather than np.append) function.
282
- filt = np.insert(diff_ > np.timedelta64(0), 0, True)
283
- if not np.all(filt):
278
+ # keep the *first* occurrence of each time.
279
+ duplicated_times = time_diff == np.timedelta64(0)
280
+ if self and np.any(duplicated_times):
284
281
  if drop_duplicated_times:
285
- filtered_flight = self.filter(filt, copy=False)
282
+ mask = np.insert(duplicated_times, 0, False)
283
+ filtered_flight = self.filter(~mask, copy=False)
286
284
  self.data = filtered_flight.data
287
285
  else:
288
286
  warnings.warn(
289
- "Flight contains duplicate times. This will cause errors "
290
- "with segment-based methods (e.g. 'segment_true_airspeed'). Set "
291
- "'drop_duplicated=True' or call the 'resample_and_fill' method."
287
+ f"Flight contains {duplicated_times.sum()} duplicate times. "
288
+ "This will cause errors with segment-based methods. Set "
289
+ "'drop_duplicated_times=True' or call the 'resample_and_fill' method."
292
290
  )
293
291
 
294
292
  @overrides
@@ -420,7 +418,7 @@ class Flight(GeoVectorDataset):
420
418
  # Segment Properties
421
419
  # ------------
422
420
 
423
- def segment_duration(self, dtype: np.dtype = np.dtype(np.float32)) -> np.ndarray:
421
+ def segment_duration(self, dtype: npt.DTypeLike = np.float32) -> npt.NDArray[np.float_]:
424
422
  r"""Compute time elapsed between waypoints in seconds.
425
423
 
426
424
  ``np.nan`` appended so the length of the output is the same as number of waypoints.
@@ -433,14 +431,14 @@ class Flight(GeoVectorDataset):
433
431
 
434
432
  Returns
435
433
  -------
436
- np.ndarray
434
+ npt.NDArray[np.float_]
437
435
  Time difference between waypoints, [:math:`s`].
438
436
  Returns an array with dtype specified by``dtype``
439
437
  """
440
438
 
441
- return _dt_waypoints(self.data["time"], dtype=dtype)
439
+ return segment_duration(self.data["time"], dtype=dtype)
442
440
 
443
- def segment_length(self) -> np.ndarray:
441
+ def segment_length(self) -> npt.NDArray[np.float_]:
444
442
  """Compute spherical distance between flight waypoints.
445
443
 
446
444
  Helper function used in :meth:`length` and :meth:`length_met`.
@@ -448,7 +446,7 @@ class Flight(GeoVectorDataset):
448
446
 
449
447
  Returns
450
448
  -------
451
- np.ndarray
449
+ npt.NDArray[np.float_]
452
450
  Array of distances in [:math:`m`] between waypoints
453
451
 
454
452
  Raises
@@ -478,7 +476,7 @@ class Flight(GeoVectorDataset):
478
476
 
479
477
  return geo.segment_length(self["longitude"], self["latitude"], self.altitude)
480
478
 
481
- def segment_angle(self) -> tuple[np.ndarray, np.ndarray]:
479
+ def segment_angle(self) -> tuple[npt.NDArray[np.float_], npt.NDArray[np.float_]]:
482
480
  """Calculate sin/cos for the angle between each segment and the longitudinal axis.
483
481
 
484
482
  This is different from the usual navigational angle between two points known as *bearing*.
@@ -498,7 +496,7 @@ class Flight(GeoVectorDataset):
498
496
 
499
497
  Returns
500
498
  -------
501
- np.ndarray, np.ndarray
499
+ npt.NDArray[np.float_], npt.NDArray[np.float_]
502
500
  sin(a), cos(a), where ``a`` is the angle between the segment and the longitudinal axis
503
501
  The final values are of both arrays are ``np.nan``.
504
502
 
@@ -528,7 +526,7 @@ class Flight(GeoVectorDataset):
528
526
  """
529
527
  return geo.segment_angle(self["longitude"], self["latitude"])
530
528
 
531
- def segment_azimuth(self) -> np.ndarray:
529
+ def segment_azimuth(self) -> npt.NDArray[np.float_]:
532
530
  """Calculate (forward) azimuth at each waypoint.
533
531
 
534
532
  Method calls `pyproj.Geod.inv`, which is slow. See `geo.forward_azimuth`
@@ -540,7 +538,7 @@ class Flight(GeoVectorDataset):
540
538
 
541
539
  Returns
542
540
  -------
543
- np.ndarray
541
+ npt.NDArray[np.float_]
544
542
  Array of azimuths.
545
543
  """
546
544
  lon = self["longitude"]
@@ -564,7 +562,7 @@ class Flight(GeoVectorDataset):
564
562
 
565
563
  def segment_groundspeed(
566
564
  self, smooth: bool = False, window_length: int = 7, polyorder: int = 1
567
- ) -> np.ndarray:
565
+ ) -> npt.NDArray[np.float_]:
568
566
  """Return groundspeed across segments.
569
567
 
570
568
  Calculate by dividing the horizontal segment length by the difference in waypoint times.
@@ -581,16 +579,14 @@ class Flight(GeoVectorDataset):
581
579
 
582
580
  Returns
583
581
  -------
584
- np.ndarray
582
+ npt.NDArray[np.float_]
585
583
  Groundspeed of the segment, [:math:`m s^{-1}`]
586
584
  """
587
- # get horizontal distance - set altitude to 0
585
+ # get horizontal distance (altitude is ignored)
588
586
  horizontal_segment_length = geo.segment_haversine(self["longitude"], self["latitude"])
589
587
 
590
588
  # time between waypoints, in seconds
591
- dt_sec = np.empty_like(horizontal_segment_length)
592
- dt_sec[:-1] = np.diff(self["time"]) / np.timedelta64(1, "s")
593
- dt_sec[-1] = np.nan
589
+ dt_sec = self.segment_duration(dtype=horizontal_segment_length.dtype)
594
590
 
595
591
  # calculate groundspeed
596
592
  groundspeed = horizontal_segment_length / dt_sec
@@ -605,8 +601,8 @@ class Flight(GeoVectorDataset):
605
601
 
606
602
  def segment_true_airspeed(
607
603
  self,
608
- u_wind: npt.NDArray[np.float_] | None = None,
609
- v_wind: npt.NDArray[np.float_] | None = None,
604
+ u_wind: npt.NDArray[np.float_] | float = 0.0,
605
+ v_wind: npt.NDArray[np.float_] | float = 0.0,
610
606
  smooth: bool = True,
611
607
  window_length: int = 7,
612
608
  polyorder: int = 1,
@@ -617,10 +613,10 @@ class Flight(GeoVectorDataset):
617
613
 
618
614
  Parameters
619
615
  ----------
620
- u_wind : npt.NDArray[np.float_], optional
616
+ u_wind : npt.NDArray[np.float_] | float
621
617
  U wind speed, [:math:`m \ s^{-1}`].
622
618
  Defaults to 0 for all waypoints.
623
- v_wind : npt.NDArray[np.float_], optional
619
+ v_wind : npt.NDArray[np.float_] | float
624
620
  V wind speed, [:math:`m \ s^{-1}`].
625
621
  Defaults to 0 for all waypoints.
626
622
  smooth : bool, optional
@@ -638,12 +634,6 @@ class Flight(GeoVectorDataset):
638
634
  """
639
635
  groundspeed = self.segment_groundspeed(smooth, window_length, polyorder)
640
636
 
641
- if u_wind is None:
642
- u_wind = np.zeros_like(groundspeed)
643
-
644
- if v_wind is None:
645
- v_wind = np.zeros_like(groundspeed)
646
-
647
637
  sin_a, cos_a = self.segment_angle()
648
638
  gs_x = groundspeed * cos_a
649
639
  gs_y = groundspeed * sin_a
@@ -653,25 +643,76 @@ class Flight(GeoVectorDataset):
653
643
  return np.sqrt(tas_x * tas_x + tas_y * tas_y)
654
644
 
655
645
  def segment_mach_number(
656
- self, true_airspeed: np.ndarray, air_temperature: np.ndarray
657
- ) -> np.ndarray:
646
+ self, true_airspeed: npt.NDArray[np.float_], air_temperature: npt.NDArray[np.float_]
647
+ ) -> npt.NDArray[np.float_]:
658
648
  r"""Calculate the mach number of each segment.
659
649
 
660
650
  Parameters
661
651
  ----------
662
- true_airspeed : np.ndarray
652
+ true_airspeed : npt.NDArray[np.float_]
663
653
  True airspeed of the segment, [:math:`m \ s^{-1}`].
664
654
  See :meth:`segment_true_airspeed`.
665
- air_temperature : np.ndarray
655
+ air_temperature : npt.NDArray[np.float_]
666
656
  Average air temperature of each segment, [:math:`K`]
667
657
 
668
658
  Returns
669
659
  -------
670
- np.ndarray
660
+ npt.NDArray[np.float_]
671
661
  Mach number of each segment
672
662
  """
673
663
  return units.tas_to_mach_number(true_airspeed, air_temperature)
674
664
 
665
+ def segment_rocd(self) -> npt.NDArray[np.float_]:
666
+ """Calculate the rate of climb and descent (ROCD).
667
+
668
+ Returns
669
+ -------
670
+ npt.NDArray[np.float_]
671
+ Rate of climb and descent over segment, [:math:`ft min^{-1}`]
672
+
673
+ See Also
674
+ --------
675
+ :func:`segment_rocd`
676
+ """
677
+ return segment_rocd(self.segment_duration(), self.altitude_ft)
678
+
679
+ def segment_phase(
680
+ self,
681
+ threshold_rocd: float = 250.0,
682
+ min_cruise_altitude_ft: float = 20000.0,
683
+ ) -> npt.NDArray[np.uint8]:
684
+ """Identify the phase of flight (climb, cruise, descent) for each segment.
685
+
686
+ Parameters
687
+ ----------
688
+ threshold_rocd : float, optional
689
+ ROCD threshold to identify climb and descent, [:math:`ft min^{-1}`].
690
+ Currently set to 250 ft/min.
691
+ min_cruise_altitude_ft : float, optional
692
+ Minimum altitude for cruise, [:math:`ft`]
693
+ This is specific for each aircraft type,
694
+ and can be approximated as 50% of the altitude ceiling.
695
+ Defaults to 20000 ft.
696
+
697
+ Returns
698
+ -------
699
+ npt.NDArray[np.uint8]
700
+ Array of values enumerating the flight phase.
701
+ See :attr:`flight.FlightPhase` for enumeration.
702
+
703
+ See Also
704
+ --------
705
+ :attr:`FlightPhase`
706
+ :func:`segment_phase`
707
+ :func:`segment_rocd`
708
+ """
709
+ return segment_phase(
710
+ self.segment_rocd(),
711
+ self.altitude_ft,
712
+ threshold_rocd=threshold_rocd,
713
+ min_cruise_altitude_ft=min_cruise_altitude_ft,
714
+ )
715
+
675
716
  # ------------
676
717
  # Filter/Resample
677
718
  # ------------
@@ -772,20 +813,20 @@ class Flight(GeoVectorDataset):
772
813
  2 50.0 0.0 0.0 2020-01-01 02:00:00
773
814
 
774
815
  >>> fl.resample_and_fill('10T').dataframe # resample with 10 minute frequency
775
- time longitude latitude altitude
776
- 0 2020-01-01 00:00:00 0.000000 0.0 0.0
777
- 1 2020-01-01 00:10:00 0.000000 0.0 0.0
778
- 2 2020-01-01 00:20:00 0.000000 0.0 0.0
779
- 3 2020-01-01 00:30:00 0.000000 0.0 0.0
780
- 4 2020-01-01 00:40:00 0.000000 0.0 0.0
781
- 5 2020-01-01 00:50:00 0.000000 0.0 0.0
782
- 6 2020-01-01 01:00:00 0.000000 0.0 0.0
783
- 7 2020-01-01 01:10:00 8.928571 0.0 0.0
784
- 8 2020-01-01 01:20:00 16.964286 0.0 0.0
785
- 9 2020-01-01 01:30:00 25.892857 0.0 0.0
786
- 10 2020-01-01 01:40:00 33.928571 0.0 0.0
787
- 11 2020-01-01 01:50:00 41.964286 0.0 0.0
788
- 12 2020-01-01 02:00:00 50.000000 0.0 0.0
816
+ longitude latitude altitude time
817
+ 0 0.000000 0.0 0.0 2020-01-01 00:00:00
818
+ 1 0.000000 0.0 0.0 2020-01-01 00:10:00
819
+ 2 0.000000 0.0 0.0 2020-01-01 00:20:00
820
+ 3 0.000000 0.0 0.0 2020-01-01 00:30:00
821
+ 4 0.000000 0.0 0.0 2020-01-01 00:40:00
822
+ 5 0.000000 0.0 0.0 2020-01-01 00:50:00
823
+ 6 0.000000 0.0 0.0 2020-01-01 01:00:00
824
+ 7 8.928571 0.0 0.0 2020-01-01 01:10:00
825
+ 8 16.964286 0.0 0.0 2020-01-01 01:20:00
826
+ 9 25.892857 0.0 0.0 2020-01-01 01:30:00
827
+ 10 33.928571 0.0 0.0 2020-01-01 01:40:00
828
+ 11 41.964286 0.0 0.0 2020-01-01 01:50:00
829
+ 12 50.000000 0.0 0.0 2020-01-01 02:00:00
789
830
  """
790
831
  methods = "geodesic", "linear"
791
832
  if fill_method not in methods:
@@ -840,7 +881,7 @@ class Flight(GeoVectorDataset):
840
881
  # STEP 5: Resample flight to freq
841
882
  df = df.resample(freq).first()
842
883
 
843
- # STEP 6: Linearly interprolate small horizontal gaps and account
884
+ # STEP 6: Linearly interpolate small horizontal gaps and account
844
885
  # for previous longitude shift.
845
886
  keys = ["latitude", "longitude"]
846
887
  df.loc[:, keys] = df.loc[:, keys].interpolate(method="linear")
@@ -860,6 +901,65 @@ class Flight(GeoVectorDataset):
860
901
  df = df.reset_index()
861
902
  return Flight(data=df, attrs=self.attrs)
862
903
 
904
+ def fit_altitude(
905
+ self,
906
+ max_segments: int = 30,
907
+ pop: int = 3,
908
+ r2_target: float = 0.999,
909
+ max_cruise_rocd: float = 10.0,
910
+ sg_window: int = 7,
911
+ sg_polyorder: int = 1,
912
+ ) -> Flight:
913
+ """Use piecewise linear fitting to smooth a flight profile.
914
+
915
+ Fit a flight profile to a series of line segments. Segments that have a
916
+ small rocd will be set to have a slope of zero and snapped to the
917
+ nearest thousand foot level. A Savitzky-Golay filter will then be
918
+ applied to the profile to smooth the climbs and descents. This filter
919
+ works best for high frequency flight data, sampled at a 1-3 second
920
+ sampling period.
921
+
922
+ Parameters
923
+ ----------
924
+ max_segments : int, optional
925
+ The maximum number of line segements to fit to the flight profile.
926
+ pop: int, optional
927
+ Population parameter used for the stocastic optimization routine
928
+ used to fit the flight profile.
929
+ r2_target: float, optional
930
+ Target r^2 value for solver. Solver will continue to add line
931
+ segments until the resulting r^2 value is greater than this.
932
+ max_cruise_rocd: float, optional
933
+ The maximum ROCD for a segment that will be forced to a slope of
934
+ zero, [:math:`ft s^{-1}`]
935
+ sg_window: int, optional
936
+ Parameter for :func:`scipy.signal.savgol_filter`
937
+ sg_polyorder: int, optional
938
+ Parameter for :func:`scipy.signal.savgol_filter`
939
+
940
+ Returns
941
+ -------
942
+ Flight
943
+ Smoothed flight
944
+ """
945
+ # np.roll pushes the last NaN value from `segment_duration` to the front
946
+ # so the elapsed time at the first waypoint will be 0
947
+ seg_dur = self.segment_duration(dtype=np.float64)
948
+ elapsed_time = np.nancumsum(np.roll(seg_dur, 1))
949
+ alt_ft = fit_altitude(
950
+ elapsed_time,
951
+ np.copy(self.altitude_ft),
952
+ max_segments,
953
+ pop,
954
+ r2_target,
955
+ max_cruise_rocd,
956
+ sg_window,
957
+ )
958
+
959
+ flight = self.copy()
960
+ flight.update(altitude_ft=alt_ft)
961
+ return flight
962
+
863
963
  def _geodesic_interpolation(self, geodesic_threshold: float) -> pd.DataFrame | None:
864
964
  """Geodesic interpolate between large gaps between waypoints.
865
965
 
@@ -1075,7 +1175,7 @@ class Flight(GeoVectorDataset):
1075
1175
  >>> variables = ["air_temperature", "specific_humidity"]
1076
1176
  >>> levels = [300, 250, 200]
1077
1177
  >>> era5 = ERA5(time=times, variables=variables, pressure_levels=levels)
1078
- >>> met = era5.open_metdataset(xr_kwargs=dict(parallel=False))
1178
+ >>> met = era5.open_metdataset()
1079
1179
 
1080
1180
  >>> # Build flight
1081
1181
  >>> df = pd.DataFrame()
@@ -1169,12 +1269,12 @@ class Flight(GeoVectorDataset):
1169
1269
  return ax
1170
1270
 
1171
1271
 
1172
- def _return_linestring(data: dict[str, np.ndarray]) -> list[list[float]]:
1272
+ def _return_linestring(data: dict[str, npt.NDArray[np.float_]]) -> list[list[float]]:
1173
1273
  """Return list of coordinates for geojson constructions.
1174
1274
 
1175
1275
  Parameters
1176
1276
  ----------
1177
- data : dict[str, np.ndarray]
1277
+ data : dict[str, npt.NDArray[np.float_]]
1178
1278
  :attr:`data` containing `longitude`, `latitude`, and `altitude` keys
1179
1279
 
1180
1280
  Returns
@@ -1184,9 +1284,9 @@ def _return_linestring(data: dict[str, np.ndarray]) -> list[list[float]]:
1184
1284
  """
1185
1285
  # rounding to reduce the size of resultant json arrays
1186
1286
  points = zip( # pylint: disable=zip-builtin-not-iterating
1187
- np.around(data["longitude"], decimals=4),
1188
- np.around(data["latitude"], decimals=4),
1189
- np.around(data["altitude"], decimals=4),
1287
+ np.round(data["longitude"], decimals=4),
1288
+ np.round(data["latitude"], decimals=4),
1289
+ np.round(data["altitude"], decimals=4),
1190
1290
  )
1191
1291
  return [list(p) for p in points]
1192
1292
 
@@ -1248,16 +1348,16 @@ def _antimeridian_index(longitude: pd.Series, crs: str = "EPSG:4326") -> int:
1248
1348
  return -1
1249
1349
 
1250
1350
 
1251
- def _sg_filter(vals: np.ndarray, window_length: int = 7, polyorder: int = 1) -> np.ndarray:
1351
+ def _sg_filter(
1352
+ vals: npt.NDArray[np.float_], window_length: int = 7, polyorder: int = 1
1353
+ ) -> npt.NDArray[np.float_]:
1252
1354
  """Apply Savitzky-Golay filter to smooth out noise in the time-series data.
1253
1355
 
1254
- Used to smooth true airspeed & fuel flow.
1255
-
1256
- TODO: move to a more centralized location in the codebase if used again
1356
+ Used to smooth true airspeed, fuel flow, and altitude.
1257
1357
 
1258
1358
  Parameters
1259
1359
  ----------
1260
- vals : np.ndarray
1360
+ vals : npt.NDArray[np.float_]
1261
1361
  Input array
1262
1362
  window_length : int, optional
1263
1363
  Parameter for :func:`scipy.signal.savgol_filter`
@@ -1266,7 +1366,7 @@ def _sg_filter(vals: np.ndarray, window_length: int = 7, polyorder: int = 1) ->
1266
1366
 
1267
1367
  Returns
1268
1368
  -------
1269
- np.ndarray
1369
+ npt.NDArray[np.float_]
1270
1370
  Smoothed values
1271
1371
 
1272
1372
  Raises
@@ -1292,8 +1392,8 @@ def _sg_filter(vals: np.ndarray, window_length: int = 7, polyorder: int = 1) ->
1292
1392
 
1293
1393
 
1294
1394
  def _altitude_interpolation(
1295
- altitude: np.ndarray, nominal_rocd: float, freq: np.timedelta64
1296
- ) -> np.ndarray:
1395
+ altitude: npt.NDArray[np.float_], nominal_rocd: float, freq: np.timedelta64
1396
+ ) -> npt.NDArray[np.float_]:
1297
1397
  """Interpolate nan values in `altitude` array.
1298
1398
 
1299
1399
  Suppose each group of consecutive nan values is enclosed by `a0` and `a1` with
@@ -1304,7 +1404,7 @@ def _altitude_interpolation(
1304
1404
 
1305
1405
  Parameters
1306
1406
  ----------
1307
- altitude : np.ndarray
1407
+ altitude : npt.NDArray[np.float_]
1308
1408
  Array of altitude values containing nan values. This function will raise
1309
1409
  an error if `altitude` does not contain nan values. Moreover, this function
1310
1410
  assumes the initial and final entries in `altitude` are not nan.
@@ -1315,7 +1415,7 @@ def _altitude_interpolation(
1315
1415
 
1316
1416
  Returns
1317
1417
  -------
1318
- np.ndarray
1418
+ npt.NDArray[np.float_]
1319
1419
  Altitude after nan values have been filled
1320
1420
  """
1321
1421
  # Determine nan state of altitude
@@ -1360,12 +1460,14 @@ def _altitude_interpolation(
1360
1460
  return np.where(sign == 1, fill_climb, fill_descent)
1361
1461
 
1362
1462
 
1363
- def _verify_altitude(altitude: np.ndarray, nominal_rocd: float, freq: np.timedelta64) -> None:
1463
+ def _verify_altitude(
1464
+ altitude: npt.NDArray[np.float_], nominal_rocd: float, freq: np.timedelta64
1465
+ ) -> None:
1364
1466
  """Confirm that the time derivative of `altitude` does not exceed twice `nominal_rocd`.
1365
1467
 
1366
1468
  Parameters
1367
1469
  ----------
1368
- altitude : np.ndarray
1470
+ altitude : npt.NDArray[np.float_]
1369
1471
  Array of filled altitude values containing nan values.
1370
1472
  nominal_rocd : float
1371
1473
  Nominal rate of climb/descent, in m/s
@@ -1386,14 +1488,94 @@ def _verify_altitude(altitude: np.ndarray, nominal_rocd: float, freq: np.timedel
1386
1488
  )
1387
1489
 
1388
1490
 
1389
- def _dt_waypoints(
1390
- time: npt.NDArray[np.datetime64], dtype: np.dtype = np.dtype(np.float32)
1491
+ def filter_altitude(
1492
+ altitude: npt.NDArray[np.float_], *, kernel_size: int = 17
1493
+ ) -> npt.NDArray[np.float_]:
1494
+ """
1495
+ Filter noisy altitude on a single flight.
1496
+
1497
+ Currently runs altitude through a median filter using :func:`scipy.signal.medfilt`
1498
+ with ``kernel_size``, then a Savitzky-Golay filter to filter noise.
1499
+
1500
+ .. todo::
1501
+
1502
+ This method assumes that the time interval between altitude points
1503
+ (:func:`segment_duration`) is moderately small (e.g. minutes).
1504
+ This filter may not work as well when waypoints are close (seconds) or
1505
+ farther apart in time (e.g. 30 minutes).
1506
+
1507
+ The optimal altitude filter is a work in a progress
1508
+ and may change in the future.
1509
+
1510
+ Parameters
1511
+ ----------
1512
+ altitude : npt.NDArray[np.float_]
1513
+ Altitude signal
1514
+ kernel_size : int, optional
1515
+ Passed directly to :func:`scipy.signal.medfilt`, by default 11.
1516
+ Passed also to :func:`scipy.signal.medfilt`
1517
+
1518
+ Returns
1519
+ -------
1520
+ npt.NDArray[np.float_]
1521
+ Filtered altitude
1522
+
1523
+ Notes
1524
+ -----
1525
+ Algorithm is derived from :meth:`traffic.core.flight.Flight.filter`.
1526
+
1527
+ The `traffic
1528
+ <https://traffic-viz.github.io/api_reference/traffic.core.flight.html#traffic.core.Flight.filter>`_
1529
+ algorithm also computes thresholds on sliding windows
1530
+ and replaces unacceptable values with NaNs.
1531
+
1532
+ Errors may raised if the ``kernel_size`` is too large.
1533
+
1534
+ See Also
1535
+ --------
1536
+ :meth:`traffic.core.flight.Flight.filter`
1537
+ :func:`scipy.signal.medfilt`
1538
+ """ # noqa: E501
1539
+ if not len(altitude):
1540
+ raise ValueError("Altitude must have non-zero length to filter")
1541
+
1542
+ # The kernel_size must be less than or equal to the number of data points available.
1543
+ kernel_size = min(kernel_size, altitude.size)
1544
+
1545
+ # The kernel_size must be odd.
1546
+ if (kernel_size % 2) == 0:
1547
+ kernel_size -= 1
1548
+
1549
+ # Apply a median filter above a certain threshold
1550
+ altitude_filt = scipy.signal.medfilt(altitude, kernel_size=kernel_size)
1551
+
1552
+ # TODO: I think this makes sense because it smooths the climb/descent phases
1553
+ altitude_filt = _sg_filter(altitude_filt, window_length=kernel_size)
1554
+
1555
+ # TODO: The right way to do this is with a low pass filter at
1556
+ # a reasonable rocd threshold ~300-500 ft/min, e.g.
1557
+ # sos = scipy.signal.butter(4, 250, 'low', output='sos')
1558
+ # return scipy.signal.sosfilt(sos, altitude_filt1)
1559
+ #
1560
+ # Remove noise manually
1561
+ # only remove above max airport elevation
1562
+ d_alt_ft = np.diff(altitude_filt, append=np.nan)
1563
+ is_noise = (np.abs(d_alt_ft) <= 25.0) & (altitude_filt > MAX_AIRPORT_ELEVATION)
1564
+ altitude_filt[is_noise] = np.round(altitude_filt[is_noise], -3)
1565
+
1566
+ return altitude_filt
1567
+
1568
+
1569
+ def segment_duration(
1570
+ time: npt.NDArray[np.datetime64], dtype: npt.DTypeLike = np.float32
1391
1571
  ) -> npt.NDArray[np.float_]:
1392
1572
  """Calculate the time difference between waypoints.
1393
1573
 
1574
+ ``np.nan`` appended so the length of the output is the same as number of waypoints.
1575
+
1394
1576
  Parameters
1395
1577
  ----------
1396
- time : np.ndarray
1578
+ time : npt.NDArray[np.datetime64]
1397
1579
  Waypoint time in ``np.datetime64`` format.
1398
1580
  dtype : np.dtype
1399
1581
  Numpy dtype for time difference.
@@ -1401,11 +1583,180 @@ def _dt_waypoints(
1401
1583
 
1402
1584
  Returns
1403
1585
  -------
1404
- np.ndarray
1586
+ npt.NDArray[np.float_]
1405
1587
  Time difference between waypoints, [:math:`s`].
1406
- This returns an array with dtype specified by``dtype``
1588
+ This returns an array with dtype specified by``dtype``.
1407
1589
  """
1408
1590
  out = np.empty_like(time, dtype=dtype)
1409
1591
  out[-1] = np.nan
1410
1592
  out[:-1] = np.diff(time) / np.timedelta64(1, "s")
1411
1593
  return out
1594
+
1595
+
1596
+ def segment_phase(
1597
+ rocd: npt.NDArray[np.float_],
1598
+ altitude_ft: npt.NDArray[np.float_],
1599
+ *,
1600
+ threshold_rocd: float = 250.0,
1601
+ min_cruise_altitude_ft: float = MIN_CRUISE_ALTITUDE,
1602
+ ) -> npt.NDArray[np.uint8]:
1603
+ """Identify the phase of flight (climb, cruise, descent) for each segment.
1604
+
1605
+ Parameters
1606
+ ----------
1607
+ rocd: pt.NDArray[np.float_]
1608
+ Rate of climb and descent across segment, [:math:`ft min^{-1}`].
1609
+ See output from :func:`segment_rocd`.
1610
+ altitude_ft: npt.NDArray[np.float_]
1611
+ Altitude, [:math:`ft`]
1612
+ threshold_rocd: float, optional
1613
+ ROCD threshold to identify climb and descent, [:math:`ft min^{-1}`].
1614
+ Defaults to 250 ft/min.
1615
+ min_cruise_altitude_ft: float, optional
1616
+ Minimum threshold altitude for cruise, [:math:`ft`]
1617
+ This is specific for each aircraft type,
1618
+ and can be approximated as 50% of the altitude ceiling.
1619
+ Defaults to :attr:`MIN_CRUISE_ALTITUDE`.
1620
+
1621
+ Returns
1622
+ -------
1623
+ npt.NDArray[np.uint8]
1624
+ Array of values enumerating the flight phase.
1625
+ See :attr:`flight.FlightPhase` for enumeration.
1626
+
1627
+ Notes
1628
+ -----
1629
+ Flight data derived from ADS-B and radar sources could contain noise leading
1630
+ to small changes in altitude and ROCD. Hence, an arbitrary ``threshold_rocd``
1631
+ is specified to identify the different phases of flight.
1632
+
1633
+ The flight phase "level-flight" is when an aircraft is holding at lower altitudes.
1634
+ The cruise phase of flight only occurs above a certain threshold altitude.
1635
+
1636
+ See Also
1637
+ --------
1638
+ :attr:`FlightPhase`
1639
+ :func:`segment_rocd`
1640
+ """
1641
+ nan = np.isnan(rocd)
1642
+ cruise = (
1643
+ (rocd < threshold_rocd) & (rocd > -threshold_rocd) & (altitude_ft > min_cruise_altitude_ft)
1644
+ )
1645
+ climb = ~cruise & (rocd > 0)
1646
+ descent = ~cruise & (rocd < 0)
1647
+ level_flight = ~(nan | cruise | climb | descent)
1648
+
1649
+ phase = np.empty(rocd.shape, dtype=np.uint8)
1650
+ phase[cruise] = FlightPhase.CRUISE
1651
+ phase[climb] = FlightPhase.CLIMB
1652
+ phase[descent] = FlightPhase.DESCENT
1653
+ phase[level_flight] = FlightPhase.LEVEL_FLIGHT
1654
+ phase[nan] = FlightPhase.NAN
1655
+
1656
+ return phase
1657
+
1658
+
1659
+ def segment_rocd(
1660
+ segment_duration: npt.NDArray[np.float_], altitude_ft: npt.NDArray[np.float_]
1661
+ ) -> npt.NDArray[np.float_]:
1662
+ """Calculate the rate of climb and descent (ROCD).
1663
+
1664
+ Parameters
1665
+ ----------
1666
+ segment_duration: npt.NDArray[np.float_]
1667
+ Time difference between waypoints, [:math:`s`].
1668
+ Expected to have numeric `dtype`, not `"timedelta64"`.
1669
+ See output from :func:`segment_duration`.
1670
+ altitude_ft: npt.NDArray[np.float_]
1671
+ Altitude of each waypoint, [:math:`ft`]
1672
+
1673
+ Returns
1674
+ -------
1675
+ npt.NDArray[np.float_]
1676
+ Rate of climb and descent over segment, [:math:`ft min^{-1}`]
1677
+
1678
+ See Also
1679
+ --------
1680
+ :func:`segment_duration`
1681
+ """
1682
+ dt_min = segment_duration / 60.0
1683
+
1684
+ out = np.empty_like(altitude_ft)
1685
+ out[:-1] = np.diff(altitude_ft) / dt_min[:-1]
1686
+ out[-1] = np.nan
1687
+
1688
+ return out
1689
+
1690
+
1691
+ def fit_altitude(
1692
+ elapsed_time: npt.NDArray[np.float_],
1693
+ altitude_ft: npt.NDArray[np.float_],
1694
+ max_segments: int = 30,
1695
+ pop: int = 3,
1696
+ r2_target: float = 0.999,
1697
+ max_cruise_rocd: float = 10.0,
1698
+ sg_window: int = 7,
1699
+ sg_polyorder: int = 1,
1700
+ ) -> npt.NDArray[np.float_]:
1701
+ """Use piecewise linear fitting to smooth a flight profile.
1702
+
1703
+ Fit a flight profile to a series of line segments. Segments that have a
1704
+ small rocd will be set to have a slope of zero and snapped to the
1705
+ nearest thousand foot level. A Savitzky-Golay filter will then be
1706
+ applied to the profile to smooth the climbs and descents. This filter
1707
+ works best for high frequency flight data, sampled at a 1-3 second
1708
+ sampling period.
1709
+
1710
+ Parameters
1711
+ ----------
1712
+ elapsed_time: npt.NDArray[np.float_]
1713
+ Cumulative time of flight between waypoints, [:math:`s`]
1714
+ altitude_ft: npt.NDArray[np.float_]
1715
+ Altitude of each waypoint, [:math:`ft`
1716
+ max_segments: int, optional
1717
+ The maximum number of line segements to fit to the flight profile.
1718
+ pop: int, optional
1719
+ Population parameter used for the stocastic optimization routine
1720
+ used to fit the flight profile.
1721
+ r2_target: float, optional
1722
+ Target r^2 value for solver. Solver will continue to add line
1723
+ segments until the resulting r^2 value is greater than this.
1724
+ max_cruise_rocd: float, optional
1725
+ The maximum ROCD for a segment that will be forced to a slope of
1726
+ zero, [:math:`ft s^{-1}`]
1727
+ sg_window: int, optional
1728
+ Parameter for :func:`scipy.signal.savgol_filter`
1729
+ sg_polyorder: int, optional
1730
+ Parameter for :func:`scipy.signal.savgol_filter`
1731
+
1732
+ Returns
1733
+ -------
1734
+ npt.NDArray[np.float_]
1735
+ Smoothed flight altitudes
1736
+ """
1737
+ try:
1738
+ import pwlf
1739
+ except ModuleNotFoundError:
1740
+ raise ModuleNotFoundError(
1741
+ "The 'fit_altitude' function requires the 'pwlf' package."
1742
+ "This can be installed with 'pip install pwlf'."
1743
+ )
1744
+ for i in range(1, max_segments):
1745
+ m2 = pwlf.PiecewiseLinFit(elapsed_time, altitude_ft)
1746
+ r = m2.fitfast(i, pop)
1747
+ r2 = m2.r_squared()
1748
+ if r2 > r2_target:
1749
+ break
1750
+
1751
+ mask = abs(m2.slopes) < max_cruise_rocd / 60.0
1752
+ bounds = r[:-1][mask], r[1:][mask]
1753
+ lvl = np.round(m2.intercepts[mask], -3)
1754
+ time_stack = np.repeat(elapsed_time[:, np.newaxis], lvl.size, axis=1)
1755
+ filt = (time_stack >= bounds[0]) & (time_stack <= bounds[1])
1756
+ altitude_ft = np.copy(altitude_ft)
1757
+ for i in range(lvl.size):
1758
+ altitude_ft[filt[:, i]] = lvl[i]
1759
+
1760
+ altitude_ft = scipy.signal.savgol_filter(altitude_ft, sg_window, sg_polyorder)
1761
+
1762
+ return altitude_ft