pycontrails 0.58.0__cp314-cp314-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.
- pycontrails/__init__.py +70 -0
- pycontrails/_version.py +34 -0
- pycontrails/core/__init__.py +30 -0
- pycontrails/core/aircraft_performance.py +679 -0
- pycontrails/core/airports.py +228 -0
- pycontrails/core/cache.py +889 -0
- pycontrails/core/coordinates.py +174 -0
- pycontrails/core/fleet.py +483 -0
- pycontrails/core/flight.py +2185 -0
- pycontrails/core/flightplan.py +228 -0
- pycontrails/core/fuel.py +140 -0
- pycontrails/core/interpolation.py +702 -0
- pycontrails/core/met.py +2931 -0
- pycontrails/core/met_var.py +387 -0
- pycontrails/core/models.py +1321 -0
- pycontrails/core/polygon.py +549 -0
- pycontrails/core/rgi_cython.cp314-win_amd64.pyd +0 -0
- pycontrails/core/vector.py +2249 -0
- pycontrails/datalib/__init__.py +12 -0
- pycontrails/datalib/_met_utils/metsource.py +746 -0
- pycontrails/datalib/ecmwf/__init__.py +73 -0
- pycontrails/datalib/ecmwf/arco_era5.py +345 -0
- pycontrails/datalib/ecmwf/common.py +114 -0
- pycontrails/datalib/ecmwf/era5.py +554 -0
- pycontrails/datalib/ecmwf/era5_model_level.py +490 -0
- pycontrails/datalib/ecmwf/hres.py +804 -0
- pycontrails/datalib/ecmwf/hres_model_level.py +466 -0
- pycontrails/datalib/ecmwf/ifs.py +287 -0
- pycontrails/datalib/ecmwf/model_levels.py +435 -0
- pycontrails/datalib/ecmwf/static/model_level_dataframe_v20240418.csv +139 -0
- pycontrails/datalib/ecmwf/variables.py +268 -0
- pycontrails/datalib/geo_utils.py +261 -0
- pycontrails/datalib/gfs/__init__.py +28 -0
- pycontrails/datalib/gfs/gfs.py +656 -0
- pycontrails/datalib/gfs/variables.py +104 -0
- pycontrails/datalib/goes.py +757 -0
- pycontrails/datalib/himawari/__init__.py +27 -0
- pycontrails/datalib/himawari/header_struct.py +266 -0
- pycontrails/datalib/himawari/himawari.py +667 -0
- pycontrails/datalib/landsat.py +589 -0
- pycontrails/datalib/leo_utils/__init__.py +5 -0
- pycontrails/datalib/leo_utils/correction.py +266 -0
- pycontrails/datalib/leo_utils/landsat_metadata.py +300 -0
- pycontrails/datalib/leo_utils/search.py +250 -0
- pycontrails/datalib/leo_utils/sentinel_metadata.py +748 -0
- pycontrails/datalib/leo_utils/static/bq_roi_query.sql +6 -0
- pycontrails/datalib/leo_utils/vis.py +59 -0
- pycontrails/datalib/sentinel.py +650 -0
- pycontrails/datalib/spire/__init__.py +5 -0
- pycontrails/datalib/spire/exceptions.py +62 -0
- pycontrails/datalib/spire/spire.py +604 -0
- pycontrails/ext/bada.py +42 -0
- pycontrails/ext/cirium.py +14 -0
- pycontrails/ext/empirical_grid.py +140 -0
- pycontrails/ext/synthetic_flight.py +431 -0
- pycontrails/models/__init__.py +1 -0
- pycontrails/models/accf.py +425 -0
- pycontrails/models/apcemm/__init__.py +8 -0
- pycontrails/models/apcemm/apcemm.py +983 -0
- pycontrails/models/apcemm/inputs.py +226 -0
- pycontrails/models/apcemm/static/apcemm_yaml_template.yaml +183 -0
- pycontrails/models/apcemm/utils.py +437 -0
- pycontrails/models/cocip/__init__.py +29 -0
- pycontrails/models/cocip/cocip.py +2742 -0
- pycontrails/models/cocip/cocip_params.py +305 -0
- pycontrails/models/cocip/cocip_uncertainty.py +291 -0
- pycontrails/models/cocip/contrail_properties.py +1530 -0
- pycontrails/models/cocip/output_formats.py +2270 -0
- pycontrails/models/cocip/radiative_forcing.py +1260 -0
- pycontrails/models/cocip/radiative_heating.py +520 -0
- pycontrails/models/cocip/unterstrasser_wake_vortex.py +508 -0
- pycontrails/models/cocip/wake_vortex.py +396 -0
- pycontrails/models/cocip/wind_shear.py +120 -0
- pycontrails/models/cocipgrid/__init__.py +9 -0
- pycontrails/models/cocipgrid/cocip_grid.py +2552 -0
- pycontrails/models/cocipgrid/cocip_grid_params.py +138 -0
- pycontrails/models/dry_advection.py +602 -0
- pycontrails/models/emissions/__init__.py +21 -0
- pycontrails/models/emissions/black_carbon.py +599 -0
- pycontrails/models/emissions/emissions.py +1353 -0
- pycontrails/models/emissions/ffm2.py +336 -0
- pycontrails/models/emissions/static/default-engine-uids.csv +239 -0
- pycontrails/models/emissions/static/edb-gaseous-v29b-engines.csv +596 -0
- pycontrails/models/emissions/static/edb-nvpm-v29b-engines.csv +215 -0
- pycontrails/models/extended_k15.py +1327 -0
- pycontrails/models/humidity_scaling/__init__.py +37 -0
- pycontrails/models/humidity_scaling/humidity_scaling.py +1075 -0
- pycontrails/models/humidity_scaling/quantiles/era5-model-level-quantiles.pq +0 -0
- pycontrails/models/humidity_scaling/quantiles/era5-pressure-level-quantiles.pq +0 -0
- pycontrails/models/issr.py +210 -0
- pycontrails/models/pcc.py +326 -0
- pycontrails/models/pcr.py +154 -0
- pycontrails/models/ps_model/__init__.py +18 -0
- pycontrails/models/ps_model/ps_aircraft_params.py +381 -0
- pycontrails/models/ps_model/ps_grid.py +701 -0
- pycontrails/models/ps_model/ps_model.py +1000 -0
- pycontrails/models/ps_model/ps_operational_limits.py +525 -0
- pycontrails/models/ps_model/static/ps-aircraft-params-20250328.csv +69 -0
- pycontrails/models/ps_model/static/ps-synonym-list-20250328.csv +104 -0
- pycontrails/models/sac.py +442 -0
- pycontrails/models/tau_cirrus.py +183 -0
- pycontrails/physics/__init__.py +1 -0
- pycontrails/physics/constants.py +117 -0
- pycontrails/physics/geo.py +1138 -0
- pycontrails/physics/jet.py +968 -0
- pycontrails/physics/static/iata-cargo-load-factors-20250221.csv +74 -0
- pycontrails/physics/static/iata-passenger-load-factors-20250221.csv +74 -0
- pycontrails/physics/thermo.py +551 -0
- pycontrails/physics/units.py +472 -0
- pycontrails/py.typed +0 -0
- pycontrails/utils/__init__.py +1 -0
- pycontrails/utils/dependencies.py +66 -0
- pycontrails/utils/iteration.py +13 -0
- pycontrails/utils/json.py +187 -0
- pycontrails/utils/temp.py +50 -0
- pycontrails/utils/types.py +163 -0
- pycontrails-0.58.0.dist-info/METADATA +180 -0
- pycontrails-0.58.0.dist-info/RECORD +122 -0
- pycontrails-0.58.0.dist-info/WHEEL +5 -0
- pycontrails-0.58.0.dist-info/licenses/LICENSE +178 -0
- pycontrails-0.58.0.dist-info/licenses/NOTICE +43 -0
- pycontrails-0.58.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
|