pycontrails 0.53.0__cp313-cp313-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.

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