pycontrails 0.41.0__cp39-cp39-win_amd64.whl → 0.42.0__cp39-cp39-win_amd64.whl

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

Potentially problematic release.


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

@@ -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,
@@ -420,7 +417,7 @@ class Flight(GeoVectorDataset):
420
417
  # Segment Properties
421
418
  # ------------
422
419
 
423
- def segment_duration(self, dtype: np.dtype = np.dtype(np.float32)) -> np.ndarray:
420
+ def segment_duration(self, dtype: np.dtype = np.dtype(np.float32)) -> npt.NDArray[np.float_]:
424
421
  r"""Compute time elapsed between waypoints in seconds.
425
422
 
426
423
  ``np.nan`` appended so the length of the output is the same as number of waypoints.
@@ -433,14 +430,14 @@ class Flight(GeoVectorDataset):
433
430
 
434
431
  Returns
435
432
  -------
436
- np.ndarray
433
+ npt.NDArray[np.float_]
437
434
  Time difference between waypoints, [:math:`s`].
438
435
  Returns an array with dtype specified by``dtype``
439
436
  """
440
437
 
441
- return _dt_waypoints(self.data["time"], dtype=dtype)
438
+ return segment_duration(self.data["time"], dtype=dtype)
442
439
 
443
- def segment_length(self) -> np.ndarray:
440
+ def segment_length(self) -> npt.NDArray[np.float_]:
444
441
  """Compute spherical distance between flight waypoints.
445
442
 
446
443
  Helper function used in :meth:`length` and :meth:`length_met`.
@@ -448,7 +445,7 @@ class Flight(GeoVectorDataset):
448
445
 
449
446
  Returns
450
447
  -------
451
- np.ndarray
448
+ npt.NDArray[np.float_]
452
449
  Array of distances in [:math:`m`] between waypoints
453
450
 
454
451
  Raises
@@ -478,7 +475,7 @@ class Flight(GeoVectorDataset):
478
475
 
479
476
  return geo.segment_length(self["longitude"], self["latitude"], self.altitude)
480
477
 
481
- def segment_angle(self) -> tuple[np.ndarray, np.ndarray]:
478
+ def segment_angle(self) -> tuple[npt.NDArray[np.float_], npt.NDArray[np.float_]]:
482
479
  """Calculate sin/cos for the angle between each segment and the longitudinal axis.
483
480
 
484
481
  This is different from the usual navigational angle between two points known as *bearing*.
@@ -498,7 +495,7 @@ class Flight(GeoVectorDataset):
498
495
 
499
496
  Returns
500
497
  -------
501
- np.ndarray, np.ndarray
498
+ npt.NDArray[np.float_], npt.NDArray[np.float_]
502
499
  sin(a), cos(a), where ``a`` is the angle between the segment and the longitudinal axis
503
500
  The final values are of both arrays are ``np.nan``.
504
501
 
@@ -528,7 +525,7 @@ class Flight(GeoVectorDataset):
528
525
  """
529
526
  return geo.segment_angle(self["longitude"], self["latitude"])
530
527
 
531
- def segment_azimuth(self) -> np.ndarray:
528
+ def segment_azimuth(self) -> npt.NDArray[np.float_]:
532
529
  """Calculate (forward) azimuth at each waypoint.
533
530
 
534
531
  Method calls `pyproj.Geod.inv`, which is slow. See `geo.forward_azimuth`
@@ -540,7 +537,7 @@ class Flight(GeoVectorDataset):
540
537
 
541
538
  Returns
542
539
  -------
543
- np.ndarray
540
+ npt.NDArray[np.float_]
544
541
  Array of azimuths.
545
542
  """
546
543
  lon = self["longitude"]
@@ -564,7 +561,7 @@ class Flight(GeoVectorDataset):
564
561
 
565
562
  def segment_groundspeed(
566
563
  self, smooth: bool = False, window_length: int = 7, polyorder: int = 1
567
- ) -> np.ndarray:
564
+ ) -> npt.NDArray[np.float_]:
568
565
  """Return groundspeed across segments.
569
566
 
570
567
  Calculate by dividing the horizontal segment length by the difference in waypoint times.
@@ -581,7 +578,7 @@ class Flight(GeoVectorDataset):
581
578
 
582
579
  Returns
583
580
  -------
584
- np.ndarray
581
+ npt.NDArray[np.float_]
585
582
  Groundspeed of the segment, [:math:`m s^{-1}`]
586
583
  """
587
584
  # get horizontal distance - set altitude to 0
@@ -653,25 +650,76 @@ class Flight(GeoVectorDataset):
653
650
  return np.sqrt(tas_x * tas_x + tas_y * tas_y)
654
651
 
655
652
  def segment_mach_number(
656
- self, true_airspeed: np.ndarray, air_temperature: np.ndarray
657
- ) -> np.ndarray:
653
+ self, true_airspeed: npt.NDArray[np.float_], air_temperature: npt.NDArray[np.float_]
654
+ ) -> npt.NDArray[np.float_]:
658
655
  r"""Calculate the mach number of each segment.
659
656
 
660
657
  Parameters
661
658
  ----------
662
- true_airspeed : np.ndarray
659
+ true_airspeed : npt.NDArray[np.float_]
663
660
  True airspeed of the segment, [:math:`m \ s^{-1}`].
664
661
  See :meth:`segment_true_airspeed`.
665
- air_temperature : np.ndarray
662
+ air_temperature : npt.NDArray[np.float_]
666
663
  Average air temperature of each segment, [:math:`K`]
667
664
 
668
665
  Returns
669
666
  -------
670
- np.ndarray
667
+ npt.NDArray[np.float_]
671
668
  Mach number of each segment
672
669
  """
673
670
  return units.tas_to_mach_number(true_airspeed, air_temperature)
674
671
 
672
+ def segment_rocd(self) -> npt.NDArray[np.float_]:
673
+ """Calculate the rate of climb and descent (ROCD).
674
+
675
+ Returns
676
+ -------
677
+ npt.NDArray[np.float_]
678
+ Rate of climb and descent over segment, [:math:`ft min^{-1}`]
679
+
680
+ See Also
681
+ --------
682
+ :func:`segment_rocd`
683
+ """
684
+ return segment_rocd(self.segment_duration(), self.altitude_ft)
685
+
686
+ def segment_phase(
687
+ self,
688
+ threshold_rocd: float = 250.0,
689
+ min_cruise_altitude_ft: float = 20000.0,
690
+ ) -> npt.NDArray[np.uint8]:
691
+ """Identify the phase of flight (climb, cruise, descent) for each segment.
692
+
693
+ Parameters
694
+ ----------
695
+ threshold_rocd : float, optional
696
+ ROCD threshold to identify climb and descent, [:math:`ft min^{-1}`].
697
+ Currently set to 250 ft/min.
698
+ min_cruise_altitude_ft : float, optional
699
+ Minimum altitude for cruise, [:math:`ft`]
700
+ This is specific for each aircraft type,
701
+ and can be approximated as 50% of the altitude ceiling.
702
+ Defaults to 20000 ft.
703
+
704
+ Returns
705
+ -------
706
+ npt.NDArray[np.uint8]
707
+ Array of values enumerating the flight phase.
708
+ See :attr:`flight.FlightPhase` for enumeration.
709
+
710
+ See Also
711
+ --------
712
+ :attr:`FlightPhase`
713
+ :func:`segment_phase`
714
+ :func:`segment_rocd`
715
+ """
716
+ return segment_phase(
717
+ self.segment_rocd(),
718
+ self.altitude_ft,
719
+ threshold_rocd=threshold_rocd,
720
+ min_cruise_altitude_ft=min_cruise_altitude_ft,
721
+ )
722
+
675
723
  # ------------
676
724
  # Filter/Resample
677
725
  # ------------
@@ -840,7 +888,7 @@ class Flight(GeoVectorDataset):
840
888
  # STEP 5: Resample flight to freq
841
889
  df = df.resample(freq).first()
842
890
 
843
- # STEP 6: Linearly interprolate small horizontal gaps and account
891
+ # STEP 6: Linearly interpolate small horizontal gaps and account
844
892
  # for previous longitude shift.
845
893
  keys = ["latitude", "longitude"]
846
894
  df.loc[:, keys] = df.loc[:, keys].interpolate(method="linear")
@@ -1169,12 +1217,12 @@ class Flight(GeoVectorDataset):
1169
1217
  return ax
1170
1218
 
1171
1219
 
1172
- def _return_linestring(data: dict[str, np.ndarray]) -> list[list[float]]:
1220
+ def _return_linestring(data: dict[str, npt.NDArray[np.float_]]) -> list[list[float]]:
1173
1221
  """Return list of coordinates for geojson constructions.
1174
1222
 
1175
1223
  Parameters
1176
1224
  ----------
1177
- data : dict[str, np.ndarray]
1225
+ data : dict[str, npt.NDArray[np.float_]]
1178
1226
  :attr:`data` containing `longitude`, `latitude`, and `altitude` keys
1179
1227
 
1180
1228
  Returns
@@ -1248,16 +1296,16 @@ def _antimeridian_index(longitude: pd.Series, crs: str = "EPSG:4326") -> int:
1248
1296
  return -1
1249
1297
 
1250
1298
 
1251
- def _sg_filter(vals: np.ndarray, window_length: int = 7, polyorder: int = 1) -> np.ndarray:
1299
+ def _sg_filter(
1300
+ vals: npt.NDArray[np.float_], window_length: int = 7, polyorder: int = 1
1301
+ ) -> npt.NDArray[np.float_]:
1252
1302
  """Apply Savitzky-Golay filter to smooth out noise in the time-series data.
1253
1303
 
1254
- Used to smooth true airspeed & fuel flow.
1255
-
1256
- TODO: move to a more centralized location in the codebase if used again
1304
+ Used to smooth true airspeed, fuel flow, and altitude.
1257
1305
 
1258
1306
  Parameters
1259
1307
  ----------
1260
- vals : np.ndarray
1308
+ vals : npt.NDArray[np.float_]
1261
1309
  Input array
1262
1310
  window_length : int, optional
1263
1311
  Parameter for :func:`scipy.signal.savgol_filter`
@@ -1266,7 +1314,7 @@ def _sg_filter(vals: np.ndarray, window_length: int = 7, polyorder: int = 1) ->
1266
1314
 
1267
1315
  Returns
1268
1316
  -------
1269
- np.ndarray
1317
+ npt.NDArray[np.float_]
1270
1318
  Smoothed values
1271
1319
 
1272
1320
  Raises
@@ -1292,8 +1340,8 @@ def _sg_filter(vals: np.ndarray, window_length: int = 7, polyorder: int = 1) ->
1292
1340
 
1293
1341
 
1294
1342
  def _altitude_interpolation(
1295
- altitude: np.ndarray, nominal_rocd: float, freq: np.timedelta64
1296
- ) -> np.ndarray:
1343
+ altitude: npt.NDArray[np.float_], nominal_rocd: float, freq: np.timedelta64
1344
+ ) -> npt.NDArray[np.float_]:
1297
1345
  """Interpolate nan values in `altitude` array.
1298
1346
 
1299
1347
  Suppose each group of consecutive nan values is enclosed by `a0` and `a1` with
@@ -1304,7 +1352,7 @@ def _altitude_interpolation(
1304
1352
 
1305
1353
  Parameters
1306
1354
  ----------
1307
- altitude : np.ndarray
1355
+ altitude : npt.NDArray[np.float_]
1308
1356
  Array of altitude values containing nan values. This function will raise
1309
1357
  an error if `altitude` does not contain nan values. Moreover, this function
1310
1358
  assumes the initial and final entries in `altitude` are not nan.
@@ -1315,7 +1363,7 @@ def _altitude_interpolation(
1315
1363
 
1316
1364
  Returns
1317
1365
  -------
1318
- np.ndarray
1366
+ npt.NDArray[np.float_]
1319
1367
  Altitude after nan values have been filled
1320
1368
  """
1321
1369
  # Determine nan state of altitude
@@ -1360,12 +1408,14 @@ def _altitude_interpolation(
1360
1408
  return np.where(sign == 1, fill_climb, fill_descent)
1361
1409
 
1362
1410
 
1363
- def _verify_altitude(altitude: np.ndarray, nominal_rocd: float, freq: np.timedelta64) -> None:
1411
+ def _verify_altitude(
1412
+ altitude: npt.NDArray[np.float_], nominal_rocd: float, freq: np.timedelta64
1413
+ ) -> None:
1364
1414
  """Confirm that the time derivative of `altitude` does not exceed twice `nominal_rocd`.
1365
1415
 
1366
1416
  Parameters
1367
1417
  ----------
1368
- altitude : np.ndarray
1418
+ altitude : npt.NDArray[np.float_]
1369
1419
  Array of filled altitude values containing nan values.
1370
1420
  nominal_rocd : float
1371
1421
  Nominal rate of climb/descent, in m/s
@@ -1386,14 +1436,94 @@ def _verify_altitude(altitude: np.ndarray, nominal_rocd: float, freq: np.timedel
1386
1436
  )
1387
1437
 
1388
1438
 
1389
- def _dt_waypoints(
1439
+ def filter_altitude(
1440
+ altitude: npt.NDArray[np.float_], *, kernel_size: int = 17
1441
+ ) -> npt.NDArray[np.float_]:
1442
+ """
1443
+ Filter noisy altitude on a single flight.
1444
+
1445
+ Currently runs altitude through a median filter using :func:`scipy.signal.medfilt`
1446
+ with ``kernel_size``, then a Savitzky-Golay filter to filter noise.
1447
+
1448
+ .. todo::
1449
+
1450
+ This method assumes that the time interval between altitude points
1451
+ (:func:`segment_duration`) is moderately small (e.g. minutes).
1452
+ This filter may not work as well when waypoints are close (seconds) or
1453
+ farther apart in time (e.g. 30 minutes).
1454
+
1455
+ The optimal altitude filter is a work in a progress
1456
+ and may change in the future.
1457
+
1458
+ Parameters
1459
+ ----------
1460
+ altitude : npt.NDArray[np.float_]
1461
+ Altitude signal
1462
+ kernel_size : int, optional
1463
+ Passed directly to :func:`scipy.signal.medfilt`, by default 11.
1464
+ Passed also to :func:`scipy.signal.medfilt`
1465
+
1466
+ Returns
1467
+ -------
1468
+ npt.NDArray[np.float_]
1469
+ Filtered altitude
1470
+
1471
+ Notes
1472
+ -----
1473
+ Algorithm is derived from :meth:`traffic.core.flight.Flight.filter`.
1474
+
1475
+ The `traffic
1476
+ <https://traffic-viz.github.io/api_reference/traffic.core.flight.html#traffic.core.Flight.filter>`_
1477
+ algorithm also computes thresholds on sliding windows
1478
+ and replaces unacceptable values with NaNs.
1479
+
1480
+ Errors may raised if the ``kernel_size`` is too large.
1481
+
1482
+ See Also
1483
+ --------
1484
+ :meth:`traffic.core.flight.Flight.filter`
1485
+ :func:`scipy.signal.medfilt`
1486
+ """ # noqa: E501
1487
+ if not len(altitude):
1488
+ raise ValueError("Altitude must have non-zero length to filter")
1489
+
1490
+ # The kernel_size must be less than or equal to the number of data points available.
1491
+ kernel_size = min(kernel_size, altitude.size)
1492
+
1493
+ # The kernel_size must be odd.
1494
+ if (kernel_size % 2) == 0:
1495
+ kernel_size -= 1
1496
+
1497
+ # Apply a median filter above a certain threshold
1498
+ altitude_filt = scipy.signal.medfilt(altitude, kernel_size=kernel_size)
1499
+
1500
+ # TODO: I think this makes sense because it smooths the climb/descent phases
1501
+ altitude_filt = _sg_filter(altitude_filt, window_length=kernel_size)
1502
+
1503
+ # TODO: The right way to do this is with a low pass filter at
1504
+ # a reasonable rocd threshold ~300-500 ft/min, e.g.
1505
+ # sos = scipy.signal.butter(4, 250, 'low', output='sos')
1506
+ # return scipy.signal.sosfilt(sos, altitude_filt1)
1507
+ #
1508
+ # Remove noise manually
1509
+ # only remove above max airport elevation
1510
+ d_alt_ft = np.diff(altitude_filt, append=np.nan)
1511
+ is_noise = (np.abs(d_alt_ft) <= 25.0) & (altitude_filt > MAX_AIRPORT_ELEVATION)
1512
+ altitude_filt[is_noise] = np.round(altitude_filt[is_noise], -3)
1513
+
1514
+ return altitude_filt
1515
+
1516
+
1517
+ def segment_duration(
1390
1518
  time: npt.NDArray[np.datetime64], dtype: np.dtype = np.dtype(np.float32)
1391
1519
  ) -> npt.NDArray[np.float_]:
1392
1520
  """Calculate the time difference between waypoints.
1393
1521
 
1522
+ ``np.nan`` appended so the length of the output is the same as number of waypoints.
1523
+
1394
1524
  Parameters
1395
1525
  ----------
1396
- time : np.ndarray
1526
+ time : npt.NDArray[np.datetime64]
1397
1527
  Waypoint time in ``np.datetime64`` format.
1398
1528
  dtype : np.dtype
1399
1529
  Numpy dtype for time difference.
@@ -1401,11 +1531,106 @@ def _dt_waypoints(
1401
1531
 
1402
1532
  Returns
1403
1533
  -------
1404
- np.ndarray
1534
+ npt.NDArray[np.float_]
1405
1535
  Time difference between waypoints, [:math:`s`].
1406
- This returns an array with dtype specified by``dtype``
1536
+ This returns an array with dtype specified by``dtype``.
1407
1537
  """
1408
1538
  out = np.empty_like(time, dtype=dtype)
1409
1539
  out[-1] = np.nan
1410
1540
  out[:-1] = np.diff(time) / np.timedelta64(1, "s")
1411
1541
  return out
1542
+
1543
+
1544
+ def segment_phase(
1545
+ rocd: npt.NDArray[np.float_],
1546
+ altitude_ft: npt.NDArray[np.float_],
1547
+ *,
1548
+ threshold_rocd: float = 250.0,
1549
+ min_cruise_altitude_ft: float = MIN_CRUISE_ALTITUDE,
1550
+ ) -> npt.NDArray[np.uint8]:
1551
+ """Identify the phase of flight (climb, cruise, descent) for each segment.
1552
+
1553
+ Parameters
1554
+ ----------
1555
+ rocd: pt.NDArray[np.float_]
1556
+ Rate of climb and descent across segment, [:math:`ft min^{-1}`].
1557
+ See output from :func:`segment_rocd`.
1558
+ altitude_ft: npt.NDArray[np.float_]
1559
+ Altitude, [:math:`ft`]
1560
+ threshold_rocd: float, optional
1561
+ ROCD threshold to identify climb and descent, [:math:`ft min^{-1}`].
1562
+ Defaults to 250 ft/min.
1563
+ min_cruise_altitude_ft: float, optional
1564
+ Minimum threshold altitude for cruise, [:math:`ft`]
1565
+ This is specific for each aircraft type,
1566
+ and can be approximated as 50% of the altitude ceiling.
1567
+ Defaults to :attr:`MIN_CRUISE_ALTITUDE`.
1568
+
1569
+ Returns
1570
+ -------
1571
+ npt.NDArray[np.uint8]
1572
+ Array of values enumerating the flight phase.
1573
+ See :attr:`flight.FlightPhase` for enumeration.
1574
+
1575
+ Notes
1576
+ -----
1577
+ Flight data derived from ADS-B and radar sources could contain noise leading
1578
+ to small changes in altitude and ROCD. Hence, an arbitrary ``threshold_rocd``
1579
+ is specified to identify the different phases of flight.
1580
+
1581
+ The flight phase "level-flight" is when an aircraft is holding at lower altitudes.
1582
+ The cruise phase of flight only occurs above a certain threshold altitude.
1583
+
1584
+ See Also
1585
+ --------
1586
+ :attr:`FlightPhase`
1587
+ :func:`segment_rocd`
1588
+ """
1589
+ nan = np.isnan(rocd)
1590
+ cruise = (
1591
+ (rocd < threshold_rocd) & (rocd > -threshold_rocd) & (altitude_ft > min_cruise_altitude_ft)
1592
+ )
1593
+ climb = ~cruise & (rocd > 0)
1594
+ descent = ~cruise & (rocd < 0)
1595
+ level_flight = ~(nan | cruise | climb | descent)
1596
+
1597
+ phase = np.empty(rocd.shape, dtype=np.uint8)
1598
+ phase[cruise] = FlightPhase.CRUISE
1599
+ phase[climb] = FlightPhase.CLIMB
1600
+ phase[descent] = FlightPhase.DESCENT
1601
+ phase[level_flight] = FlightPhase.LEVEL_FLIGHT
1602
+ phase[nan] = FlightPhase.NAN
1603
+
1604
+ return phase
1605
+
1606
+
1607
+ def segment_rocd(
1608
+ segment_duration: npt.NDArray[np.float_], altitude_ft: npt.NDArray[np.float_]
1609
+ ) -> npt.NDArray[np.float_]:
1610
+ """Calculate the rate of climb and descent (ROCD).
1611
+
1612
+ Parameters
1613
+ ----------
1614
+ segment_duration: npt.NDArray[np.float_]
1615
+ Time difference between waypoints, [:math:`s`].
1616
+ Expected to have numeric `dtype`, not `"timedelta64"`.
1617
+ See output from :func:`segment_duration`.
1618
+ altitude_ft: npt.NDArray[np.float_]
1619
+ Altitude of each waypoint, [:math:`ft`]
1620
+
1621
+ Returns
1622
+ -------
1623
+ npt.NDArray[np.float_]
1624
+ Rate of climb and descent over segment, [:math:`ft min^{-1}`]
1625
+
1626
+ See Also
1627
+ --------
1628
+ :func:`segment_duration`
1629
+ """
1630
+ dt_min = segment_duration / 60.0
1631
+
1632
+ out = np.empty_like(altitude_ft)
1633
+ out[:-1] = np.diff(altitude_ft) / dt_min[:-1]
1634
+ out[-1] = np.nan
1635
+
1636
+ return out