pycontrails 0.59.0__cp314-cp314-macosx_10_15_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 (123) hide show
  1. pycontrails/__init__.py +70 -0
  2. pycontrails/_version.py +34 -0
  3. pycontrails/core/__init__.py +30 -0
  4. pycontrails/core/aircraft_performance.py +679 -0
  5. pycontrails/core/airports.py +228 -0
  6. pycontrails/core/cache.py +889 -0
  7. pycontrails/core/coordinates.py +174 -0
  8. pycontrails/core/fleet.py +483 -0
  9. pycontrails/core/flight.py +2185 -0
  10. pycontrails/core/flightplan.py +228 -0
  11. pycontrails/core/fuel.py +140 -0
  12. pycontrails/core/interpolation.py +702 -0
  13. pycontrails/core/met.py +2936 -0
  14. pycontrails/core/met_var.py +387 -0
  15. pycontrails/core/models.py +1321 -0
  16. pycontrails/core/polygon.py +549 -0
  17. pycontrails/core/rgi_cython.cpython-314-darwin.so +0 -0
  18. pycontrails/core/vector.py +2249 -0
  19. pycontrails/datalib/__init__.py +12 -0
  20. pycontrails/datalib/_met_utils/metsource.py +746 -0
  21. pycontrails/datalib/ecmwf/__init__.py +73 -0
  22. pycontrails/datalib/ecmwf/arco_era5.py +345 -0
  23. pycontrails/datalib/ecmwf/common.py +114 -0
  24. pycontrails/datalib/ecmwf/era5.py +554 -0
  25. pycontrails/datalib/ecmwf/era5_model_level.py +490 -0
  26. pycontrails/datalib/ecmwf/hres.py +804 -0
  27. pycontrails/datalib/ecmwf/hres_model_level.py +466 -0
  28. pycontrails/datalib/ecmwf/ifs.py +287 -0
  29. pycontrails/datalib/ecmwf/model_levels.py +435 -0
  30. pycontrails/datalib/ecmwf/static/model_level_dataframe_v20240418.csv +139 -0
  31. pycontrails/datalib/ecmwf/variables.py +268 -0
  32. pycontrails/datalib/geo_utils.py +261 -0
  33. pycontrails/datalib/gfs/__init__.py +28 -0
  34. pycontrails/datalib/gfs/gfs.py +656 -0
  35. pycontrails/datalib/gfs/variables.py +104 -0
  36. pycontrails/datalib/goes.py +764 -0
  37. pycontrails/datalib/gruan.py +343 -0
  38. pycontrails/datalib/himawari/__init__.py +27 -0
  39. pycontrails/datalib/himawari/header_struct.py +266 -0
  40. pycontrails/datalib/himawari/himawari.py +671 -0
  41. pycontrails/datalib/landsat.py +589 -0
  42. pycontrails/datalib/leo_utils/__init__.py +5 -0
  43. pycontrails/datalib/leo_utils/correction.py +266 -0
  44. pycontrails/datalib/leo_utils/landsat_metadata.py +300 -0
  45. pycontrails/datalib/leo_utils/search.py +250 -0
  46. pycontrails/datalib/leo_utils/sentinel_metadata.py +748 -0
  47. pycontrails/datalib/leo_utils/static/bq_roi_query.sql +6 -0
  48. pycontrails/datalib/leo_utils/vis.py +59 -0
  49. pycontrails/datalib/sentinel.py +650 -0
  50. pycontrails/datalib/spire/__init__.py +5 -0
  51. pycontrails/datalib/spire/exceptions.py +62 -0
  52. pycontrails/datalib/spire/spire.py +604 -0
  53. pycontrails/ext/bada.py +42 -0
  54. pycontrails/ext/cirium.py +14 -0
  55. pycontrails/ext/empirical_grid.py +140 -0
  56. pycontrails/ext/synthetic_flight.py +431 -0
  57. pycontrails/models/__init__.py +1 -0
  58. pycontrails/models/accf.py +425 -0
  59. pycontrails/models/apcemm/__init__.py +8 -0
  60. pycontrails/models/apcemm/apcemm.py +983 -0
  61. pycontrails/models/apcemm/inputs.py +226 -0
  62. pycontrails/models/apcemm/static/apcemm_yaml_template.yaml +183 -0
  63. pycontrails/models/apcemm/utils.py +437 -0
  64. pycontrails/models/cocip/__init__.py +29 -0
  65. pycontrails/models/cocip/cocip.py +2742 -0
  66. pycontrails/models/cocip/cocip_params.py +305 -0
  67. pycontrails/models/cocip/cocip_uncertainty.py +291 -0
  68. pycontrails/models/cocip/contrail_properties.py +1530 -0
  69. pycontrails/models/cocip/output_formats.py +2270 -0
  70. pycontrails/models/cocip/radiative_forcing.py +1260 -0
  71. pycontrails/models/cocip/radiative_heating.py +520 -0
  72. pycontrails/models/cocip/unterstrasser_wake_vortex.py +508 -0
  73. pycontrails/models/cocip/wake_vortex.py +396 -0
  74. pycontrails/models/cocip/wind_shear.py +120 -0
  75. pycontrails/models/cocipgrid/__init__.py +9 -0
  76. pycontrails/models/cocipgrid/cocip_grid.py +2552 -0
  77. pycontrails/models/cocipgrid/cocip_grid_params.py +138 -0
  78. pycontrails/models/dry_advection.py +602 -0
  79. pycontrails/models/emissions/__init__.py +21 -0
  80. pycontrails/models/emissions/black_carbon.py +599 -0
  81. pycontrails/models/emissions/emissions.py +1353 -0
  82. pycontrails/models/emissions/ffm2.py +336 -0
  83. pycontrails/models/emissions/static/default-engine-uids.csv +239 -0
  84. pycontrails/models/emissions/static/edb-gaseous-v29b-engines.csv +596 -0
  85. pycontrails/models/emissions/static/edb-nvpm-v29b-engines.csv +215 -0
  86. pycontrails/models/extended_k15.py +1327 -0
  87. pycontrails/models/humidity_scaling/__init__.py +37 -0
  88. pycontrails/models/humidity_scaling/humidity_scaling.py +1075 -0
  89. pycontrails/models/humidity_scaling/quantiles/era5-model-level-quantiles.pq +0 -0
  90. pycontrails/models/humidity_scaling/quantiles/era5-pressure-level-quantiles.pq +0 -0
  91. pycontrails/models/issr.py +210 -0
  92. pycontrails/models/pcc.py +326 -0
  93. pycontrails/models/pcr.py +154 -0
  94. pycontrails/models/ps_model/__init__.py +18 -0
  95. pycontrails/models/ps_model/ps_aircraft_params.py +381 -0
  96. pycontrails/models/ps_model/ps_grid.py +701 -0
  97. pycontrails/models/ps_model/ps_model.py +1000 -0
  98. pycontrails/models/ps_model/ps_operational_limits.py +525 -0
  99. pycontrails/models/ps_model/static/ps-aircraft-params-20250328.csv +69 -0
  100. pycontrails/models/ps_model/static/ps-synonym-list-20250328.csv +104 -0
  101. pycontrails/models/sac.py +442 -0
  102. pycontrails/models/tau_cirrus.py +183 -0
  103. pycontrails/physics/__init__.py +1 -0
  104. pycontrails/physics/constants.py +117 -0
  105. pycontrails/physics/geo.py +1138 -0
  106. pycontrails/physics/jet.py +968 -0
  107. pycontrails/physics/static/iata-cargo-load-factors-20250221.csv +74 -0
  108. pycontrails/physics/static/iata-passenger-load-factors-20250221.csv +74 -0
  109. pycontrails/physics/thermo.py +551 -0
  110. pycontrails/physics/units.py +472 -0
  111. pycontrails/py.typed +0 -0
  112. pycontrails/utils/__init__.py +1 -0
  113. pycontrails/utils/dependencies.py +66 -0
  114. pycontrails/utils/iteration.py +13 -0
  115. pycontrails/utils/json.py +187 -0
  116. pycontrails/utils/temp.py +50 -0
  117. pycontrails/utils/types.py +163 -0
  118. pycontrails-0.59.0.dist-info/METADATA +179 -0
  119. pycontrails-0.59.0.dist-info/RECORD +123 -0
  120. pycontrails-0.59.0.dist-info/WHEEL +6 -0
  121. pycontrails-0.59.0.dist-info/licenses/LICENSE +178 -0
  122. pycontrails-0.59.0.dist-info/licenses/NOTICE +43 -0
  123. pycontrails-0.59.0.dist-info/top_level.txt +3 -0
@@ -0,0 +1,2185 @@
1
+ """Flight Data Handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ import logging
7
+ import sys
8
+ import warnings
9
+ from typing import TYPE_CHECKING, Any, NoReturn, Self
10
+
11
+ if sys.version_info >= (3, 12):
12
+ from typing import override
13
+ else:
14
+ from typing_extensions import override
15
+
16
+
17
+ import numpy as np
18
+ import numpy.typing as npt
19
+ import pandas as pd
20
+ import scipy.signal
21
+
22
+ from pycontrails.core.fuel import Fuel, JetA
23
+ from pycontrails.core.vector import GeoVectorDataset, VectorDataDict, VectorDataset
24
+ from pycontrails.physics import constants, geo, units
25
+ from pycontrails.utils import dependencies
26
+ from pycontrails.utils.types import ArrayOrFloat
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # optional imports
31
+ if TYPE_CHECKING:
32
+ import matplotlib.axes
33
+ import traffic.core
34
+
35
+
36
+ class FlightPhase(enum.IntEnum):
37
+ """Flight phase enumeration.
38
+
39
+ Use :func:`segment_phase` or :meth:`Flight.segment_phase` to determine flight phase.
40
+ """
41
+
42
+ #: Waypoints at which the flight is in a climb phase
43
+ CLIMB = enum.auto()
44
+
45
+ #: Waypoints at which the flight is in a cruise phase
46
+ CRUISE = enum.auto()
47
+
48
+ #: Waypoints at which the flight is in a descent phase
49
+ DESCENT = enum.auto()
50
+
51
+ #: Waypoints at which the flight is not in a climb, cruise, or descent phase.
52
+ #: In practice, this category is used for waypoints at which the ROCD resembles
53
+ #: that of a cruise phase, but the altitude is below the minimum cruise altitude.
54
+ LEVEL_FLIGHT = enum.auto()
55
+
56
+ #: Waypoints at which the ROCD is not defined.
57
+ NAN = enum.auto()
58
+
59
+
60
+ #: Max airport elevation, [:math:`ft`]
61
+ #: See `Daocheng_Yading_Airport <https://en.wikipedia.org/wiki/Daocheng_Yading_Airport>`_
62
+ MAX_AIRPORT_ELEVATION = 15_000.0
63
+
64
+ #: Min estimated cruise altitude, [:math:`ft`]
65
+ MIN_CRUISE_ALTITUDE = 20_000.0
66
+
67
+ #: Short haul duration cutoff, [:math:`s`]
68
+ SHORT_HAUL_DURATION = 3600.0
69
+
70
+ #: Set maximum speed compatible with "on_ground" indicator, [:math:`mph`]
71
+ #: Thresholds assessed based on scatter plot (150 knots = 278 km/h)
72
+ MAX_ON_GROUND_SPEED = 150.0
73
+
74
+
75
+ class Flight(GeoVectorDataset):
76
+ """A single flight trajectory.
77
+
78
+ Expect latitude-longitude coordinates in WGS 84.
79
+ Expect altitude in [:math:`m`].
80
+ Expect pressure level (`level`) in [:math:`hPa`].
81
+
82
+ Parameters
83
+ ----------
84
+ data : dict[str, npt.ArrayLike] | pd.DataFrame | VectorDataDict | VectorDataset | None
85
+ Flight trajectory waypoints as data dictionary or :class:`pandas.DataFrame`.
86
+ Must include columns ``time``, ``latitude``, ``longitude``, ``altitude`` or ``level``.
87
+ Keyword arguments for ``time``, ``latitude``, ``longitude``, ``altitude`` or ``level``
88
+ will override ``data`` inputs. Expects ``altitude`` in meters and ``time`` as a
89
+ DatetimeLike (or array that can processed with :func:`pd.to_datetime`).
90
+ Additional waypoint-specific data can be included as additional keys/columns.
91
+ longitude : npt.ArrayLike | None, optional
92
+ Flight trajectory waypoint longitude.
93
+ Defaults to None.
94
+ latitude : npt.ArrayLike | None, optional
95
+ Flight trajectory waypoint latitude.
96
+ Defaults to None.
97
+ altitude : npt.ArrayLike | None, optional
98
+ Flight trajectory waypoint altitude, [:math:`m`].
99
+ Defaults to None.
100
+ altitude_ft : npt.ArrayLike | None, optional
101
+ Flight trajectory waypoint altitude, [:math:`ft`].
102
+ level : npt.ArrayLike | None, optional
103
+ Flight trajectory waypoint pressure level, [:math:`hPa`].
104
+ Defaults to None.
105
+ time : npt.ArrayLike | None, optional
106
+ Flight trajectory waypoint time.
107
+ Defaults to None.
108
+ attrs : dict[str, Any] | None, optional
109
+ Additional flight properties as a dictionary.
110
+ While different models may utilize Flight attributes differently,
111
+ pycontrails applies the following conventions:
112
+
113
+ - ``flight_id``: An internal flight identifier. Used internally
114
+ for :class:`Fleet` interoperability.
115
+ - ``aircraft_type``: Aircraft type ICAO, e.g. ``"A320"``.
116
+ - ``wingspan``: Aircraft wingspan, [:math:`m`].
117
+ - ``n_engine``: Number of aircraft engines.
118
+ - ``engine_uid``: Aircraft engine unique identifier. Used for emissions
119
+ calculations with the ICAO Aircraft Emissions Databank (EDB).
120
+ - ``max_mach_number``: Maximum Mach number at cruise altitude. Used by
121
+ some aircraft performance models to clip true airspeed.
122
+ - ``load_factor``: The load factor used in determining the aircraft's
123
+ take-off weight. Used by some aircraft performance models.
124
+
125
+ Numeric quantities that are constant over the entire flight trajectory
126
+ should be included as attributes.
127
+ copy : bool, optional
128
+ Copy data on Flight creation.
129
+ Defaults to True.
130
+ fuel : Fuel | None, optional
131
+ Fuel used in flight trajectory. Defaults to :class:`JetA`.
132
+ drop_duplicated_times : bool, optional
133
+ Drop duplicate times in flight trajectory. Defaults to False.
134
+ **attrs_kwargs : Any
135
+ Additional flight properties passed as keyword arguments.
136
+
137
+ Raises
138
+ ------
139
+ KeyError
140
+ Raises if ``data`` input does not contain at least ``time``, ``latitude``, ``longitude``,
141
+ (``altitude`` or ``level``).
142
+
143
+ Notes
144
+ -----
145
+ The `Traffic <https://traffic-viz.github.io/index.html>`_ library has many helpful
146
+ flight processing utilities.
147
+
148
+ See :class:`traffic.core.Flight` for more information.
149
+
150
+ Examples
151
+ --------
152
+ >>> import numpy as np
153
+ >>> import pandas as pd
154
+ >>> from pycontrails import Flight
155
+
156
+ >>> # Create `Flight` from a DataFrame.
157
+ >>> df = pd.DataFrame({
158
+ ... "longitude": np.linspace(20, 30, 500),
159
+ ... "latitude": np.linspace(40, 10, 500),
160
+ ... "altitude": 10500,
161
+ ... "time": pd.date_range('2021-01-01T10', '2021-01-01T15', periods=500),
162
+ ... })
163
+ >>> fl = Flight(data=df, flight_id=123) # specify a flight_id by keyword
164
+ >>> fl
165
+ Flight [4 keys x 500 length, 1 attributes]
166
+ Keys: longitude, latitude, altitude, time
167
+ Attributes:
168
+ time [2021-01-01 10:00:00, 2021-01-01 15:00:00]
169
+ longitude [20.0, 30.0]
170
+ latitude [10.0, 40.0]
171
+ altitude [10500.0, 10500.0]
172
+ flight_id 123
173
+
174
+ >>> # Create `Flight` from keywords
175
+ >>> fl = Flight(
176
+ ... longitude=np.linspace(20, 30, 200),
177
+ ... latitude=np.linspace(40, 30, 200),
178
+ ... altitude=11000 * np.ones(200),
179
+ ... time=pd.date_range('2021-01-01T12', '2021-01-01T14', periods=200),
180
+ ... )
181
+ >>> fl
182
+ Flight [4 keys x 200 length, 0 attributes]
183
+ Keys: longitude, latitude, time, altitude
184
+ Attributes:
185
+ time [2021-01-01 12:00:00, 2021-01-01 14:00:00]
186
+ longitude [20.0, 30.0]
187
+ latitude [30.0, 40.0]
188
+ altitude [11000.0, 11000.0]
189
+
190
+ >>> # Access the underlying data as DataFrame
191
+ >>> fl.dataframe.head()
192
+ longitude latitude time altitude
193
+ 0 20.000000 40.000000 2021-01-01 12:00:00.000000000 11000.0
194
+ 1 20.050251 39.949749 2021-01-01 12:00:36.180904522 11000.0
195
+ 2 20.100503 39.899497 2021-01-01 12:01:12.361809045 11000.0
196
+ 3 20.150754 39.849246 2021-01-01 12:01:48.542713567 11000.0
197
+ 4 20.201005 39.798995 2021-01-01 12:02:24.723618090 11000.0
198
+ """
199
+
200
+ __slots__ = ("fuel",)
201
+
202
+ #: Fuel used in flight trajectory
203
+ fuel: Fuel
204
+
205
+ def __init__(
206
+ self,
207
+ data: (
208
+ dict[str, npt.ArrayLike] | pd.DataFrame | VectorDataDict | VectorDataset | None
209
+ ) = None,
210
+ *,
211
+ longitude: npt.ArrayLike | None = None,
212
+ latitude: npt.ArrayLike | None = None,
213
+ altitude: npt.ArrayLike | None = None,
214
+ altitude_ft: npt.ArrayLike | None = None,
215
+ level: npt.ArrayLike | None = None,
216
+ time: npt.ArrayLike | None = None,
217
+ attrs: dict[str, Any] | None = None,
218
+ copy: bool = True,
219
+ fuel: Fuel | None = None,
220
+ drop_duplicated_times: bool = False,
221
+ **attrs_kwargs: Any,
222
+ ) -> None:
223
+ super().__init__(
224
+ data=data,
225
+ longitude=longitude,
226
+ latitude=latitude,
227
+ altitude=altitude,
228
+ altitude_ft=altitude_ft,
229
+ level=level,
230
+ time=time,
231
+ attrs=attrs,
232
+ copy=copy,
233
+ **attrs_kwargs,
234
+ )
235
+
236
+ # Set fuel - fuel instance is NOT copied
237
+ self.fuel = fuel or JetA()
238
+
239
+ # Check flight data for possible errors
240
+ if np.any(self.altitude > 16000.0):
241
+ flight_id = self.attrs.get("flight_id", "")
242
+ flight_id = flight_id and f" for flight {flight_id}"
243
+ warnings.warn(
244
+ f"Flight altitude is high{flight_id}. Expected altitude unit is meters. "
245
+ f"Found waypoint with altitude {self.altitude.max():.0f} m."
246
+ )
247
+
248
+ # Get time differences between waypoints
249
+ if np.isnat(self["time"]).any():
250
+ warnings.warn(
251
+ "Flight trajectory contains NaT times. This will cause errors "
252
+ "with segment-based methods (e.g. 'segment_true_airspeed')."
253
+ )
254
+
255
+ time_diff = np.diff(self["time"])
256
+
257
+ # Ensure that time is sorted
258
+ if self and np.any(time_diff < np.timedelta64(0)):
259
+ if not copy:
260
+ raise ValueError(
261
+ "The 'time' array must be sorted if 'copy=False' on creation. "
262
+ "Set copy=False, or sort data before creating Flight."
263
+ )
264
+ warnings.warn("Sorting Flight data by time.")
265
+ self.data = GeoVectorDataset(self, copy=False).sort("time").data
266
+
267
+ # Update time_diff ... we use it again below
268
+ time_diff = np.diff(self["time"])
269
+
270
+ # Check for duplicate times. If dropping duplicates,
271
+ # keep the *first* occurrence of each time.
272
+ duplicated_times = time_diff == np.timedelta64(0)
273
+ if self and np.any(duplicated_times):
274
+ if drop_duplicated_times:
275
+ mask = np.insert(duplicated_times, 0, False)
276
+ filtered_flight = self.filter(~mask, copy=False)
277
+ self.data = filtered_flight.data
278
+ else:
279
+ warnings.warn(
280
+ f"Flight contains {duplicated_times.sum()} duplicate times. "
281
+ "This will cause errors with segment-based methods. Set "
282
+ "'drop_duplicated_times=True' or call the 'resample_and_fill' method."
283
+ )
284
+
285
+ @override
286
+ def copy(self, **kwargs: Any) -> Self:
287
+ kwargs.setdefault("fuel", self.fuel)
288
+ return super().copy(**kwargs)
289
+
290
+ @override
291
+ def filter(self, mask: npt.NDArray[np.bool_], copy: bool = True, **kwargs: Any) -> Self:
292
+ kwargs.setdefault("fuel", self.fuel)
293
+ return super().filter(mask, copy=copy, **kwargs)
294
+
295
+ @override
296
+ def sort(self, by: str | list[str]) -> NoReturn:
297
+ msg = (
298
+ "Flight.sort is not implemented. A Flight instance is automatically sorted "
299
+ "by 'time' on creation. To force sorting, create a GeoVectorDataset instance "
300
+ "and call the 'sort' method."
301
+ )
302
+ raise ValueError(msg)
303
+
304
+ @property
305
+ def time_start(self) -> pd.Timestamp:
306
+ """First waypoint time.
307
+
308
+ Returns
309
+ -------
310
+ pd.Timestamp
311
+ First waypoint time
312
+ """
313
+ return pd.Timestamp(np.nanmin(self["time"]))
314
+
315
+ @property
316
+ def time_end(self) -> pd.Timestamp:
317
+ """Last waypoint time.
318
+
319
+ Returns
320
+ -------
321
+ pd.Timestamp
322
+ Last waypoint time
323
+ """
324
+ return pd.Timestamp(np.nanmax(self["time"]))
325
+
326
+ @property
327
+ def duration(self) -> pd.Timedelta:
328
+ """Determine flight duration.
329
+
330
+ Returns
331
+ -------
332
+ pd.Timedelta
333
+ Difference between terminal and initial time
334
+ """
335
+ return pd.Timedelta(self.time_end - self.time_start)
336
+
337
+ @property
338
+ def max_time_gap(self) -> pd.Timedelta:
339
+ """Return maximum time gap between waypoints along flight trajectory.
340
+
341
+ Returns
342
+ -------
343
+ pd.Timedelta
344
+ Gap size
345
+
346
+ Examples
347
+ --------
348
+ >>> import numpy as np
349
+ >>> fl = Flight(
350
+ ... longitude=np.linspace(20, 30, 200),
351
+ ... latitude=np.linspace(40, 30, 200),
352
+ ... altitude=11000 * np.ones(200),
353
+ ... time=pd.date_range('2021-01-01T12', '2021-01-01T14', periods=200),
354
+ ... )
355
+ >>> fl.max_time_gap
356
+ Timedelta('0 days 00:00:36.180...')
357
+ """
358
+ return pd.Timedelta(np.nanmax(np.diff(self["time"])))
359
+
360
+ @property
361
+ def max_distance_gap(self) -> float:
362
+ """Return maximum distance gap between waypoints along flight trajectory.
363
+
364
+ Distance is calculated based on WGS84 geodesic.
365
+
366
+ Returns
367
+ -------
368
+ float
369
+ Maximum distance between waypoints, [:math:`m`]
370
+
371
+ Examples
372
+ --------
373
+ >>> import numpy as np
374
+ >>> fl = Flight(
375
+ ... longitude=np.linspace(20, 30, 200),
376
+ ... latitude=np.linspace(40, 30, 200),
377
+ ... altitude=11000 * np.ones(200),
378
+ ... time=pd.date_range('2021-01-01T12', '2021-01-01T14', periods=200),
379
+ ... )
380
+ >>> fl.max_distance_gap
381
+ np.float64(7391.27...)
382
+ """
383
+ return self.segment_length()[:-1].max()
384
+
385
+ @property
386
+ def length(self) -> float:
387
+ """Return flight length based on WGS84 geodesic.
388
+
389
+ Returns
390
+ -------
391
+ float
392
+ Total flight length, [:math:`m`]
393
+
394
+ Examples
395
+ --------
396
+ >>> import numpy as np
397
+ >>> fl = Flight(
398
+ ... longitude=np.linspace(20, 30, 200),
399
+ ... latitude=np.linspace(40, 30, 200),
400
+ ... altitude=11000 * np.ones(200),
401
+ ... time=pd.date_range('2021-01-01T12', '2021-01-01T14', periods=200),
402
+ ... )
403
+ >>> fl.length
404
+ np.float64(1436924.67...)
405
+ """
406
+ # drop off the nan
407
+ return np.nansum(self.segment_length()[:-1])
408
+
409
+ # ------------
410
+ # Segment Properties
411
+ # ------------
412
+
413
+ def segment_duration(self, dtype: npt.DTypeLike = np.float32) -> npt.NDArray[np.floating]:
414
+ r"""Compute time elapsed between waypoints in seconds.
415
+
416
+ ``np.nan`` appended so the length of the output is the same as number of waypoints.
417
+
418
+ Parameters
419
+ ----------
420
+ dtype : npt.DTypeLike, optional
421
+ Numpy dtype for time difference.
422
+ Defaults to ``np.float32``
423
+
424
+ Returns
425
+ -------
426
+ npt.NDArray[np.floating]
427
+ Time difference between waypoints, [:math:`s`].
428
+ Returns an array with dtype specified by``dtype``
429
+ """
430
+
431
+ return segment_duration(self.data["time"], dtype=dtype)
432
+
433
+ def segment_haversine(self) -> npt.NDArray[np.floating]:
434
+ """Compute Haversine (great circle) distance between flight waypoints.
435
+
436
+ Helper function used in :meth:`resample_and_fill`.
437
+ `np.nan` appended so the length of the output is the same as number of waypoints.
438
+
439
+ To account for vertical displacements when computing segment lengths,
440
+ use :meth:`segment_length`.
441
+
442
+ Returns
443
+ -------
444
+ npt.NDArray[np.floating]
445
+ Array of great circle distances in [:math:`m`] between waypoints
446
+
447
+ Examples
448
+ --------
449
+ >>> from pycontrails import Flight
450
+ >>> fl = Flight(
451
+ ... longitude=np.array([1, 2, 3, 5, 8]),
452
+ ... latitude=np.arange(5),
453
+ ... altitude=np.full(shape=(5,), fill_value=11000),
454
+ ... time=pd.date_range('2021-01-01T12', '2021-01-01T14', periods=5),
455
+ ... )
456
+ >>> fl.segment_haversine()
457
+ array([157255.03346286, 157231.08336815, 248456.48781503, 351047.44358851,
458
+ nan])
459
+
460
+ See Also
461
+ --------
462
+ :func:`segment_haversine`
463
+ :meth:`segment_length`
464
+ """
465
+ return geo.segment_haversine(self["longitude"], self["latitude"])
466
+
467
+ def segment_length(self) -> npt.NDArray[np.floating]:
468
+ """Compute spherical distance between flight waypoints.
469
+
470
+ Helper function used in :meth:`length` and :meth:`length_met`.
471
+ `np.nan` appended so the length of the output is the same as number of waypoints.
472
+
473
+ Returns
474
+ -------
475
+ npt.NDArray[np.floating]
476
+ Array of distances in [:math:`m`] between waypoints
477
+
478
+ Examples
479
+ --------
480
+ >>> from pycontrails import Flight
481
+ >>> fl = Flight(
482
+ ... longitude=np.array([1, 2, 3, 5, 8]),
483
+ ... latitude=np.arange(5),
484
+ ... altitude=np.full(shape=(5,), fill_value=11000),
485
+ ... time=pd.date_range('2021-01-01T12', '2021-01-01T14', periods=5),
486
+ ... )
487
+ >>> fl.segment_length()
488
+ array([157255.03346286, 157231.08336815, 248456.48781503, 351047.44358851,
489
+ nan])
490
+
491
+ See Also
492
+ --------
493
+ :func:`segment_length`
494
+ """
495
+ return geo.segment_length(self["longitude"], self["latitude"], self.altitude)
496
+
497
+ def segment_angle(self) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]:
498
+ """Calculate sine and cosine for the angle between each segment and the longitudinal axis.
499
+
500
+ This is different from the usual navigational angle between two points known as *bearing*.
501
+
502
+ *Bearing* in 3D spherical coordinates is referred to as *azimuth*.
503
+ ::
504
+
505
+ (lon_2, lat_2) X
506
+ /|
507
+ / |
508
+ / |
509
+ / |
510
+ / |
511
+ / |
512
+ / |
513
+ (lon_1, lat_1) X -------> longitude (x-axis)
514
+
515
+ Returns
516
+ -------
517
+ tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]
518
+ Returns ``sin(a), cos(a)``, where ``a`` is the angle between the segment and the
519
+ longitudinal axis. The final values are of both arrays are ``np.nan``.
520
+
521
+ See Also
522
+ --------
523
+ :func:`geo.segment_angle`
524
+ :func:`units.heading_to_longitudinal_angle`
525
+ :meth:`segment_azimuth`
526
+ :func:`geo.forward_azimuth`
527
+
528
+ Examples
529
+ --------
530
+ >>> from pycontrails import Flight
531
+ >>> fl = Flight(
532
+ ... longitude=np.array([1, 2, 3, 5, 8]),
533
+ ... latitude=np.arange(5),
534
+ ... altitude=np.full(shape=(5,), fill_value=11000),
535
+ ... time=pd.date_range('2021-01-01T12', '2021-01-01T14', periods=5),
536
+ ... )
537
+ >>> sin, cos = fl.segment_angle()
538
+ >>> sin
539
+ array([0.70716063, 0.70737598, 0.44819424, 0.31820671, nan])
540
+
541
+ >>> cos
542
+ array([0.70705293, 0.70683748, 0.8939362 , 0.94802136, nan])
543
+
544
+ """
545
+ return geo.segment_angle(self["longitude"], self["latitude"])
546
+
547
+ def segment_azimuth(self) -> npt.NDArray[np.floating]:
548
+ """Calculate (forward) azimuth at each waypoint.
549
+
550
+ Method calls `pyproj.Geod.inv`, which is slow. See `geo.forward_azimuth`
551
+ for an outline of a faster implementation.
552
+
553
+ .. versionchanged:: 0.33.7
554
+
555
+ The dtype of the output now matches the dtype of ``self["longitude"]``.
556
+
557
+ Returns
558
+ -------
559
+ npt.NDArray[np.floating]
560
+ Array of azimuths.
561
+
562
+ See Also
563
+ --------
564
+ :meth:`segment_angle`
565
+ :func:`geo.forward_azimuth`
566
+ """
567
+ lon = self["longitude"]
568
+ lat = self["latitude"]
569
+
570
+ lons1 = lon[:-1]
571
+ lats1 = lat[:-1]
572
+ lons2 = lon[1:]
573
+ lats2 = lat[1:]
574
+
575
+ try:
576
+ import pyproj
577
+ except ModuleNotFoundError as exc:
578
+ dependencies.raise_module_not_found_error(
579
+ name="Flight.segment_azimuth method",
580
+ package_name="pyproj",
581
+ module_not_found_error=exc,
582
+ pycontrails_optional_package="pyproj",
583
+ )
584
+
585
+ geod = pyproj.Geod(a=constants.radius_earth)
586
+ az, *_ = geod.inv(lons1, lats1, lons2, lats2)
587
+
588
+ # NOTE: geod.inv automatically promotes to float64. We match the dtype of lon.
589
+ out = np.empty_like(lon)
590
+ out[:-1] = az
591
+ # Convention: append nan
592
+ out[-1] = np.nan
593
+
594
+ return out
595
+
596
+ def segment_groundspeed(
597
+ self, smooth: bool = False, window_length: int = 7, polyorder: int = 1
598
+ ) -> npt.NDArray[np.floating]:
599
+ """Return groundspeed across segments.
600
+
601
+ Calculate by dividing the horizontal segment length by the difference in waypoint times.
602
+
603
+ Parameters
604
+ ----------
605
+ smooth : bool, optional
606
+ Smooth airspeed with Savitzky-Golay filter.
607
+ Defaults to False.
608
+ window_length : int, optional
609
+ Passed directly to :func:`scipy.signal.savgol_filter`, by default 7.
610
+ polyorder : int, optional
611
+ Passed directly to :func:`scipy.signal.savgol_filter`, by default 1.
612
+
613
+ Returns
614
+ -------
615
+ npt.NDArray[np.floating]
616
+ Groundspeed of the segment, [:math:`m s^{-1}`]
617
+ """
618
+ # get horizontal distance (altitude is ignored)
619
+ horizontal_segment_length = geo.segment_haversine(self["longitude"], self["latitude"])
620
+
621
+ # time between waypoints, in seconds
622
+ dt_sec = self.segment_duration(dtype=horizontal_segment_length.dtype)
623
+
624
+ # calculate groundspeed
625
+ groundspeed = horizontal_segment_length / dt_sec
626
+
627
+ # Savitzky-Golay filter
628
+ if smooth:
629
+ # omit final nan value, then reattach it afterwards
630
+ groundspeed[:-1] = _sg_filter(groundspeed[:-1], window_length, polyorder)
631
+ groundspeed[-1] = np.nan
632
+
633
+ return groundspeed
634
+
635
+ def segment_true_airspeed(
636
+ self,
637
+ u_wind: npt.NDArray[np.floating] | float = 0.0,
638
+ v_wind: npt.NDArray[np.floating] | float = 0.0,
639
+ smooth: bool = True,
640
+ window_length: int = 7,
641
+ polyorder: int = 1,
642
+ ) -> npt.NDArray[np.floating]:
643
+ r"""Calculate the true airspeed [:math:`m/s`] from the ground speed and horizontal winds.
644
+
645
+ The calculated ground speed will first be smoothed with a Savitzky-Golay filter if enabled.
646
+
647
+ Parameters
648
+ ----------
649
+ u_wind : npt.NDArray[np.floating] | float
650
+ U wind speed, [:math:`m \ s^{-1}`].
651
+ Defaults to 0 for all waypoints.
652
+ v_wind : npt.NDArray[np.floating] | float
653
+ V wind speed, [:math:`m \ s^{-1}`].
654
+ Defaults to 0 for all waypoints.
655
+ smooth : bool, optional
656
+ Smooth airspeed with Savitzky-Golay filter.
657
+ Defaults to True.
658
+ window_length : int, optional
659
+ Passed directly to :func:`scipy.signal.savgol_filter`, by default 7.
660
+ polyorder : int, optional
661
+ Passed directly to :func:`scipy.signal.savgol_filter`, by default 1.
662
+
663
+ Returns
664
+ -------
665
+ npt.NDArray[np.floating]
666
+ True wind speed of each segment, [:math:`m \ s^{-1}`]
667
+ """
668
+ groundspeed = self.segment_groundspeed(smooth, window_length, polyorder)
669
+
670
+ sin_a, cos_a = self.segment_angle()
671
+ gs_x = groundspeed * cos_a
672
+ gs_y = groundspeed * sin_a
673
+ tas_x = gs_x - u_wind
674
+ tas_y = gs_y - v_wind
675
+
676
+ return np.sqrt(tas_x * tas_x + tas_y * tas_y)
677
+
678
+ def segment_mach_number(
679
+ self, true_airspeed: npt.NDArray[np.floating], air_temperature: npt.NDArray[np.floating]
680
+ ) -> npt.NDArray[np.floating]:
681
+ r"""Calculate the mach number of each segment.
682
+
683
+ Parameters
684
+ ----------
685
+ true_airspeed : npt.NDArray[np.floating]
686
+ True airspeed of the segment, [:math:`m \ s^{-1}`].
687
+ See :meth:`segment_true_airspeed`.
688
+ air_temperature : npt.NDArray[np.floating]
689
+ Average air temperature of each segment, [:math:`K`]
690
+
691
+ Returns
692
+ -------
693
+ npt.NDArray[np.floating]
694
+ Mach number of each segment
695
+ """
696
+ return units.tas_to_mach_number(true_airspeed, air_temperature)
697
+
698
+ def segment_rocd(
699
+ self,
700
+ air_temperature: None | npt.NDArray[np.floating] = None,
701
+ ) -> npt.NDArray[np.floating]:
702
+ """Calculate the rate of climb and descent (ROCD).
703
+
704
+ Parameters
705
+ ----------
706
+ air_temperature: None | npt.NDArray[np.floating]
707
+ Air temperature of each flight waypoint, [:math:`K`]
708
+
709
+ Returns
710
+ -------
711
+ npt.NDArray[np.floating]
712
+ Rate of climb and descent over segment, [:math:`ft min^{-1}`]
713
+
714
+ See Also
715
+ --------
716
+ :func:`segment_rocd`
717
+ """
718
+ return segment_rocd(self.segment_duration(), self.altitude_ft, air_temperature)
719
+
720
+ def segment_phase(
721
+ self,
722
+ threshold_rocd: float = 250.0,
723
+ min_cruise_altitude_ft: float = 20000.0,
724
+ air_temperature: None | npt.NDArray[np.floating] = None,
725
+ ) -> npt.NDArray[np.uint8]:
726
+ """Identify the phase of flight (climb, cruise, descent) for each segment.
727
+
728
+ Parameters
729
+ ----------
730
+ threshold_rocd : float, optional
731
+ ROCD threshold to identify climb and descent, [:math:`ft min^{-1}`].
732
+ Currently set to 250 ft/min.
733
+ min_cruise_altitude_ft : float, optional
734
+ Minimum altitude for cruise, [:math:`ft`]
735
+ This is specific for each aircraft type,
736
+ and can be approximated as 50% of the altitude ceiling.
737
+ Defaults to 20000 ft.
738
+ air_temperature: None | npt.NDArray[np.floating]
739
+ Air temperature of each flight waypoint, [:math:`K`]
740
+
741
+ Returns
742
+ -------
743
+ npt.NDArray[np.uint8]
744
+ Array of values enumerating the flight phase.
745
+ See :attr:`flight.FlightPhase` for enumeration.
746
+
747
+ See Also
748
+ --------
749
+ :attr:`FlightPhase`
750
+ :func:`segment_phase`
751
+ :func:`segment_rocd`
752
+ """
753
+ return segment_phase(
754
+ self.segment_rocd(air_temperature),
755
+ self.altitude_ft,
756
+ threshold_rocd=threshold_rocd,
757
+ min_cruise_altitude_ft=min_cruise_altitude_ft,
758
+ )
759
+
760
+ # ------------
761
+ # Filter/Resample
762
+ # ------------
763
+
764
+ def filter_by_first(self) -> Self:
765
+ """Keep first row of group of waypoints with identical coordinates.
766
+
767
+ Chaining this method with `resample_and_fill` often gives a cleaner trajectory
768
+ when using noisy flight waypoints.
769
+
770
+ Returns
771
+ -------
772
+ Self
773
+ Filtered Flight instance
774
+
775
+ Examples
776
+ --------
777
+ >>> from datetime import datetime
778
+ >>> import pandas as pd
779
+
780
+ >>> df = pd.DataFrame()
781
+ >>> df['longitude'] = [0, 0, 50]
782
+ >>> df['latitude'] = 0
783
+ >>> df['altitude'] = 0
784
+ >>> df['time'] = [datetime(2020, 1, 1, h) for h in range(3)]
785
+
786
+ >>> fl = Flight(df)
787
+
788
+ >>> fl.filter_by_first().dataframe
789
+ longitude latitude altitude time
790
+ 0 0.0 0.0 0.0 2020-01-01 00:00:00
791
+ 1 50.0 0.0 0.0 2020-01-01 02:00:00
792
+ """
793
+ df = self.dataframe.groupby(["longitude", "latitude"], sort=False).first().reset_index()
794
+ return type(self)(data=df, attrs=self.attrs, fuel=self.fuel)
795
+
796
+ def resample_and_fill(
797
+ self,
798
+ freq: str = "1min",
799
+ fill_method: str = "geodesic",
800
+ geodesic_threshold: float = 100e3,
801
+ nominal_rocd: float = constants.nominal_rocd,
802
+ drop: bool = True,
803
+ keep_original_index: bool = False,
804
+ time: npt.NDArray[np.datetime64] | None = None,
805
+ ) -> Self:
806
+ """Resample and fill flight trajectory with geodesics and linear interpolation.
807
+
808
+ Waypoints are resampled according to the frequency ``freq`` or to the times in ``time``.
809
+ Values for :attr:`data` columns ``longitude``, ``latitude``, and ``altitude``
810
+ are interpolated.
811
+
812
+ When resampled based on ``freq``, waypoints will include all multiples of ``freq``
813
+ between the flight start and end time. For example, when resampling to a frequency of
814
+ 1 minute, a flight that starts at 2020/1/1 00:00:59 and ends at 2020/1/1 00:01:01
815
+ will return a single waypoint at 2020/1/1 00:01:00, whereas a flight that
816
+ starts at 2020/1/1 00:01:01 and ends at 2020/1/1 00:01:59 will return an empty
817
+ flight.
818
+
819
+ When resampled based on ``time``, waypoints will include all times between the
820
+ flight start and end time.
821
+
822
+ Parameters
823
+ ----------
824
+ freq : str, optional
825
+ Resampling frequency, by default "1min"
826
+ fill_method : str, optional
827
+ Choose between ``"geodesic"`` and ``"linear"``, by default ``"geodesic"``.
828
+ In geodesic mode, large gaps between waypoints are filled with geodesic
829
+ interpolation and small gaps are filled with linear interpolation. In linear
830
+ mode, all gaps are filled with linear interpolation.
831
+ geodesic_threshold : float, optional
832
+ Threshold for geodesic interpolation, [:math:`m`].
833
+ If the distance between consecutive waypoints is under this threshold,
834
+ values are interpolated linearly.
835
+ nominal_rocd : float, optional
836
+ Nominal rate of climb / descent for aircraft type.
837
+ Defaults to :attr:`constants.nominal_rocd`.
838
+ drop : bool, optional
839
+ Drop any columns that are not resampled and filled.
840
+ Defaults to ``True``, dropping all keys outside of "time", "latitude",
841
+ "longitude" and "altitude". If set to False, the extra keys will be
842
+ kept but filled with ``nan`` or ``None`` values, depending on the data type.
843
+ keep_original_index : bool, optional
844
+ Keep the original index of the :class:`Flight` in addition to the new
845
+ resampled index. Defaults to ``False``.
846
+ .. versionadded:: 0.45.2
847
+ time : npt.NDArray[np.datetime64] | None, optional
848
+ Times to resample to. Will override ``freq`` if provided.
849
+ .. versionadded:: 0.54.11
850
+
851
+ Returns
852
+ -------
853
+ Self
854
+ Filled Flight
855
+
856
+ Raises
857
+ ------
858
+ ValueError
859
+ Unknown ``fill_method``
860
+
861
+ Examples
862
+ --------
863
+ >>> from datetime import datetime
864
+ >>> import pandas as pd
865
+
866
+ >>> df = pd.DataFrame()
867
+ >>> df['longitude'] = [0, 0, 50]
868
+ >>> df['latitude'] = 0
869
+ >>> df['altitude'] = 0
870
+ >>> df['time'] = [datetime(2020, 1, 1, h) for h in range(3)]
871
+
872
+ >>> fl = Flight(df)
873
+ >>> fl.dataframe
874
+ longitude latitude altitude time
875
+ 0 0.0 0.0 0.0 2020-01-01 00:00:00
876
+ 1 0.0 0.0 0.0 2020-01-01 01:00:00
877
+ 2 50.0 0.0 0.0 2020-01-01 02:00:00
878
+
879
+ >>> fl.resample_and_fill('10min').dataframe # resample with 10 minute frequency
880
+ longitude latitude altitude time
881
+ 0 0.000000 0.0 0.0 2020-01-01 00:00:00
882
+ 1 0.000000 0.0 0.0 2020-01-01 00:10:00
883
+ 2 0.000000 0.0 0.0 2020-01-01 00:20:00
884
+ 3 0.000000 0.0 0.0 2020-01-01 00:30:00
885
+ 4 0.000000 0.0 0.0 2020-01-01 00:40:00
886
+ 5 0.000000 0.0 0.0 2020-01-01 00:50:00
887
+ 6 0.000000 0.0 0.0 2020-01-01 01:00:00
888
+ 7 8.333333 0.0 0.0 2020-01-01 01:10:00
889
+ 8 16.666667 0.0 0.0 2020-01-01 01:20:00
890
+ 9 25.000000 0.0 0.0 2020-01-01 01:30:00
891
+ 10 33.333333 0.0 0.0 2020-01-01 01:40:00
892
+ 11 41.666667 0.0 0.0 2020-01-01 01:50:00
893
+ 12 50.000000 0.0 0.0 2020-01-01 02:00:00
894
+ """
895
+ methods = "geodesic", "linear"
896
+ if fill_method not in methods:
897
+ raise ValueError(f"Unknown `fill_method`. Supported methods: {', '.join(methods)}")
898
+
899
+ # STEP 0: If self is empty, return an empty flight
900
+ if not self:
901
+ warnings.warn("Flight instance is empty.")
902
+ return self.copy()
903
+
904
+ # STEP 1: Prepare DataFrame on which we'll perform resampling
905
+ df = self.dataframe
906
+
907
+ # put altitude on dataframe if its not already there
908
+ if "altitude" not in df:
909
+ df["altitude"] = self.altitude
910
+
911
+ # always drop level
912
+ if "level" in df:
913
+ df = df.drop(columns="level")
914
+
915
+ # drop all cols except time/lon/lat/alt
916
+ if drop:
917
+ df = df.loc[:, ["time", "longitude", "latitude", "altitude"]]
918
+
919
+ # STEP 2: Fill large horizontal gaps with interpolated geodesics
920
+ if fill_method == "geodesic":
921
+ filled = self._geodesic_interpolation(geodesic_threshold)
922
+ if filled is not None:
923
+ df = pd.concat([df, filled])
924
+
925
+ # STEP 3: Set the time index, and sort it
926
+ df = df.set_index("time", verify_integrity=True).sort_index()
927
+
928
+ # STEP 4: handle antimeridian crossings
929
+ # For flights spanning the antimeridian, we translate them to a
930
+ # common "chart" away from the antimeridian (see variable `shift`),
931
+ # then apply the interpolation, then shift back to their original position.
932
+ shift = self._antimeridian_shift()
933
+ if shift is not None:
934
+ df["longitude"] = (df["longitude"] - shift) % 360.0
935
+
936
+ # STEP 5: Resample flight
937
+ # Save altitudes to copy over - these just get rounded down in time.
938
+ # Also get target sample indices
939
+ df, t = _resample_to_freq_or_time(df, freq, time)
940
+
941
+ if shift is not None:
942
+ # We need to translate back to the original chart here
943
+ df["longitude"] = ((df["longitude"] + shift + 180.0) % 360.0) - 180.0
944
+
945
+ # STEP 6: Interpolate nan values in altitude
946
+ altitude = df["altitude"].to_numpy()
947
+ time = df.index.to_numpy()
948
+ if np.any(np.isnan(altitude)):
949
+ df["altitude"] = _altitude_interpolation(altitude, time, nominal_rocd)
950
+
951
+ # Remove original index if requested
952
+ if not keep_original_index:
953
+ df = df.loc[t]
954
+
955
+ # finally reset index
956
+ df = df.reset_index()
957
+ if df.empty:
958
+ msg = "Method 'resample_and_fill' returns in an empty Flight."
959
+ if not keep_original_index:
960
+ msg = f"{msg} Pass 'keep_original_index=True' to keep the original index."
961
+ warnings.warn(msg)
962
+
963
+ # Reorder columns (this is unimportant but makes the output more canonical)
964
+ coord_names = ("longitude", "latitude", "altitude", "time")
965
+ df = df[[*coord_names, *[c for c in df.columns if c not in set(coord_names)]]]
966
+
967
+ data = {k: v.to_numpy() for k, v in df.items()}
968
+ return type(self)._from_fastpath(data, attrs=self.attrs, fuel=self.fuel)
969
+
970
+ def clean_and_resample(
971
+ self,
972
+ freq: str = "1min",
973
+ fill_method: str = "geodesic",
974
+ geodesic_threshold: float = 100e3,
975
+ nominal_rocd: float = constants.nominal_rocd,
976
+ kernel_size: int = 17,
977
+ cruise_threshold: float = 120.0,
978
+ force_filter: bool = False,
979
+ drop: bool = True,
980
+ keep_original_index: bool = False,
981
+ ) -> Self:
982
+ """Resample and (possibly) filter a flight trajectory.
983
+
984
+ Waypoints are resampled according to the frequency ``freq``. If the original
985
+ flight data has a short sampling period, `filter_altitude` will also be called
986
+ to clean the data. Large gaps in trajectories may be interpolated as step climbs
987
+ through `_altitude_interpolation`.
988
+
989
+ Parameters
990
+ ----------
991
+ freq : str, optional
992
+ Resampling frequency, by default "1min"
993
+ fill_method : str, optional
994
+ Choose between ``"geodesic"`` and ``"linear"``, by default ``"geodesic"``.
995
+ In geodesic mode, large gaps between waypoints are filled with geodesic
996
+ interpolation and small gaps are filled with linear interpolation. In linear
997
+ mode, all gaps are filled with linear interpolation.
998
+ geodesic_threshold : float, optional
999
+ Threshold for geodesic interpolation, [:math:`m`].
1000
+ If the distance between consecutive waypoints is under this threshold,
1001
+ values are interpolated linearly.
1002
+ nominal_rocd : float, optional
1003
+ Nominal rate of climb / descent for aircraft type.
1004
+ Defaults to :attr:`constants.nominal_rocd`.
1005
+ kernel_size : int, optional
1006
+ Passed directly to :func:`scipy.signal.medfilt`, by default 11.
1007
+ Passed also to :func:`scipy.signal.medfilt`
1008
+ cruise_threshold : float, optional
1009
+ Minimal length of time, in seconds, for a flight to be in cruise to apply median filter
1010
+ force_filter: bool, optional
1011
+ If set to true, meth:`filter_altitude` will always be called. otherwise, it will only
1012
+ be called if the flight has a median sample period under 10 seconds
1013
+ drop : bool, optional
1014
+ Drop any columns that are not resampled and filled.
1015
+ Defaults to ``True``, dropping all keys outside of "time", "latitude",
1016
+ "longitude" and "altitude". If set to False, the extra keys will be
1017
+ kept but filled with ``nan`` or ``None`` values, depending on the data type.
1018
+ keep_original_index : bool, optional
1019
+ Keep the original index of the :class:`Flight` in addition to the new
1020
+ resampled index. Defaults to ``False``.
1021
+ .. versionadded:: 0.45.2
1022
+
1023
+ Returns
1024
+ -------
1025
+ Self
1026
+ Filled Flight
1027
+ """
1028
+ clean_flight: Flight
1029
+ # If the flight has a large sampling period, don't try to smooth it unless requested
1030
+ seg_duration = self.segment_duration()
1031
+ median_gap = np.nanmedian(seg_duration)
1032
+ if (median_gap > 10.0) and (not force_filter):
1033
+ return self.resample_and_fill(
1034
+ freq,
1035
+ fill_method,
1036
+ geodesic_threshold,
1037
+ nominal_rocd,
1038
+ drop,
1039
+ keep_original_index,
1040
+ )
1041
+
1042
+ # If the flight has large gap(s), then call resample and fill, then filter altitude
1043
+ max_gap = np.max(seg_duration)
1044
+ if max_gap > 300.0:
1045
+ # Ignore warning in intermediate resample
1046
+ with warnings.catch_warnings():
1047
+ warnings.filterwarnings("ignore", message="^.*greater than nominal.*$")
1048
+ clean_flight = self.resample_and_fill(
1049
+ "1s",
1050
+ fill_method,
1051
+ geodesic_threshold,
1052
+ nominal_rocd,
1053
+ drop,
1054
+ keep_original_index,
1055
+ )
1056
+ clean_flight = clean_flight.filter_altitude(kernel_size, cruise_threshold)
1057
+ else:
1058
+ clean_flight = self.filter_altitude(kernel_size, cruise_threshold)
1059
+
1060
+ # Resample to requested rate and return
1061
+ return clean_flight.resample_and_fill(
1062
+ freq,
1063
+ fill_method,
1064
+ geodesic_threshold,
1065
+ nominal_rocd,
1066
+ drop,
1067
+ keep_original_index,
1068
+ )
1069
+
1070
+ def filter_altitude(
1071
+ self,
1072
+ kernel_size: int = 17,
1073
+ cruise_threshold: float = 120.0,
1074
+ ) -> Self:
1075
+ """
1076
+ Filter noisy altitude on a single flight.
1077
+
1078
+ Currently runs altitude through a median filter using :func:`scipy.signal.medfilt`
1079
+ with ``kernel_size``, then a Savitzky-Golay filter to filter noise. The median filter
1080
+ is only applied during cruise segments that are longer than ``cruise_threshold``.
1081
+
1082
+ Parameters
1083
+ ----------
1084
+ kernel_size : int, optional
1085
+ Passed directly to :func:`scipy.signal.medfilt`, by default 11.
1086
+ Passed also to :func:`scipy.signal.medfilt`
1087
+ cruise_threshold : float, optional
1088
+ Minimal length of time, in seconds, for a flight to be in cruise to apply median filter
1089
+
1090
+ Returns
1091
+ -------
1092
+ Self
1093
+ Filtered Flight
1094
+
1095
+ Notes
1096
+ -----
1097
+ Algorithm is derived from :meth:`traffic.core.Flight.filter`.
1098
+
1099
+ The `traffic
1100
+ <https://traffic-viz.github.io/api_reference/traffic.core.flight.html#traffic.core.Flight.filter>`_
1101
+ algorithm also computes thresholds on sliding windows
1102
+ and replaces unacceptable values with NaNs.
1103
+
1104
+ Errors may raised if the ``kernel_size`` is too large.
1105
+
1106
+ See Also
1107
+ --------
1108
+ :meth:`traffic.core.flight.Flight.filter`
1109
+ :func:`scipy.signal.medfilt`
1110
+ """
1111
+ out = self.copy()
1112
+ altitude_ft_filtered = filter_altitude(
1113
+ self["time"], self.altitude_ft, kernel_size, cruise_threshold
1114
+ )
1115
+ out.update(altitude_ft=altitude_ft_filtered)
1116
+ out.data.pop("altitude", None) # avoid any ambiguity
1117
+ out.data.pop("level", None) # avoid any ambiguity
1118
+ return out
1119
+
1120
+ def distance_to_coords(
1121
+ self: Flight, distance: ArrayOrFloat
1122
+ ) -> tuple[
1123
+ ArrayOrFloat,
1124
+ ArrayOrFloat,
1125
+ np.intp | npt.NDArray[np.intp],
1126
+ ]:
1127
+ """
1128
+ Convert distance along flight path to geodesic coordinates.
1129
+
1130
+ Will return a tuple containing `(lat, lon, index)`, where index indicates which flight
1131
+ segment contains the returned coordinate.
1132
+
1133
+ Parameters
1134
+ ----------
1135
+ distance : ArrayOrFloat
1136
+ Distance along flight path, [:math:`m`]
1137
+
1138
+ Returns
1139
+ -------
1140
+ tuple[ArrayOrFloat, ArrayOrFloat, np.intp | npt.NDArray[np.intp]]
1141
+ latitude, longitude, and segment index corresponding to distance.
1142
+ """
1143
+
1144
+ # Check if flight crosses antimeridian line
1145
+ # If it does, shift longitude chart to remove jump
1146
+ lon_ = self["longitude"]
1147
+ lat_ = self["latitude"]
1148
+ shift = self._antimeridian_shift()
1149
+ if shift is not None:
1150
+ lon_ = (lon_ - shift) % 360.0
1151
+
1152
+ # Make a fake flight that flies at constant height so distance is just
1153
+ # distance traveled across groud
1154
+ flat_dataset = Flight(
1155
+ longitude=self.coords["longitude"],
1156
+ latitude=self.coords["latitude"],
1157
+ time=self.coords["time"],
1158
+ level=[self.coords["level"][0] for _ in range(self.size)],
1159
+ )
1160
+
1161
+ lengths = flat_dataset.segment_length()
1162
+ cumulative_lengths = np.nancumsum(lengths)
1163
+ cumulative_lengths = np.insert(cumulative_lengths[:-1], 0, 0)
1164
+ seg_idx: np.intp | npt.NDArray[np.intp]
1165
+
1166
+ if isinstance(distance, float):
1167
+ seg_idx = np.argmax(cumulative_lengths > distance)
1168
+ else:
1169
+ seg_idx = np.argmax(cumulative_lengths > distance.reshape((distance.size, 1)), axis=1)
1170
+
1171
+ # If in the last segment (which has length 0), then just return the last waypoint
1172
+ seg_idx -= 1
1173
+
1174
+ # linear interpolation in lat/lon - assuming the way points are within 100-200km so this
1175
+ # should be accurate enough without needed to reproject or use spherical distance
1176
+ lat1: ArrayOrFloat = lat_[seg_idx]
1177
+ lon1: ArrayOrFloat = lon_[seg_idx]
1178
+ lat2: ArrayOrFloat = lat_[seg_idx + 1]
1179
+ lon2: ArrayOrFloat = lon_[seg_idx + 1]
1180
+
1181
+ dx = distance - cumulative_lengths[seg_idx]
1182
+ fx = dx / lengths[seg_idx]
1183
+ lat = (1 - fx) * lat1 + fx * lat2
1184
+ lon = (1 - fx) * lon1 + fx * lon2
1185
+
1186
+ if isinstance(distance, float):
1187
+ if distance < 0:
1188
+ lat = np.nan
1189
+ lon = np.nan
1190
+ seg_idx = np.intp(0)
1191
+ elif distance >= cumulative_lengths[-1]:
1192
+ lat = lat_[-1]
1193
+ lon = lon_[-1]
1194
+ seg_idx = np.intp(self.size - 1)
1195
+ else:
1196
+ lat[distance < 0] = np.nan
1197
+ lon[distance < 0] = np.nan
1198
+ seg_idx[distance < 0] = 0 # type: ignore
1199
+
1200
+ lat[distance >= cumulative_lengths[-1]] = lat_[-1]
1201
+ lon[distance >= cumulative_lengths[-1]] = lon_[-1]
1202
+ seg_idx[distance >= cumulative_lengths[-1]] = self.size - 1 # type: ignore
1203
+
1204
+ if shift is not None:
1205
+ # We need to translate back to the original chart here
1206
+ lon += shift
1207
+ lon = ((lon + 180.0) % 360.0) - 180.0
1208
+
1209
+ return lat, lon, seg_idx
1210
+
1211
+ def _antimeridian_shift(self) -> float | None:
1212
+ """Determine shift required for resampling trajectories that cross antimeridian.
1213
+
1214
+ Because flights sometimes span more than 180 degree longitude (for example,
1215
+ when flight-level winds favor travel in a specific direction, typically eastward),
1216
+ antimeridian crossings cannot reliably be detected by looking only at minimum
1217
+ and maximum longitudes.
1218
+
1219
+ Instead, this function checks each flight segment for an antimeridian crossing,
1220
+ and if it finds one returns the coordinate of a meridian that is not crossed by
1221
+ the flight.
1222
+
1223
+ Returns
1224
+ -------
1225
+ float | None
1226
+ Longitude shift for handling antimeridian crossings, or None if the
1227
+ flight does not cross the antimeridian.
1228
+ """
1229
+
1230
+ # logic for detecting crossings is consistent with _antimeridian_crossing,
1231
+ # but implementation is separate to keep performance costs as low as possible
1232
+ lon = self["longitude"]
1233
+ if np.any(np.isnan(lon)):
1234
+ warnings.warn("Anti-meridian crossings can't be reliably detected with nan longitudes")
1235
+
1236
+ s1 = (lon >= -180) & (lon <= -90)
1237
+ s2 = (lon <= 180) & (lon >= 90)
1238
+ jump12 = s1[:-1] & s2[1:] # westward
1239
+ jump21 = s2[:-1] & s1[1:] # eastward
1240
+ if not np.any(jump12 | jump21):
1241
+ return None
1242
+
1243
+ # separate flight into segments that are east and west of crossings
1244
+ net_westward = np.insert(np.cumsum(jump12.astype(int) - jump21.astype(int)), 0, 0)
1245
+ max_westward = net_westward.max()
1246
+ if max_westward - net_westward.min() > 1:
1247
+ msg = "Cannot handle consecutive antimeridian crossings in the same direction"
1248
+ raise ValueError(msg)
1249
+ east = (net_westward == 0) if max_westward == 1 else (net_westward == -1)
1250
+
1251
+ # shift must be between maximum longitude east of crossings
1252
+ # and minimum longitude west of crossings
1253
+ shift_min = np.nanmax(lon[east])
1254
+ shift_max = np.nanmin(lon[~east])
1255
+ if shift_min >= shift_max:
1256
+ msg = "Cannot handle flight that spans more than 360 degrees longitude"
1257
+ raise ValueError(msg)
1258
+ return (shift_min + shift_max) / 2
1259
+
1260
+ def _geodesic_interpolation(self, geodesic_threshold: float) -> pd.DataFrame | None:
1261
+ """Geodesic interpolate between large gaps between waypoints.
1262
+
1263
+ Parameters
1264
+ ----------
1265
+ geodesic_threshold : float
1266
+ The threshold for large gap, [:math:`m`].
1267
+
1268
+ Returns
1269
+ -------
1270
+ pd.DataFrame | None
1271
+ Generated waypoints to be merged into underlying :attr:`data`.
1272
+ Return `None` if no new waypoints are created.
1273
+ """
1274
+ # Omit the final nan and ensure index + 1 (below) is well defined
1275
+ segs = self.segment_haversine()[:-1]
1276
+
1277
+ # For default geodesic_threshold, we expect gap_indices to be very
1278
+ # sparse (so the for loop below is cheap)
1279
+ gap_indices = np.flatnonzero(segs > geodesic_threshold)
1280
+ if gap_indices.size == 0:
1281
+ # For most flights, gap_indices is empty. It's more performant
1282
+ # to exit now rather than build an empty DataFrame below.
1283
+ return None
1284
+
1285
+ try:
1286
+ import pyproj
1287
+ except ModuleNotFoundError as exc:
1288
+ dependencies.raise_module_not_found_error(
1289
+ name="Flight._geodesic_interpolation method",
1290
+ package_name="pyproj",
1291
+ module_not_found_error=exc,
1292
+ pycontrails_optional_package="pyproj",
1293
+ )
1294
+
1295
+ geod = pyproj.Geod(ellps="WGS84")
1296
+ longitudes: list[float] = []
1297
+ latitudes: list[float] = []
1298
+ times: list[np.ndarray] = []
1299
+
1300
+ longitude = self["longitude"]
1301
+ latitude = self["latitude"]
1302
+ time = self["time"]
1303
+
1304
+ for index in gap_indices:
1305
+ lon0 = longitude[index]
1306
+ lat0 = latitude[index]
1307
+ t0 = time[index]
1308
+ lon1 = longitude[index + 1]
1309
+ lat1 = latitude[index + 1]
1310
+ t1 = time[index + 1]
1311
+
1312
+ distance = segs[index]
1313
+ n_steps = distance // geodesic_threshold # number of new waypoints to generate
1314
+
1315
+ # This is the expensive call within the for-loop
1316
+ # NOTE: geod.npts does not return the initial or terminal points
1317
+ lonlats: list[tuple[float, float]] = geod.npts(lon0, lat0, lon1, lat1, n_steps)
1318
+
1319
+ lons, lats = zip(*lonlats, strict=True)
1320
+ longitudes.extend(lons)
1321
+ latitudes.extend(lats)
1322
+
1323
+ # + 1 to denominator to stay consistent with geod.npts (only interior points)
1324
+ t_step = (t1 - t0) / (n_steps + 1.0)
1325
+
1326
+ # subtract 0.5 * t_step to ensure round-off error doesn't put final arange point
1327
+ # very close to t1
1328
+ t_range = np.arange(t0 + t_step, t1 - 0.5 * t_step, t_step)
1329
+ times.append(t_range)
1330
+
1331
+ times_ = np.concatenate(times)
1332
+ return pd.DataFrame({"longitude": longitudes, "latitude": latitudes, "time": times_})
1333
+
1334
+ # ------------
1335
+ # I / O
1336
+ # ------------
1337
+
1338
+ def to_geojson_linestring(self) -> dict[str, Any]:
1339
+ """Return trajectory as geojson FeatureCollection containing single LineString.
1340
+
1341
+ Returns
1342
+ -------
1343
+ dict[str, Any]
1344
+ Python representation of geojson FeatureCollection
1345
+ """
1346
+ points = _return_linestring(
1347
+ {
1348
+ "longitude": self["longitude"],
1349
+ "latitude": self["latitude"],
1350
+ "altitude": self.altitude,
1351
+ }
1352
+ )
1353
+ geometry = {"type": "LineString", "coordinates": points}
1354
+ properties = {
1355
+ "start_time": self.time_start.isoformat(),
1356
+ "end_time": self.time_end.isoformat(),
1357
+ }
1358
+ properties.update(self.constants)
1359
+ linestring = {"type": "Feature", "geometry": geometry, "properties": properties}
1360
+
1361
+ return {"type": "FeatureCollection", "features": [linestring]}
1362
+
1363
+ def to_geojson_multilinestring(
1364
+ self, key: str | None = None, split_antimeridian: bool = True
1365
+ ) -> dict[str, Any]:
1366
+ """Return trajectory as GeoJSON FeatureCollection of MultiLineStrings.
1367
+
1368
+ If `key` is provided, Flight :attr:`data` is grouped according to values of ``key``.
1369
+ Each group gives rise to a Feature containing a MultiLineString geometry.
1370
+ Each MultiLineString can optionally be split over the antimeridian.
1371
+
1372
+ Parameters
1373
+ ----------
1374
+ key : str | None, optional
1375
+ If provided, name of :attr:`data` column to group by.
1376
+ split_antimeridian : bool, optional
1377
+ Split linestrings that cross the antimeridian. Defaults to True.
1378
+
1379
+ Returns
1380
+ -------
1381
+ dict[str, Any]
1382
+ Python representation of GeoJSON FeatureCollection of MultiLinestring Features
1383
+
1384
+ Raises
1385
+ ------
1386
+ KeyError
1387
+ ``key`` is provided but :attr:`data` does not contain column ``key``
1388
+ """
1389
+ jump_indices = _antimeridian_index(pd.Series(self["longitude"]))
1390
+
1391
+ def _group_to_feature(name: Any, group: pd.DataFrame) -> dict[str, str | dict[str, Any]]:
1392
+ # assigns a different value to each group of consecutive indices
1393
+ subgrouping = group.index.to_series().diff().ne(1).cumsum()
1394
+
1395
+ # increments values after antimeridian crossings
1396
+ if split_antimeridian:
1397
+ for jump_index in jump_indices:
1398
+ if jump_index in subgrouping:
1399
+ subgrouping.loc[jump_index:] += 1
1400
+
1401
+ # creates separate linestrings for sets of points
1402
+ # - with non-consecutive indices
1403
+ # - before and after antimeridian crossings
1404
+ multi_ls = [_return_linestring(g) for _, g in group.groupby(subgrouping)]
1405
+ geometry = {"type": "MultiLineString", "coordinates": multi_ls}
1406
+
1407
+ # adding in static properties
1408
+ properties: dict[str, Any] = {key: name} if key is not None else {}
1409
+ properties.update(self.constants)
1410
+ return {"type": "Feature", "geometry": geometry, "properties": properties}
1411
+
1412
+ # Add altitude (needed in _return_linestring) without mutating self
1413
+ df = self.dataframe
1414
+ if "altitude" not in self:
1415
+ df["altitude"] = self.altitude
1416
+
1417
+ if key is not None:
1418
+ if key not in self:
1419
+ raise KeyError(f"Column {key} does not exist in data.")
1420
+ groups = df.groupby(key)
1421
+ else:
1422
+ # create a single group containing all rows of dataframe
1423
+ groups = [(None, df)] # name is ignored in this case
1424
+
1425
+ features = [_group_to_feature(name, group) for name, group in groups]
1426
+ return {"type": "FeatureCollection", "features": features}
1427
+
1428
+ def to_traffic(self) -> traffic.core.Flight:
1429
+ """Convert to :class:`traffic.core.Flight` instance.
1430
+
1431
+ Returns
1432
+ -------
1433
+ traffic.core.Flight
1434
+ traffic flight instance
1435
+
1436
+ Raises
1437
+ ------
1438
+ ModuleNotFoundError
1439
+ `traffic` package not installed
1440
+
1441
+ See Also
1442
+ --------
1443
+ :class:`traffic.core.Flight`
1444
+ """
1445
+ try:
1446
+ import traffic.core
1447
+ except ModuleNotFoundError as e:
1448
+ dependencies.raise_module_not_found_error(
1449
+ name="Flight.to_traffic method",
1450
+ package_name="traffic",
1451
+ module_not_found_error=e,
1452
+ )
1453
+
1454
+ return traffic.core.Flight(
1455
+ self.to_dataframe(copy=True).rename(columns={"time": "timestamp"})
1456
+ )
1457
+
1458
+ # ------------
1459
+ # MET
1460
+ # ------------
1461
+
1462
+ def length_met(self, key: str, threshold: float = 1.0) -> float:
1463
+ """Calculate total horizontal distance where column ``key`` exceeds ``threshold``.
1464
+
1465
+ Parameters
1466
+ ----------
1467
+ key : str
1468
+ Column key in :attr:`data`
1469
+ threshold : float
1470
+ Consider trajectory waypoints whose associated ``key`` value exceeds ``threshold``,
1471
+ by default 1.0
1472
+
1473
+ Returns
1474
+ -------
1475
+ float
1476
+ Total distance, [:math:`m`]
1477
+
1478
+ Raises
1479
+ ------
1480
+ KeyError
1481
+ :attr:`data` does not contain column ``key``
1482
+
1483
+ Examples
1484
+ --------
1485
+ >>> from datetime import datetime
1486
+ >>> import pandas as pd
1487
+ >>> import numpy as np
1488
+ >>> from pycontrails.datalib.ecmwf import ERA5
1489
+ >>> from pycontrails import Flight
1490
+
1491
+ >>> # Get met data
1492
+ >>> times = (datetime(2022, 3, 1, 0), datetime(2022, 3, 1, 3))
1493
+ >>> variables = ["air_temperature", "specific_humidity"]
1494
+ >>> levels = [300, 250, 200]
1495
+ >>> era5 = ERA5(time=times, variables=variables, pressure_levels=levels)
1496
+ >>> met = era5.open_metdataset()
1497
+
1498
+ >>> # Build flight
1499
+ >>> df = pd.DataFrame()
1500
+ >>> df["time"] = pd.date_range("2022-03-01T00", "2022-03-01T03", periods=11)
1501
+ >>> df["longitude"] = np.linspace(-20, 20, 11)
1502
+ >>> df["latitude"] = np.linspace(-20, 20, 11)
1503
+ >>> df["altitude"] = np.linspace(9500, 10000, 11)
1504
+ >>> fl = Flight(df).resample_and_fill("10s")
1505
+
1506
+ >>> # Intersect and attach
1507
+ >>> fl["air_temperature"] = fl.intersect_met(met["air_temperature"])
1508
+ >>> fl["air_temperature"]
1509
+ array([235.94657007, 235.55745645, 235.56709768, ..., 234.59917962,
1510
+ 234.60387402, 234.60845312], shape=(1081,))
1511
+
1512
+ >>> # Length (in meters) of waypoints whose temperature exceeds 236K
1513
+ >>> fl.length_met("air_temperature", threshold=236)
1514
+ np.float64(3589705.998...)
1515
+
1516
+ >>> # Proportion (with respect to distance) of waypoints whose temperature exceeds 236K
1517
+ >>> fl.proportion_met("air_temperature", threshold=236)
1518
+ np.float64(0.576...)
1519
+ """
1520
+ if key not in self.data:
1521
+ raise KeyError(f"Column {key} does not exist in data.")
1522
+
1523
+ # The column of interest may contain floating point values less than 1.
1524
+ # In this case, if the default threshold is not changed, warn the user that the behavior
1525
+ # might not be what is expected.
1526
+
1527
+ # Checking if column of interest contains floating point values below 1
1528
+ if threshold == 1.0 and ((self[key] > 0) & (self[key] < 1)).any():
1529
+ warnings.warn(
1530
+ f"Column {key} contains real numbers between 0 and 1. "
1531
+ "To include these values in this calculation, change the `threshold` parameter "
1532
+ "or modify the underlying DataFrame in place."
1533
+ )
1534
+
1535
+ segs = self.segment_length()[:-1] # lengths between waypoints, dropping off the nan
1536
+
1537
+ # giving each waypoint the average of the segments on either side side
1538
+ segs = np.concatenate([segs[:1], (segs[1:] + segs[:-1]) / 2, segs[-1:]])
1539
+
1540
+ # filter by region of interest
1541
+ indices = np.where(self[key] >= threshold)[0]
1542
+
1543
+ return np.sum(segs[indices])
1544
+
1545
+ def proportion_met(self, key: str, threshold: float = 1.0) -> float:
1546
+ """Calculate proportion of flight with certain meteorological constraint.
1547
+
1548
+ Parameters
1549
+ ----------
1550
+ key : str
1551
+ Column key in :attr:`data`
1552
+ threshold : float
1553
+ Consider trajectory waypoints whose associated ``key`` value exceeds ``threshold``,
1554
+ Defaults to 1.0
1555
+
1556
+ Returns
1557
+ -------
1558
+ float
1559
+ Ratio
1560
+ """
1561
+ try:
1562
+ return self.length_met(key, threshold) / self.length
1563
+ except ZeroDivisionError:
1564
+ return 0.0
1565
+
1566
+ # ------------
1567
+ # Visualization
1568
+ # ------------
1569
+
1570
+ def plot(self, **kwargs: Any) -> matplotlib.axes.Axes:
1571
+ """Plot flight trajectory longitude-latitude values.
1572
+
1573
+ Parameters
1574
+ ----------
1575
+ **kwargs : Any
1576
+ Additional plot properties to passed to `pd.DataFrame.plot`
1577
+
1578
+ Returns
1579
+ -------
1580
+ matplotlib.axes.Axes
1581
+ Plot
1582
+ """
1583
+ kwargs.setdefault("legend", False)
1584
+ ax = self.dataframe.plot(x="longitude", y="latitude", **kwargs)
1585
+ ax.set(xlabel="longitude", ylabel="latitude")
1586
+ return ax
1587
+
1588
+ def plot_profile(self, **kwargs: Any) -> matplotlib.axes.Axes:
1589
+ """Plot flight trajectory time-altitude values.
1590
+
1591
+ Parameters
1592
+ ----------
1593
+ **kwargs : Any
1594
+ Additional plot properties to passed to `pd.DataFrame.plot`
1595
+
1596
+ Returns
1597
+ -------
1598
+ matplotlib.axes.Axes
1599
+ Plot
1600
+ """
1601
+ kwargs.setdefault("legend", False)
1602
+ df = self.dataframe.assign(altitude_ft=self.altitude_ft)
1603
+ ax = df.plot(x="time", y="altitude_ft", **kwargs)
1604
+ ax.set(xlabel="time", ylabel="altitude_ft")
1605
+ return ax
1606
+
1607
+
1608
+ def _return_linestring(data: dict[str, npt.NDArray[np.floating]]) -> list[list[float]]:
1609
+ """Return list of coordinates for geojson constructions.
1610
+
1611
+ Parameters
1612
+ ----------
1613
+ data : dict[str, npt.NDArray[np.floating]]
1614
+ :attr:`data` containing `longitude`, `latitude`, and `altitude` keys
1615
+
1616
+ Returns
1617
+ -------
1618
+ list[list[float]]
1619
+ The list of coordinates
1620
+ """
1621
+ # rounding to reduce the size of resultant json arrays
1622
+ points = zip(
1623
+ np.round(data["longitude"], decimals=4),
1624
+ np.round(data["latitude"], decimals=4),
1625
+ np.round(data["altitude"], decimals=4),
1626
+ strict=True,
1627
+ )
1628
+ return [list(p) for p in points]
1629
+
1630
+
1631
+ def _antimeridian_index(longitude: pd.Series) -> list[int]:
1632
+ """Return indices after flight crosses antimeridian, or an empty list if flight does not cross.
1633
+
1634
+ This function assumes EPSG:4326 coordinates.
1635
+
1636
+ Parameters
1637
+ ----------
1638
+ longitude : pd.Series
1639
+ longitude values with an integer index
1640
+
1641
+ Returns
1642
+ -------
1643
+ list[int]
1644
+ Indices after jump, or empty list of flight does not cross antimeridian.
1645
+ """
1646
+ l1 = (-180.0, -90.0)
1647
+ l2 = (90.0, 180.0)
1648
+
1649
+ # TODO: When nans exist, this method *may* not find the meridian
1650
+ if np.any(np.isnan(longitude)):
1651
+ warnings.warn("Anti-meridian index can't be found accurately with nan values in longitude")
1652
+
1653
+ s1 = longitude.between(*l1)
1654
+ s2 = longitude.between(*l2)
1655
+ jump12 = longitude[s1 & s2.shift()]
1656
+ jump21 = longitude[s1.shift() & s2]
1657
+ return pd.concat([jump12, jump21]).index.to_list()
1658
+
1659
+
1660
+ def _sg_filter(
1661
+ vals: npt.NDArray[np.floating], window_length: int = 7, polyorder: int = 1
1662
+ ) -> npt.NDArray[np.floating]:
1663
+ """Apply Savitzky-Golay filter to smooth out noise in the time-series data.
1664
+
1665
+ Used to smooth true airspeed, fuel flow, and altitude.
1666
+
1667
+ Parameters
1668
+ ----------
1669
+ vals : npt.NDArray[np.floating]
1670
+ Input array
1671
+ window_length : int, optional
1672
+ Parameter for :func:`scipy.signal.savgol_filter`
1673
+ polyorder : int, optional
1674
+ Parameter for :func:`scipy.signal.savgol_filter`
1675
+
1676
+ Returns
1677
+ -------
1678
+ npt.NDArray[np.floating]
1679
+ Smoothed values
1680
+
1681
+ Raises
1682
+ ------
1683
+ ArithmeticError
1684
+ Raised if NaN values input to SG filter
1685
+ """
1686
+ # The window_length must be less than or equal to the number of data points available.
1687
+ window_length = min(window_length, vals.size)
1688
+
1689
+ # The time window_length must be odd.
1690
+ if (window_length % 2) == 0:
1691
+ window_length -= 1
1692
+
1693
+ # If there is not enough data points to perform smoothing, return the mean ground speed
1694
+ if window_length <= polyorder:
1695
+ return np.full_like(vals, np.nanmean(vals))
1696
+
1697
+ if np.isnan(vals).any():
1698
+ raise ArithmeticError("NaN values not supported by SG filter.")
1699
+
1700
+ return scipy.signal.savgol_filter(vals, window_length, polyorder)
1701
+
1702
+
1703
+ def _altitude_interpolation(
1704
+ altitude: npt.NDArray[np.floating],
1705
+ time: npt.NDArray[np.datetime64],
1706
+ nominal_rocd: float,
1707
+ minimum_cruise_altitude_ft: float = 20000.0,
1708
+ assumed_cruise_altitude_ft: float = 30000.0,
1709
+ ) -> npt.NDArray[np.floating]:
1710
+ """Interpolate nan values in ``altitude`` array.
1711
+
1712
+ Suppose each group of consecutive nan values is enclosed by ``a0`` and ``a1`` with
1713
+ corresponding time values ``t0`` and ``t1`` respectively. For segments under two hours,
1714
+ this function immediately climbs starting at ``t0``, or for descents, descents at the end
1715
+ the segment so ``a1`` is met at ``t1``. For segments greater than two hours, a descent will
1716
+ still occur at the end of the segment, but climbs will start halfway between ``t0`` and
1717
+ ``t1``.
1718
+
1719
+ Parameters
1720
+ ----------
1721
+ altitude : npt.NDArray[np.floating]
1722
+ Array of altitude values containing nan values. This function will raise
1723
+ an error if ``altitude`` does not contain nan values. Moreover, this function
1724
+ assumes the initial and final entries in ``altitude`` are not nan, [:math:`m`]
1725
+ time : npt.NDArray[np.datetime64]
1726
+ Timestamp at each waypoint. Must be monotonically increasing.
1727
+ nominal_rocd : float
1728
+ Nominal rate of climb/descent, [:math:`m s^{-1}`]
1729
+ minimum_cruise_altitude_ft : float
1730
+ Minimium cruising altitude for a given aircraft type, [:math:`ft`].
1731
+ By default, this is 20000.0 ft.
1732
+ assumed_cruise_altitude_ft : float
1733
+ Assumed cruising altitude for a given aircraft type, [:math:`ft`].
1734
+ By default, this is 30000.0 ft.
1735
+
1736
+ Returns
1737
+ -------
1738
+ npt.NDArray[np.floating]
1739
+ Altitude after nan values have been filled, [:math:`m`]
1740
+
1741
+ Notes
1742
+ -----
1743
+ Default values for ``minimum_cruise_altitude_ft`` and ``assumed_cruise_altitude_ft`` should be
1744
+ provided if aircraft-specific parameters are available to improve the output quality.
1745
+
1746
+ We can assume ``minimum_cruise_altitude_ft`` as 0.5 times the aircraft service ceiling, and
1747
+ ``assumed_cruise_altitude_ft`` as 0.8 times the aircraft service ceiling.
1748
+
1749
+ Assume that aircraft will generally prefer to climb to a higher altitude as early as possible,
1750
+ and descent to a lower altitude as late as possible, because a higher altitude can reduce
1751
+ drag and fuel consumption.
1752
+ """
1753
+ # Work in units of feet
1754
+ alt_ft = units.m_to_ft(altitude)
1755
+ nominal_rocd_ft_min = units.m_to_ft(nominal_rocd) * 60.0
1756
+
1757
+ # Determine nan state of altitude
1758
+ isna = np.isnan(alt_ft)
1759
+
1760
+ start_na = np.empty(alt_ft.size, dtype=bool)
1761
+ start_na[:-1] = ~isna[:-1] & isna[1:]
1762
+ start_na[-1] = False
1763
+
1764
+ end_na = np.empty(alt_ft.size, dtype=bool)
1765
+ end_na[0] = False
1766
+ end_na[1:] = isna[:-1] & ~isna[1:]
1767
+
1768
+ # And get the size of each group of consecutive nan values
1769
+ start_na_idxs = np.flatnonzero(start_na)
1770
+ end_na_idxs = np.flatnonzero(end_na)
1771
+ na_group_size = end_na_idxs - start_na_idxs
1772
+
1773
+ # NOTE: Only fill altitude gaps that require special attention
1774
+ # At the end of this for loop, those with NaN altitudes will be filled with pd.interpolate
1775
+ for i in range(len(na_group_size)):
1776
+ alt_ft_start = alt_ft[start_na_idxs[i]]
1777
+ alt_ft_end = alt_ft[end_na_idxs[i]]
1778
+ time_start = time[start_na_idxs[i]]
1779
+ time_end = time[end_na_idxs[i]]
1780
+
1781
+ # Calculate parameters to determine how to interpolate altitude
1782
+ # Time to next waypoint
1783
+ dt_next = (time_end - time_start) / np.timedelta64(1, "m")
1784
+
1785
+ # (1): Unrealistic scenario: first and next known waypoints are at very
1786
+ # low altitudes with a large time gap.
1787
+ is_unrealistic = (
1788
+ dt_next > 60.0
1789
+ and alt_ft_start < minimum_cruise_altitude_ft
1790
+ and alt_ft_end < minimum_cruise_altitude_ft
1791
+ )
1792
+
1793
+ # If unrealistic, assume flight will climb to cruise altitudes (0.8 * max_altitude_ft),
1794
+ # stay there, and then descent to the next known waypoint
1795
+ if is_unrealistic:
1796
+ # Add altitude at top of climb
1797
+ alt_ft_cruise = assumed_cruise_altitude_ft
1798
+ d_alt_start = alt_ft_cruise - alt_ft_start
1799
+ dt_climb = int(np.ceil(d_alt_start / nominal_rocd_ft_min))
1800
+ t_cruise_start = time_start + np.timedelta64(dt_climb, "m")
1801
+ idx_cruise_start = np.searchsorted(time, t_cruise_start) + 1
1802
+ alt_ft[idx_cruise_start] = alt_ft_cruise
1803
+
1804
+ # Add altitude at top of descent
1805
+ d_alt_end = alt_ft_cruise - alt_ft_end
1806
+ dt_descent = int(np.ceil(d_alt_end / nominal_rocd_ft_min))
1807
+ t_cruise_end = time_end - np.timedelta64(dt_descent, "m")
1808
+ idx_cruise_end = np.searchsorted(time, t_cruise_end) - 1
1809
+ alt_ft[idx_cruise_end] = alt_ft_cruise
1810
+ continue
1811
+
1812
+ # (2): If both altitudes are the same, then skip entire operations below
1813
+ if alt_ft_start == alt_ft_end:
1814
+ continue
1815
+
1816
+ # Rate of climb and descent to next waypoint, in ft/min
1817
+ rocd_next = (alt_ft_end - alt_ft_start) / dt_next
1818
+
1819
+ # (3): If cruise over 2 h with small altitude change, set change to mid-point
1820
+ is_long_segment_small_altitude_change = (
1821
+ dt_next > 120.0
1822
+ and rocd_next < 500.0
1823
+ and rocd_next > -500.0
1824
+ and alt_ft_start > minimum_cruise_altitude_ft
1825
+ and alt_ft_end > minimum_cruise_altitude_ft
1826
+ )
1827
+
1828
+ if is_long_segment_small_altitude_change:
1829
+ mid_na_idx = int(0.5 * (start_na_idxs[i] + end_na_idxs[i]))
1830
+ alt_ft[mid_na_idx] = alt_ft_start
1831
+ alt_ft[mid_na_idx + 1] = alt_ft_end
1832
+ continue
1833
+
1834
+ # (4): Climb at the start until target altitude and level off if:
1835
+ #: (i) large time gap (`dt_next` > 20 minutes) and positive `rocd`, or
1836
+ #: (ii) shallow climb (0 < `rocd_next` < 500 ft/min) between current and next waypoint
1837
+
1838
+ #: For (i), we only perform this for large time gaps, because we do not want the aircraft to
1839
+ #: constantly climb, level off, and repeat, while the ADS-B waypoints show that it is
1840
+ #: continuously climbing
1841
+ if (dt_next > 20.0 and rocd_next > 0.0) or (0.0 < rocd_next < 500.0):
1842
+ dt_climb = int(np.ceil((alt_ft_end - alt_ft_start) / nominal_rocd_ft_min * 60))
1843
+ t_climb_complete = time_start + np.timedelta64(dt_climb, "s")
1844
+ idx_climb_complete = np.searchsorted(time, t_climb_complete)
1845
+
1846
+ #: [Safeguard for very small `dt_next`] Ensure climb can be performed within the
1847
+ #: interpolated time step. If False, then aircraft will climb between waypoints instead
1848
+ #: of levelling off.
1849
+ if start_na_idxs[i] < idx_climb_complete < end_na_idxs[i]:
1850
+ alt_ft[idx_climb_complete] = alt_ft_end
1851
+
1852
+ continue
1853
+
1854
+ # (5): Descent towards the end until target altitude and level off if:
1855
+ #: (i) large time gap (`dt_next` > 20 minutes) and negative `rocd`, or
1856
+ #: (ii) shallow descent (-250 < `rocd_next` < 0 ft/min) between current and next waypoint
1857
+ if (dt_next > 20.0 and rocd_next < 0.0) or (-250.0 < rocd_next < 0.0):
1858
+ dt_descent = int(np.ceil((alt_ft_start - alt_ft_end) / nominal_rocd_ft_min * 60))
1859
+ t_descent_start = time_end - np.timedelta64(dt_descent, "s")
1860
+ idx_descent_start = np.where(
1861
+ -250.0 < rocd_next < 0.0,
1862
+ np.searchsorted(time, t_descent_start) - 1,
1863
+ np.searchsorted(time, t_descent_start),
1864
+ )
1865
+
1866
+ #: [Safeguard for very small `dt_next`] Ensure descent can be performed within the
1867
+ #: interpolated time step. If False, then aircraft will descent between waypoints
1868
+ #: instead of levelling off.
1869
+ if start_na_idxs[i] < idx_descent_start < end_na_idxs[i]:
1870
+ alt_ft[idx_descent_start] = alt_ft_start
1871
+
1872
+ continue
1873
+
1874
+ # Linearly interpolate between remaining nan values
1875
+ out_alt_ft = pd.Series(alt_ft, index=time).interpolate(method="index")
1876
+ return units.ft_to_m(out_alt_ft.to_numpy())
1877
+
1878
+
1879
+ def filter_altitude(
1880
+ time: npt.NDArray[np.datetime64],
1881
+ altitude_ft: npt.NDArray[np.floating],
1882
+ kernel_size: int = 17,
1883
+ cruise_threshold: float = 120.0,
1884
+ air_temperature: npt.NDArray[np.floating] | None = None,
1885
+ ) -> npt.NDArray[np.floating]:
1886
+ """
1887
+ Filter noisy altitude on a single flight.
1888
+
1889
+ Currently runs altitude through a median filter using :func:`scipy.signal.medfilt`
1890
+ with ``kernel_size``, then a Savitzky-Golay filter to filter noise. The median filter
1891
+ is only applied during cruise segments that are longer than ``cruise_threshold``.
1892
+
1893
+ Parameters
1894
+ ----------
1895
+ time : npt.NDArray[np.datetime64]
1896
+ Waypoint time in ``np.datetime64`` format.
1897
+ altitude_ft : npt.NDArray[np.floating]
1898
+ Altitude signal in feet
1899
+ kernel_size : int, optional
1900
+ Passed directly to :func:`scipy.signal.medfilt`, by default 11.
1901
+ Passed also to :func:`scipy.signal.medfilt`
1902
+ cruise_threshold : float, optional
1903
+ Minimal length of time, in seconds, for a flight to be in cruise to apply median filter
1904
+ air_temperature: npt.NDArray[np.floating] | None, optional
1905
+ Air temperature of each flight waypoint, [:math:`K`]
1906
+
1907
+ Returns
1908
+ -------
1909
+ npt.NDArray[np.floating]
1910
+ Filtered altitude
1911
+
1912
+ Notes
1913
+ -----
1914
+ Algorithm is derived from :meth:`traffic.core.Flight.filter`.
1915
+
1916
+ The `traffic filter algorithm
1917
+ <https://traffic-viz.github.io/api_reference/traffic.core.flight.html#traffic.core.Flight.filter>`_
1918
+ also computes thresholds on sliding windows
1919
+ and replaces unacceptable values with NaNs.
1920
+
1921
+ Errors may raised if the ``kernel_size`` is too large.
1922
+
1923
+ See Also
1924
+ --------
1925
+ :meth:`traffic.core.Flight.filter`
1926
+ :func:`scipy.signal.medfilt`
1927
+ """
1928
+ if not len(altitude_ft):
1929
+ raise ValueError("Altitude must have non-zero length to filter")
1930
+
1931
+ # The kernel_size must be less than or equal to the number of data points available.
1932
+ kernel_size = min(kernel_size, altitude_ft.size)
1933
+
1934
+ # The kernel_size must be odd.
1935
+ if (kernel_size % 2) == 0:
1936
+ kernel_size -= 1
1937
+
1938
+ # Apply a median filter above a certain threshold
1939
+ altitude_filt = scipy.signal.medfilt(altitude_ft, kernel_size=kernel_size)
1940
+
1941
+ # Apply Savitzky-Golay filter
1942
+ altitude_filt = _sg_filter(altitude_filt, window_length=kernel_size)
1943
+
1944
+ # Remove noise manually
1945
+ # only remove above max airport elevation
1946
+ d_alt_ft = np.diff(altitude_filt, append=np.nan)
1947
+ is_noise = (np.abs(d_alt_ft) <= 25.0) & (altitude_filt > MAX_AIRPORT_ELEVATION)
1948
+ altitude_filt[is_noise] = np.round(altitude_filt[is_noise], -3)
1949
+
1950
+ # Find cruise phase in filtered profile
1951
+ seg_duration = segment_duration(time)
1952
+ seg_rocd = segment_rocd(seg_duration, altitude_filt, air_temperature)
1953
+ seg_phase = segment_phase(seg_rocd, altitude_filt)
1954
+ is_cruise = seg_phase == FlightPhase.CRUISE
1955
+
1956
+ # Compute cumulative segment time in cruise segments
1957
+ v = np.nan_to_num(seg_duration)
1958
+ v[~is_cruise] = 0.0
1959
+ n = v == 0.0
1960
+ c = np.cumsum(v)
1961
+ d = np.diff(c[n], prepend=0.0)
1962
+ v[n] = -d
1963
+ cruise_duration = np.cumsum(v)
1964
+
1965
+ # Find cruise segment start and end indices
1966
+ not_cruise = cruise_duration == 0.0
1967
+
1968
+ end_cruise = np.empty(cruise_duration.size, dtype=bool)
1969
+ end_cruise[:-1] = ~not_cruise[:-1] & not_cruise[1:]
1970
+ # if last sample is in cruise, last sample of end_cruise marks the end of a cruise segment
1971
+ end_cruise[-1] = ~not_cruise[-1]
1972
+
1973
+ start_cruise = np.empty(cruise_duration.size, dtype=bool)
1974
+ # if first sample is in cruise, first sample of start_cruise marks start of a segment
1975
+ start_cruise[0] = ~not_cruise[0]
1976
+ start_cruise[1:] = not_cruise[:-1] & ~not_cruise[1:]
1977
+
1978
+ start_idxs = np.flatnonzero(start_cruise)
1979
+ end_idxs = np.flatnonzero(end_cruise)
1980
+
1981
+ # Threshold for min cruise segment
1982
+ long_mask = cruise_duration[end_idxs] > cruise_threshold
1983
+ start_idxs = start_idxs[long_mask]
1984
+ end_idxs = end_idxs[long_mask]
1985
+
1986
+ result = np.copy(altitude_ft)
1987
+ if np.any(start_idxs):
1988
+ for i0, i1 in zip(start_idxs, end_idxs, strict=True):
1989
+ result[i0:i1] = altitude_filt[i0:i1]
1990
+
1991
+ # reapply Savitzky-Golay filter to smooth climb and descent
1992
+ return _sg_filter(result, window_length=kernel_size)
1993
+
1994
+
1995
+ def segment_duration(
1996
+ time: npt.NDArray[np.datetime64], dtype: npt.DTypeLike = np.float32
1997
+ ) -> npt.NDArray[np.floating]:
1998
+ """Calculate the time difference between waypoints.
1999
+
2000
+ ``np.nan`` appended so the length of the output is the same as number of waypoints.
2001
+
2002
+ Parameters
2003
+ ----------
2004
+ time : npt.NDArray[np.datetime64]
2005
+ Waypoint time in ``np.datetime64`` format.
2006
+ dtype : npt.DTypeLike, optional
2007
+ Numpy dtype for time difference.
2008
+ Defaults to ``np.float32``
2009
+
2010
+ Returns
2011
+ -------
2012
+ npt.NDArray[np.floating]
2013
+ Time difference between waypoints, [:math:`s`].
2014
+ This returns an array with dtype specified by ``dtype``.
2015
+ """
2016
+ out = np.empty_like(time, dtype=dtype)
2017
+ out[-1] = np.nan
2018
+ out[:-1] = np.diff(time) / np.timedelta64(1, "s")
2019
+ return out
2020
+
2021
+
2022
+ def segment_phase(
2023
+ rocd: npt.NDArray[np.floating],
2024
+ altitude_ft: npt.NDArray[np.floating],
2025
+ *,
2026
+ threshold_rocd: float = 250.0,
2027
+ min_cruise_altitude_ft: float = MIN_CRUISE_ALTITUDE,
2028
+ ) -> npt.NDArray[np.uint8]:
2029
+ """Identify the phase of flight (climb, cruise, descent) for each segment.
2030
+
2031
+ Parameters
2032
+ ----------
2033
+ rocd: npt.NDArray[np.floating]
2034
+ Rate of climb and descent across segment, [:math:`ft min^{-1}`].
2035
+ See output from :func:`segment_rocd`.
2036
+ altitude_ft: npt.NDArray[np.floating]
2037
+ Altitude, [:math:`ft`]
2038
+ threshold_rocd: float, optional
2039
+ ROCD threshold to identify climb and descent, [:math:`ft min^{-1}`].
2040
+ Defaults to 250 ft/min.
2041
+ min_cruise_altitude_ft: float, optional
2042
+ Minimum threshold altitude for cruise, [:math:`ft`]
2043
+ This is specific for each aircraft type,
2044
+ and can be approximated as 50% of the altitude ceiling.
2045
+ Defaults to :attr:`MIN_CRUISE_ALTITUDE`.
2046
+
2047
+ Returns
2048
+ -------
2049
+ npt.NDArray[np.uint8]
2050
+ Array of values enumerating the flight phase.
2051
+ See :attr:`flight.FlightPhase` for enumeration.
2052
+
2053
+ Notes
2054
+ -----
2055
+ Flight data derived from ADS-B and radar sources could contain noise leading
2056
+ to small changes in altitude and ROCD. Hence, an arbitrary ``threshold_rocd``
2057
+ is specified to identify the different phases of flight.
2058
+
2059
+ The flight phase "level-flight" is when an aircraft is holding at lower altitudes.
2060
+ The cruise phase of flight only occurs above a certain threshold altitude.
2061
+
2062
+ See Also
2063
+ --------
2064
+ :attr:`FlightPhase`
2065
+ :func:`segment_rocd`
2066
+ """
2067
+ nan = np.isnan(rocd)
2068
+ cruise = (
2069
+ (rocd < threshold_rocd) & (rocd > -threshold_rocd) & (altitude_ft > min_cruise_altitude_ft)
2070
+ )
2071
+ climb = ~cruise & (rocd > 0.0)
2072
+ descent = ~cruise & (rocd < 0.0)
2073
+ level_flight = ~(nan | cruise | climb | descent)
2074
+
2075
+ phase = np.empty(rocd.shape, dtype=np.uint8)
2076
+ phase[cruise] = FlightPhase.CRUISE
2077
+ phase[climb] = FlightPhase.CLIMB
2078
+ phase[descent] = FlightPhase.DESCENT
2079
+ phase[level_flight] = FlightPhase.LEVEL_FLIGHT
2080
+ phase[nan] = FlightPhase.NAN
2081
+
2082
+ return phase
2083
+
2084
+
2085
+ def segment_rocd(
2086
+ segment_duration: npt.NDArray[np.floating],
2087
+ altitude_ft: npt.NDArray[np.floating],
2088
+ air_temperature: npt.NDArray[np.floating] | None = None,
2089
+ ) -> npt.NDArray[np.floating]:
2090
+ """Calculate the rate of climb and descent (ROCD).
2091
+
2092
+ Parameters
2093
+ ----------
2094
+ segment_duration: npt.NDArray[np.floating]
2095
+ Time difference between waypoints, [:math:`s`].
2096
+ Expected to have numeric ``dtype``, not ``np.timedelta64``.
2097
+ See output from :func:`segment_duration`.
2098
+ altitude_ft: npt.NDArray[np.floating]
2099
+ Altitude of each waypoint, [:math:`ft`]
2100
+ air_temperature: npt.NDArray[np.floating] | None
2101
+ Air temperature of each flight waypoint, [:math:`K`]
2102
+
2103
+ Returns
2104
+ -------
2105
+ npt.NDArray[np.floating]
2106
+ Rate of climb and descent over segment, [:math:`ft min^{-1}`]
2107
+
2108
+ Notes
2109
+ -----
2110
+ The hydrostatic equation will be used to estimate the ROCD if ``air_temperature`` is provided.
2111
+ This will improve the accuracy of the estimated ROCD with a temperature correction. The
2112
+ estimated ROCD with the temperature correction are expected to differ by up to +-5% compared to
2113
+ those without the correction. These differences are important when the ROCD estimates are used
2114
+ as inputs to aircraft performance models.
2115
+
2116
+ See Also
2117
+ --------
2118
+ segment_duration
2119
+ """
2120
+ dt_min = segment_duration / 60.0
2121
+
2122
+ out = np.empty_like(altitude_ft)
2123
+ out[:-1] = np.diff(altitude_ft) / dt_min[:-1]
2124
+ out[-1] = np.nan
2125
+
2126
+ if air_temperature is None:
2127
+ return out
2128
+
2129
+ altitude_m = units.ft_to_m(altitude_ft)
2130
+ T_isa = units.m_to_T_isa(altitude_m)
2131
+
2132
+ T_correction = np.empty_like(altitude_ft)
2133
+ T_correction[:-1] = (air_temperature[:-1] + air_temperature[1:]) / (T_isa[:-1] + T_isa[1:])
2134
+ T_correction[-1] = np.nan
2135
+
2136
+ return T_correction * out
2137
+
2138
+
2139
+ def _resample_to_freq_or_time(
2140
+ df: pd.DataFrame, freq: str, time: npt.NDArray[np.datetime64] | None
2141
+ ) -> tuple[pd.DataFrame, pd.DatetimeIndex]:
2142
+ """Resample a DataFrame to a given frequency.
2143
+
2144
+ This function is used to resample a DataFrame to a given frequency or a specified set of times.
2145
+ The new index will include all the original index values and the new resampled index values.
2146
+ The "longitude" and "latitude" columns will be linearly interpolated to the new index values.
2147
+
2148
+ Parameters
2149
+ ----------
2150
+ df : pd.DataFrame
2151
+ DataFrame to resample. Assumed to have a :class:`pd.DatetimeIndex`
2152
+ and "longitude" and "latitude" columns.
2153
+ freq : str
2154
+ Frequency to resample to. See :func:`pd.DataFrame.resample` for
2155
+ valid frequency strings.
2156
+ time : npt.NDArray[np.datetime64] | None
2157
+ Times to resample to. Overrides ``freq`` if not ``None``.
2158
+
2159
+ Returns
2160
+ -------
2161
+ tuple[pd.DataFrame, pd.DatetimeIndex]
2162
+ Resampled DataFrame and the new index.
2163
+ """
2164
+
2165
+ # Manually create a new index that includes all the original index values
2166
+ # and the resampled index values
2167
+ if time is None:
2168
+ t0 = df.index[0].ceil(freq)
2169
+ t1 = df.index[-1]
2170
+ t = pd.date_range(t0, t1, freq=freq, name="time")
2171
+ else:
2172
+ mask = (time >= df.index[0]) & (time <= df.index[-1])
2173
+ t = pd.DatetimeIndex(time[mask], name="time")
2174
+
2175
+ concat_arr = np.concatenate([df.index, t])
2176
+ concat_arr = np.unique(concat_arr)
2177
+ concat_index = pd.DatetimeIndex(concat_arr, name="time", copy=False)
2178
+
2179
+ out = df.reindex(concat_index)
2180
+
2181
+ # Linearly interpolate small horizontal gap
2182
+ coords = ["longitude", "latitude"]
2183
+ out.loc[:, coords] = out.loc[:, coords].interpolate(method="index")
2184
+
2185
+ return out, t