pycontrails 0.54.6__cp313-cp313-macosx_11_0_arm64.whl → 0.54.8__cp313-cp313-macosx_11_0_arm64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of pycontrails might be problematic. Click here for more details.
- pycontrails/__init__.py +1 -1
- pycontrails/_version.py +9 -4
- pycontrails/core/aircraft_performance.py +12 -30
- pycontrails/core/airports.py +4 -1
- pycontrails/core/cache.py +4 -0
- pycontrails/core/flight.py +4 -4
- pycontrails/core/flightplan.py +10 -2
- pycontrails/core/met.py +53 -40
- pycontrails/core/met_var.py +18 -0
- pycontrails/core/models.py +79 -3
- pycontrails/core/rgi_cython.cpython-313-darwin.so +0 -0
- pycontrails/core/vector.py +74 -0
- pycontrails/datalib/spire/__init__.py +5 -0
- pycontrails/datalib/spire/exceptions.py +62 -0
- pycontrails/datalib/spire/spire.py +604 -0
- pycontrails/models/accf.py +4 -4
- pycontrails/models/cocip/cocip.py +52 -6
- pycontrails/models/cocip/cocip_params.py +10 -1
- pycontrails/models/cocip/contrail_properties.py +4 -6
- pycontrails/models/cocip/output_formats.py +12 -4
- pycontrails/models/cocip/radiative_forcing.py +2 -8
- pycontrails/models/cocip/unterstrasser_wake_vortex.py +132 -30
- pycontrails/models/cocipgrid/cocip_grid.py +14 -11
- pycontrails/models/emissions/black_carbon.py +19 -14
- pycontrails/models/emissions/emissions.py +8 -8
- pycontrails/models/humidity_scaling/humidity_scaling.py +49 -4
- pycontrails/models/ps_model/ps_aircraft_params.py +1 -1
- pycontrails/models/ps_model/ps_grid.py +22 -22
- pycontrails/models/ps_model/ps_model.py +4 -7
- pycontrails/models/ps_model/static/{ps-aircraft-params-20240524.csv → ps-aircraft-params-20250328.csv} +58 -57
- pycontrails/models/ps_model/static/{ps-synonym-list-20240524.csv → ps-synonym-list-20250328.csv} +1 -0
- pycontrails/models/tau_cirrus.py +1 -0
- pycontrails/physics/constants.py +2 -1
- pycontrails/physics/jet.py +5 -4
- pycontrails/physics/static/{iata-cargo-load-factors-20241115.csv → iata-cargo-load-factors-20250221.csv} +3 -0
- pycontrails/physics/static/{iata-passenger-load-factors-20241115.csv → iata-passenger-load-factors-20250221.csv} +3 -0
- {pycontrails-0.54.6.dist-info → pycontrails-0.54.8.dist-info}/METADATA +5 -4
- {pycontrails-0.54.6.dist-info → pycontrails-0.54.8.dist-info}/RECORD +42 -40
- {pycontrails-0.54.6.dist-info → pycontrails-0.54.8.dist-info}/WHEEL +2 -1
- {pycontrails-0.54.6.dist-info → pycontrails-0.54.8.dist-info/licenses}/NOTICE +1 -1
- pycontrails/datalib/spire.py +0 -739
- {pycontrails-0.54.6.dist-info → pycontrails-0.54.8.dist-info/licenses}/LICENSE +0 -0
- {pycontrails-0.54.6.dist-info → pycontrails-0.54.8.dist-info}/top_level.txt +0 -0
pycontrails/datalib/spire.py
DELETED
|
@@ -1,739 +0,0 @@
|
|
|
1
|
-
"""`Spire Aviation <https://spire.com/aviation/>`_ ADS-B data support."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import logging
|
|
6
|
-
|
|
7
|
-
import numpy as np
|
|
8
|
-
import pandas as pd
|
|
9
|
-
|
|
10
|
-
from pycontrails.core import flight
|
|
11
|
-
from pycontrails.physics import geo, units
|
|
12
|
-
|
|
13
|
-
logger = logging.getLogger(__name__)
|
|
14
|
-
|
|
15
|
-
#: Minimum messages to identify a single flight trajectory
|
|
16
|
-
TRAJECTORY_MINIMUM_MESSAGES = 10
|
|
17
|
-
|
|
18
|
-
#: Data types of parsed message fields
|
|
19
|
-
#: "timestamp" is excluded as its parsed by :func:`pandas.to_datetime`
|
|
20
|
-
MESSAGE_DTYPES = {
|
|
21
|
-
"icao_address": str,
|
|
22
|
-
"latitude": float,
|
|
23
|
-
"longitude": float,
|
|
24
|
-
"altitude_baro": float,
|
|
25
|
-
"heading": float,
|
|
26
|
-
"speed": float,
|
|
27
|
-
"on_ground": bool,
|
|
28
|
-
"callsign": str,
|
|
29
|
-
"tail_number": str,
|
|
30
|
-
"collection_type": str,
|
|
31
|
-
"aircraft_type_icao": str,
|
|
32
|
-
"aircraft_type_name": str,
|
|
33
|
-
"airline_iata": str,
|
|
34
|
-
"airline_name": str,
|
|
35
|
-
"departure_utc_offset": str,
|
|
36
|
-
"departure_scheduled_time": str,
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def clean(messages: pd.DataFrame) -> pd.DataFrame:
|
|
41
|
-
"""
|
|
42
|
-
Remove erroneous messages from raw Spire ADS-B data.
|
|
43
|
-
|
|
44
|
-
Copies input `messages` before modifying.
|
|
45
|
-
|
|
46
|
-
Parameters
|
|
47
|
-
----------
|
|
48
|
-
messages : pd.DataFrame
|
|
49
|
-
Raw ADS-B messages
|
|
50
|
-
|
|
51
|
-
Returns
|
|
52
|
-
-------
|
|
53
|
-
pd.DataFrame
|
|
54
|
-
ADS-B messages with erroneous data removed.
|
|
55
|
-
|
|
56
|
-
Notes
|
|
57
|
-
-----
|
|
58
|
-
This function removes:
|
|
59
|
-
|
|
60
|
-
#. Remove messages without data in "icao_address", "aircraft_type_icao",
|
|
61
|
-
"altitude_baro", "on_ground", "tail_number"
|
|
62
|
-
#. Ensure columns have the dtype as specified in :attr:`MESSAGE_DTYPES`
|
|
63
|
-
#. Remove messages with tail number "VARIOUS"
|
|
64
|
-
and aircraft types "N/A", "GNDT", "GRND", "ZZZZ"
|
|
65
|
-
#. Remove terrestrial messages without callsign.
|
|
66
|
-
Most of these messages are below 10,000 feet and from general aviation.
|
|
67
|
-
#. Remove messages when "on_ground" indicator is True, but
|
|
68
|
-
speed > :attr:`flight.MAX_ON_GROUND_SPEED` knots
|
|
69
|
-
or altitude > :attr:`flight.MAX_AIRPORT_ELEVATION` ft
|
|
70
|
-
#. Drop duplicates by "icao_address" and "timestamp"
|
|
71
|
-
"""
|
|
72
|
-
_n_messages = len(messages)
|
|
73
|
-
|
|
74
|
-
# TODO: Enable method to work without copying
|
|
75
|
-
# data multiple times
|
|
76
|
-
mdf = messages.copy()
|
|
77
|
-
|
|
78
|
-
# Remove waypoints without data in "icao_address", "aircraft_type_icao",
|
|
79
|
-
# "altitude_baro", "on_ground", "tail_number"
|
|
80
|
-
non_null_cols = [
|
|
81
|
-
"timestamp",
|
|
82
|
-
"longitude",
|
|
83
|
-
"latitude",
|
|
84
|
-
"altitude_baro",
|
|
85
|
-
"on_ground",
|
|
86
|
-
"icao_address",
|
|
87
|
-
"aircraft_type_icao",
|
|
88
|
-
"tail_number",
|
|
89
|
-
]
|
|
90
|
-
mdf = mdf.dropna(subset=non_null_cols)
|
|
91
|
-
|
|
92
|
-
# Ensure columns have the correct dtype
|
|
93
|
-
mdf = mdf.astype(MESSAGE_DTYPES)
|
|
94
|
-
|
|
95
|
-
# convert timestamp into a timezone naive pd.Timestamp
|
|
96
|
-
mdf["timestamp"] = pd.to_datetime(mdf["timestamp"]).dt.tz_localize(None)
|
|
97
|
-
|
|
98
|
-
# Remove messages with tail number set to VARIOUS
|
|
99
|
-
# or aircraft type set to "N/A", "GNDT", "GRND", "ZZZZ"
|
|
100
|
-
filt = (mdf["tail_number"] == "VARIOUS") | (
|
|
101
|
-
mdf["aircraft_type_icao"].isin(["N/A", "GNDT", "GRND", "ZZZZ"])
|
|
102
|
-
)
|
|
103
|
-
mdf = mdf[~filt]
|
|
104
|
-
|
|
105
|
-
# Remove terrestrial waypoints without callsign
|
|
106
|
-
# Most of these waypoints are below 10,000 feet and from general aviation
|
|
107
|
-
filt = (mdf["callsign"].isna()) & (mdf["collection_type"] == "terrestrial")
|
|
108
|
-
mdf = mdf[~filt]
|
|
109
|
-
|
|
110
|
-
# Fill missing callsigns for satellite records
|
|
111
|
-
callsigns_missing = (mdf["collection_type"] == "satellite") & (mdf["callsign"].isna())
|
|
112
|
-
mdf.loc[callsigns_missing, "callsign"] = None # reset values to be None
|
|
113
|
-
|
|
114
|
-
callsigns_missing_unique = mdf.loc[callsigns_missing, "icao_address"].unique()
|
|
115
|
-
|
|
116
|
-
rows_with_any_missing_callsigns = mdf.loc[
|
|
117
|
-
mdf["icao_address"].isin(callsigns_missing_unique),
|
|
118
|
-
["icao_address", "callsign", "collection_type"],
|
|
119
|
-
]
|
|
120
|
-
|
|
121
|
-
for _, gp in rows_with_any_missing_callsigns.groupby("icao_address", sort=False):
|
|
122
|
-
mdf.loc[gp.index, "callsign"] = gp["callsign"].ffill().bfill()
|
|
123
|
-
|
|
124
|
-
# Remove messages with erroneous "on_ground" indicator
|
|
125
|
-
filt = mdf["on_ground"] & (
|
|
126
|
-
(mdf["speed"] > flight.MAX_ON_GROUND_SPEED)
|
|
127
|
-
| (mdf["altitude_baro"] > flight.MAX_AIRPORT_ELEVATION)
|
|
128
|
-
)
|
|
129
|
-
mdf = mdf[~filt]
|
|
130
|
-
|
|
131
|
-
# Drop duplicates by icao_address and timestamp
|
|
132
|
-
mdf = mdf.drop_duplicates(subset=["icao_address", "timestamp"])
|
|
133
|
-
|
|
134
|
-
logger.debug(f"{len(mdf) / _n_messages:.2f} messages remain after Spire ADS-B cleanup")
|
|
135
|
-
|
|
136
|
-
return mdf.reset_index(drop=True)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def generate_flight_id(time: pd.Timestamp, callsign: str) -> str:
|
|
140
|
-
"""Generate a unique flight id for instance of flight.
|
|
141
|
-
|
|
142
|
-
Parameters
|
|
143
|
-
----------
|
|
144
|
-
time : pd.Timestamp
|
|
145
|
-
First waypoint time associated with flight.
|
|
146
|
-
callsign : str
|
|
147
|
-
Callsign of the flight.
|
|
148
|
-
Other flight identifiers could be used if the callsign
|
|
149
|
-
is not defined.
|
|
150
|
-
|
|
151
|
-
Returns
|
|
152
|
-
-------
|
|
153
|
-
str
|
|
154
|
-
Flight id in the form "{%Y%m%d-%H%M}-{callsign}"
|
|
155
|
-
"""
|
|
156
|
-
t_string = time.strftime("%Y%m%d-%H%M")
|
|
157
|
-
return f"{t_string}-{callsign}"
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def identify_flights(messages: pd.DataFrame) -> pd.Series:
|
|
161
|
-
"""Identify unique flights from Spire ADS-B messages.
|
|
162
|
-
|
|
163
|
-
Parameters
|
|
164
|
-
----------
|
|
165
|
-
messages : pd.DataFrame
|
|
166
|
-
Cleaned ADS-B messages,
|
|
167
|
-
as output from :func:`clean`
|
|
168
|
-
|
|
169
|
-
Returns
|
|
170
|
-
-------
|
|
171
|
-
pd.Series
|
|
172
|
-
Flight ids for the same index as `messages`
|
|
173
|
-
|
|
174
|
-
Notes
|
|
175
|
-
-----
|
|
176
|
-
The algorithm groups flights initially on "icao_address".
|
|
177
|
-
|
|
178
|
-
For each group:
|
|
179
|
-
|
|
180
|
-
#. Fill callsign for satellite messages
|
|
181
|
-
#. Group again by "tail_number", "aircraft_type_icao", "callsign"
|
|
182
|
-
#. Remove flights with less than :attr:`TRAJECTORY_MINIMUM_MESSAGES` messages
|
|
183
|
-
#. Separate flights by "on_ground" indicator. See `_separate_by_on_ground`.
|
|
184
|
-
#. Separate flights by cruise phase. See `_separate_by_cruise_phase`.
|
|
185
|
-
|
|
186
|
-
See Also
|
|
187
|
-
--------
|
|
188
|
-
:func:`clean`
|
|
189
|
-
:class:`Spire`
|
|
190
|
-
:func:`_separate_on_ground`
|
|
191
|
-
:func:`_separate_by_cruise_phase`
|
|
192
|
-
"""
|
|
193
|
-
|
|
194
|
-
# Set default flight id
|
|
195
|
-
flight_id = pd.Series(
|
|
196
|
-
data=None,
|
|
197
|
-
dtype=object,
|
|
198
|
-
index=messages.index,
|
|
199
|
-
)
|
|
200
|
-
|
|
201
|
-
for idx, gp in messages[
|
|
202
|
-
[
|
|
203
|
-
"icao_address",
|
|
204
|
-
"tail_number",
|
|
205
|
-
"aircraft_type_icao",
|
|
206
|
-
"callsign",
|
|
207
|
-
"timestamp",
|
|
208
|
-
"longitude",
|
|
209
|
-
"latitude",
|
|
210
|
-
"altitude_baro",
|
|
211
|
-
"on_ground",
|
|
212
|
-
]
|
|
213
|
-
].groupby(["icao_address", "tail_number", "aircraft_type_icao", "callsign"], sort=False):
|
|
214
|
-
# minimum # of messages > TRAJECTORY_MINIMUM_MESSAGES
|
|
215
|
-
if len(gp) < TRAJECTORY_MINIMUM_MESSAGES:
|
|
216
|
-
logger.debug(f"Message {idx} group too small to create flight ids")
|
|
217
|
-
continue
|
|
218
|
-
|
|
219
|
-
# TODO: this altitude cleanup does not persist back into messages
|
|
220
|
-
# this should get moved into flight module
|
|
221
|
-
gp = _clean_trajectory_altitude(gp)
|
|
222
|
-
|
|
223
|
-
# separate flights by "on_ground" column
|
|
224
|
-
gp["flight_id"] = _separate_by_on_ground(gp)
|
|
225
|
-
|
|
226
|
-
# further separate flights by cruise phase analysis
|
|
227
|
-
for _, fl in gp.groupby("flight_id"):
|
|
228
|
-
gp.loc[fl.index, "flight_id"] = _separate_by_cruise_phase(fl)
|
|
229
|
-
|
|
230
|
-
# save flight ids
|
|
231
|
-
flight_id.loc[gp.index] = gp["flight_id"]
|
|
232
|
-
|
|
233
|
-
return flight_id
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
def _clean_trajectory_altitude(messages: pd.DataFrame) -> pd.DataFrame:
|
|
237
|
-
"""
|
|
238
|
-
Clean erroneous and noisy altitude on a single flight.
|
|
239
|
-
|
|
240
|
-
TODO: move this to Flight
|
|
241
|
-
|
|
242
|
-
Parameters
|
|
243
|
-
----------
|
|
244
|
-
messages: pd.DataFrame
|
|
245
|
-
ADS-B messages from a single flight trajectory.
|
|
246
|
-
|
|
247
|
-
Returns
|
|
248
|
-
-------
|
|
249
|
-
pd.DataFrame
|
|
250
|
-
ADS-B messages with filtered altitude.
|
|
251
|
-
|
|
252
|
-
Notes
|
|
253
|
-
-----
|
|
254
|
-
#. If ``pycontrails.ext.bada`` installed,
|
|
255
|
-
remove erroneous altitude, i.e., altitude above operating limit of aircraft type
|
|
256
|
-
#. Filter altitude signal to remove noise and
|
|
257
|
-
snap cruise altitudes to 1000 ft intervals
|
|
258
|
-
|
|
259
|
-
See Also
|
|
260
|
-
--------
|
|
261
|
-
:func:`flight.filter_altitude`
|
|
262
|
-
"""
|
|
263
|
-
mdf = messages.copy()
|
|
264
|
-
|
|
265
|
-
# Use BADA 3 to support filtering
|
|
266
|
-
try:
|
|
267
|
-
from pycontrails.ext.bada import BADA3
|
|
268
|
-
|
|
269
|
-
bada3 = BADA3()
|
|
270
|
-
aircraft_type_icao = (
|
|
271
|
-
mdf["aircraft_type_icao"].iloc[0]
|
|
272
|
-
if len(mdf["aircraft_type_icao"]) > 1
|
|
273
|
-
else mdf["aircraft_type_icao"]
|
|
274
|
-
)
|
|
275
|
-
|
|
276
|
-
# Try remove aircraft types not covered by BADA 3 (mainly helicopters)
|
|
277
|
-
mdf = mdf.loc[mdf["aircraft_type_icao"].isin(bada3.synonym_dict)]
|
|
278
|
-
|
|
279
|
-
# Remove erroneous altitude, i.e., altitude above operating limit of aircraft type
|
|
280
|
-
bada_max_altitude_ft = bada3.ptf_param_dict[aircraft_type_icao].max_altitude_ft
|
|
281
|
-
is_above_ceiling = mdf["altitude_baro"] > bada_max_altitude_ft
|
|
282
|
-
mdf = mdf.loc[~is_above_ceiling]
|
|
283
|
-
|
|
284
|
-
# Set the min cruise altitude to 0.5 * "max_altitude_ft"
|
|
285
|
-
min_cruise_altitude_ft = 0.5 * bada_max_altitude_ft
|
|
286
|
-
|
|
287
|
-
except (ImportError, FileNotFoundError, KeyError):
|
|
288
|
-
min_cruise_altitude_ft = flight.MIN_CRUISE_ALTITUDE
|
|
289
|
-
|
|
290
|
-
# Filter altitude signal
|
|
291
|
-
# See https://traffic-viz.github.io/api_reference/traffic.core.flight.html#traffic.core.Flight.filter
|
|
292
|
-
mdf["altitude_baro"] = flight.filter_altitude(mdf["timestamp"], mdf["altitude_baro"].to_numpy())
|
|
293
|
-
|
|
294
|
-
# Snap altitudes in cruise to the nearest flight level.
|
|
295
|
-
# Requires segment phase
|
|
296
|
-
altitude_ft = mdf["altitude_baro"].to_numpy()
|
|
297
|
-
segment_duration = flight.segment_duration(mdf["timestamp"].to_numpy())
|
|
298
|
-
segment_rocd = flight.segment_rocd(segment_duration, altitude_ft)
|
|
299
|
-
segment_phase = flight.segment_phase(
|
|
300
|
-
segment_rocd,
|
|
301
|
-
altitude_ft,
|
|
302
|
-
min_cruise_altitude_ft=min_cruise_altitude_ft,
|
|
303
|
-
)
|
|
304
|
-
is_cruise = segment_phase == flight.FlightPhase.CRUISE
|
|
305
|
-
mdf.loc[is_cruise, "altitude_baro"] = np.round(altitude_ft[is_cruise], -3)
|
|
306
|
-
|
|
307
|
-
return mdf
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
def _separate_by_on_ground(messages: pd.DataFrame) -> pd.Series:
|
|
311
|
-
"""Separate individual flights by "on_ground" column.
|
|
312
|
-
|
|
313
|
-
The input ``messages`` are expected to grouped by "icao_address", "tail_number",
|
|
314
|
-
"aircraft_type_icao", "callsign".
|
|
315
|
-
|
|
316
|
-
Parameters
|
|
317
|
-
----------
|
|
318
|
-
messages : pd.DataFrame
|
|
319
|
-
Messages grouped by "icao_address", "tail_number",
|
|
320
|
-
"aircraft_type_icao", "callsign".
|
|
321
|
-
Must contain the "on_ground" and "altitude_baro" columns.
|
|
322
|
-
|
|
323
|
-
Returns
|
|
324
|
-
-------
|
|
325
|
-
pd.Series
|
|
326
|
-
Flight ids for the same index as `messages`
|
|
327
|
-
"""
|
|
328
|
-
# Set default flight id
|
|
329
|
-
try:
|
|
330
|
-
flight_id = messages["flight_id"]
|
|
331
|
-
except KeyError:
|
|
332
|
-
flight_id = pd.Series(
|
|
333
|
-
data=None,
|
|
334
|
-
dtype=object,
|
|
335
|
-
index=messages.index,
|
|
336
|
-
)
|
|
337
|
-
|
|
338
|
-
# make sure aircraft is actually on ground
|
|
339
|
-
# TODO: use DEM for ground position?
|
|
340
|
-
is_on_ground = messages["on_ground"] & (
|
|
341
|
-
messages["altitude_baro"] < flight.MAX_AIRPORT_ELEVATION
|
|
342
|
-
)
|
|
343
|
-
|
|
344
|
-
# filter this signal so that it removes isolated 1-2 wide variations
|
|
345
|
-
is_on_ground = is_on_ground.rolling(window=5).mean().bfill() > 0.5
|
|
346
|
-
|
|
347
|
-
# find end of flight indexes using "on_ground"
|
|
348
|
-
end_of_flight = (~is_on_ground).astype(int).diff(periods=-1) == -1
|
|
349
|
-
|
|
350
|
-
# identify each individual flight using cumsum magic
|
|
351
|
-
for _, gp in messages.groupby(end_of_flight.cumsum()):
|
|
352
|
-
flight_id.loc[gp.index] = generate_flight_id(
|
|
353
|
-
gp["timestamp"].iloc[0], gp["callsign"].iloc[0]
|
|
354
|
-
)
|
|
355
|
-
|
|
356
|
-
return flight_id
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
def _separate_by_cruise_phase(messages: pd.DataFrame) -> pd.Series:
|
|
360
|
-
"""
|
|
361
|
-
Separate flights by multiple cruise phases.
|
|
362
|
-
|
|
363
|
-
The input ``messages`` are expected to grouped by "icao_address", "tail_number",
|
|
364
|
-
"aircraft_type_icao", "callsign".
|
|
365
|
-
|
|
366
|
-
Its strongly encouraged to run :func:`flight.filter_altitude(...)` over the messages
|
|
367
|
-
before passing into this function.
|
|
368
|
-
|
|
369
|
-
Parameters
|
|
370
|
-
----------
|
|
371
|
-
messages : pd.DataFrame
|
|
372
|
-
Messages grouped by "icao_address", "tail_number",
|
|
373
|
-
"aircraft_type_icao", "callsign".
|
|
374
|
-
Must contain the "on_ground" and "altitude_baro" columns.
|
|
375
|
-
|
|
376
|
-
Returns
|
|
377
|
-
-------
|
|
378
|
-
pd.Series
|
|
379
|
-
Integer series for each unique flight with same index as "messages"
|
|
380
|
-
|
|
381
|
-
Notes
|
|
382
|
-
-----
|
|
383
|
-
Flights with multiple cruise phases are identified by evaluating the
|
|
384
|
-
messages between the start and end of the cruise phase.
|
|
385
|
-
|
|
386
|
-
For these messages that should be at cruise, multiple cruise phases are identified when:
|
|
387
|
-
|
|
388
|
-
#. Altitudes fall below the minimum cruise altitude.
|
|
389
|
-
If pycontrails.ext.bada is installed, this value is set to half
|
|
390
|
-
the BADA3 altitude ceiling of the aircraft.
|
|
391
|
-
If not, this value is set to :attr:`flight.MIN_CRUISE_ALTITUDE`.
|
|
392
|
-
#. There is a time difference > 15 minutes between messages.
|
|
393
|
-
|
|
394
|
-
If multiple flights are identified,
|
|
395
|
-
the cut-off point is specified at messages with the largest time difference.
|
|
396
|
-
|
|
397
|
-
Flight "diversion" is defined when the aircraft descends below 10,000 feet
|
|
398
|
-
and climbs back to cruise altitude to travel to the alternative airport.
|
|
399
|
-
A diversion is identified when all five conditions below are satisfied:
|
|
400
|
-
|
|
401
|
-
#. Altitude in any messages between
|
|
402
|
-
the start and end of cruise is < :attr:`flight.MAX_AIRPORT_ELEVATION` ft
|
|
403
|
-
#. Time difference between messages that should be
|
|
404
|
-
at cruise must be < 15 minutes (continuous telemetry)
|
|
405
|
-
#. Segment length between messages that should be
|
|
406
|
-
at cruise must be > 500 m (no stationary messages),
|
|
407
|
-
#. Time elapsed between message with the lowest altitude
|
|
408
|
-
(during cruise) and final message should be < 2 h,
|
|
409
|
-
#. No messages should be on the ground between the start and end of cruise.
|
|
410
|
-
"""
|
|
411
|
-
|
|
412
|
-
# Set default flight id
|
|
413
|
-
try:
|
|
414
|
-
flight_id = messages["flight_id"].copy()
|
|
415
|
-
except KeyError:
|
|
416
|
-
flight_id = pd.Series(
|
|
417
|
-
data=generate_flight_id(messages["timestamp"].iloc[0], messages["callsign"].iloc[0]),
|
|
418
|
-
dtype=object,
|
|
419
|
-
index=messages.index,
|
|
420
|
-
)
|
|
421
|
-
|
|
422
|
-
# Use BADA 3 to calculate max altitude
|
|
423
|
-
try:
|
|
424
|
-
from pycontrails.ext.bada import BADA3
|
|
425
|
-
|
|
426
|
-
bada3 = BADA3()
|
|
427
|
-
aircraft_type_icao = (
|
|
428
|
-
messages["aircraft_type_icao"].iloc[0]
|
|
429
|
-
if len(messages["aircraft_type_icao"]) > 1
|
|
430
|
-
else messages["aircraft_type_icao"]
|
|
431
|
-
)
|
|
432
|
-
|
|
433
|
-
# Set the min cruise altitude to 0.5 * "max_altitude_ft"
|
|
434
|
-
bada_max_altitude_ft = bada3.ptf_param_dict[aircraft_type_icao].max_altitude_ft
|
|
435
|
-
min_cruise_altitude_ft = 0.5 * bada_max_altitude_ft
|
|
436
|
-
|
|
437
|
-
except (ImportError, FileNotFoundError, KeyError):
|
|
438
|
-
min_cruise_altitude_ft = flight.MIN_CRUISE_ALTITUDE
|
|
439
|
-
|
|
440
|
-
# Calculate flight phase
|
|
441
|
-
altitude_ft = messages["altitude_baro"].to_numpy()
|
|
442
|
-
segment_duration = flight.segment_duration(messages["timestamp"].to_numpy())
|
|
443
|
-
segment_rocd = flight.segment_rocd(segment_duration, altitude_ft)
|
|
444
|
-
segment_phase = flight.segment_phase(
|
|
445
|
-
segment_rocd,
|
|
446
|
-
altitude_ft,
|
|
447
|
-
threshold_rocd=250,
|
|
448
|
-
min_cruise_altitude_ft=min_cruise_altitude_ft,
|
|
449
|
-
)
|
|
450
|
-
|
|
451
|
-
# get cruise phase
|
|
452
|
-
cruise = segment_phase == flight.FlightPhase.CRUISE
|
|
453
|
-
|
|
454
|
-
# fill between the first and last cruise phase indicator
|
|
455
|
-
# this represents the flight phase after takeoff climb and before descent to landing
|
|
456
|
-
# Index of first and final cruise waypoint
|
|
457
|
-
# i_cruise_start = np.min(np.argwhere(flight_phase.cruise))
|
|
458
|
-
# i_cruise_end = min(np.max(np.argwhere(flight_phase.cruise)), len(flight_phase.cruise))
|
|
459
|
-
within_cruise = np.bitwise_xor.accumulate(cruise) | cruise
|
|
460
|
-
|
|
461
|
-
# There should not be any waypoints with low altitudes between
|
|
462
|
-
# the start and end of `within_cruise`
|
|
463
|
-
is_low_altitude = altitude_ft < min_cruise_altitude_ft
|
|
464
|
-
is_long_interval = segment_duration > (15 * 60) # 15 minutes
|
|
465
|
-
anomalous_phase = within_cruise & is_low_altitude & is_long_interval
|
|
466
|
-
multiple_cruise_phase = np.any(anomalous_phase)
|
|
467
|
-
|
|
468
|
-
# if there is only one cruise phase, just return one label
|
|
469
|
-
if not multiple_cruise_phase:
|
|
470
|
-
return flight_id
|
|
471
|
-
|
|
472
|
-
# Check for presence of a diverted flight
|
|
473
|
-
potentially_diverted = within_cruise & is_low_altitude
|
|
474
|
-
if np.any(potentially_diverted):
|
|
475
|
-
# Calculate segment length
|
|
476
|
-
segment_length = geo.segment_length(
|
|
477
|
-
messages["longitude"].to_numpy(),
|
|
478
|
-
messages["latitude"].to_numpy(),
|
|
479
|
-
units.ft_to_m(altitude_ft),
|
|
480
|
-
)
|
|
481
|
-
|
|
482
|
-
# get the index of the minimum altitude when potentially diverted
|
|
483
|
-
altitude_min_diverted = np.min(altitude_ft[potentially_diverted])
|
|
484
|
-
mask = within_cruise & is_low_altitude & (altitude_ft == altitude_min_diverted)
|
|
485
|
-
i_lowest_altitude = np.flatnonzero(mask)[0] + 1
|
|
486
|
-
|
|
487
|
-
# get reference to "on_ground"
|
|
488
|
-
ground_indicator = messages["on_ground"].to_numpy()
|
|
489
|
-
|
|
490
|
-
# Check for flight diversion
|
|
491
|
-
condition_1 = np.any(altitude_ft[within_cruise] < flight.MAX_AIRPORT_ELEVATION)
|
|
492
|
-
condition_2 = np.all(segment_duration[within_cruise] < (15.0 * 60.0))
|
|
493
|
-
condition_3 = np.all(segment_length[within_cruise] > 500.0)
|
|
494
|
-
condition_4 = np.nansum(segment_duration[i_lowest_altitude:]) < (2.0 * 60.0 * 60.0)
|
|
495
|
-
condition_5 = np.all(~ground_indicator[within_cruise])
|
|
496
|
-
|
|
497
|
-
flight_diversion = condition_1 & condition_2 & condition_3 & condition_4 & condition_5
|
|
498
|
-
|
|
499
|
-
# if there is a potential flight diversion, just return a single flight index
|
|
500
|
-
if flight_diversion:
|
|
501
|
-
return flight_id
|
|
502
|
-
|
|
503
|
-
# If there are multiple cruise phases, get cut-off point
|
|
504
|
-
i_cutoff = int(np.argmax(segment_duration[anomalous_phase]) + 1)
|
|
505
|
-
|
|
506
|
-
# assign a new id after cutoff
|
|
507
|
-
flight_id.iloc[i_cutoff:] = generate_flight_id(
|
|
508
|
-
messages["timestamp"].iloc[i_cutoff], messages["callsign"].iloc[i_cutoff]
|
|
509
|
-
)
|
|
510
|
-
|
|
511
|
-
return flight_id
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
def validate_flights(messages: pd.DataFrame) -> pd.Series:
|
|
515
|
-
"""Validate unique flights from Spire ADS-B messages.
|
|
516
|
-
|
|
517
|
-
Parameters
|
|
518
|
-
----------
|
|
519
|
-
messages : pd.DataFrame
|
|
520
|
-
Messages that has been assigned flight ids by :func:`identify_flights`.
|
|
521
|
-
Requires "flight_id" column to be defined in messages
|
|
522
|
-
|
|
523
|
-
Returns
|
|
524
|
-
-------
|
|
525
|
-
pd.Series
|
|
526
|
-
Boolean array of `flight_id` validity with the same index as `messages`
|
|
527
|
-
|
|
528
|
-
Notes
|
|
529
|
-
-----
|
|
530
|
-
See :func:`is_valid_trajectory` docstring for validation criteria
|
|
531
|
-
|
|
532
|
-
See Also
|
|
533
|
-
--------
|
|
534
|
-
:func:`is_valid_trajectory`
|
|
535
|
-
:func:`identify_flights`
|
|
536
|
-
"""
|
|
537
|
-
if "flight_id" not in messages:
|
|
538
|
-
raise KeyError("'flight_id' column required in messages")
|
|
539
|
-
|
|
540
|
-
# Set default flight id
|
|
541
|
-
valid = pd.Series(
|
|
542
|
-
data=False,
|
|
543
|
-
dtype=bool,
|
|
544
|
-
index=messages.index,
|
|
545
|
-
)
|
|
546
|
-
|
|
547
|
-
for _, gp in messages[
|
|
548
|
-
[
|
|
549
|
-
"flight_id",
|
|
550
|
-
"aircraft_type_icao",
|
|
551
|
-
"timestamp",
|
|
552
|
-
"altitude_baro",
|
|
553
|
-
"on_ground",
|
|
554
|
-
"speed",
|
|
555
|
-
]
|
|
556
|
-
].groupby("flight_id", sort=False):
|
|
557
|
-
# save flight ids
|
|
558
|
-
valid.loc[gp.index] = is_valid_trajectory(gp)
|
|
559
|
-
|
|
560
|
-
return valid
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
def is_valid_trajectory(
|
|
564
|
-
messages: pd.DataFrame,
|
|
565
|
-
*,
|
|
566
|
-
minimum_messages: int = TRAJECTORY_MINIMUM_MESSAGES,
|
|
567
|
-
final_time_available: pd.Timestamp | None = None,
|
|
568
|
-
) -> bool:
|
|
569
|
-
"""
|
|
570
|
-
Ensure messages likely contain only one unique flight trajectory.
|
|
571
|
-
|
|
572
|
-
Parameters
|
|
573
|
-
----------
|
|
574
|
-
messages: pd.DataFrame
|
|
575
|
-
ADS-B messages from a single flight trajectory.
|
|
576
|
-
minimum_messages: int, optional
|
|
577
|
-
Minimum number of messages required for trajectory to be accepted.
|
|
578
|
-
Defaults to :attr:`TRAJECTORY_MINIMUM_MESSAGES`
|
|
579
|
-
final_time_available: pd.Timestamp, optional
|
|
580
|
-
Time of the final recorded ADS-B message available.
|
|
581
|
-
Relaxes the criteria for flight completion (see *Notes*).
|
|
582
|
-
|
|
583
|
-
Returns
|
|
584
|
-
-------
|
|
585
|
-
bool
|
|
586
|
-
Boolean indicating if messages constitute a single valid trajectory
|
|
587
|
-
|
|
588
|
-
Notes
|
|
589
|
-
-----
|
|
590
|
-
The inputs messages must satisfy all
|
|
591
|
-
the following conditions to validate the flight trajectory:
|
|
592
|
-
|
|
593
|
-
#. Must contain more than :attr:`TRAJECTORY_MINIMUM_MESSAGES`
|
|
594
|
-
#. Trajectory must contain a `cruise` phase as defined by :func:`flight.segment_phase`
|
|
595
|
-
#. Trajectory must not go below the minimum cruising altitude throughout the cruise phase.
|
|
596
|
-
#. A trajectory must be "complete", defined when one of these conditions are satisfied:
|
|
597
|
-
- Final message is on the ground, altitude < :attr:`flight.MAX_AIRPORT_ELEVATION` feet
|
|
598
|
-
and speed < :attr:`flight.MAX_ON_GROUND_SPEED` knots
|
|
599
|
-
- Final message is < :attr:`flight.MAX_AIRPORT_ELEVATION` feet, in descent,
|
|
600
|
-
and > 2 h have passed since `final_time_available`.
|
|
601
|
-
- At least 12 h have passed since `final_time_available`
|
|
602
|
-
(remaining trajectory might not be recorded).
|
|
603
|
-
|
|
604
|
-
Trajectory is not valid if any criteria is False.
|
|
605
|
-
"""
|
|
606
|
-
|
|
607
|
-
# Use BADA 3 to calculate min cruise altitude
|
|
608
|
-
try:
|
|
609
|
-
from pycontrails.ext.bada import BADA3
|
|
610
|
-
|
|
611
|
-
bada3 = BADA3()
|
|
612
|
-
aircraft_type_icao = (
|
|
613
|
-
messages["aircraft_type_icao"].iloc[0]
|
|
614
|
-
if len(messages["aircraft_type_icao"]) > 1
|
|
615
|
-
else messages["aircraft_type_icao"]
|
|
616
|
-
)
|
|
617
|
-
|
|
618
|
-
# Remove erroneous altitude, i.e., altitude above operating limit of aircraft type
|
|
619
|
-
altitude_ceiling_ft = bada3.ptf_param_dict[aircraft_type_icao].max_altitude_ft
|
|
620
|
-
min_cruise_altitude_ft = 0.5 * altitude_ceiling_ft
|
|
621
|
-
|
|
622
|
-
except (ImportError, FileNotFoundError, KeyError):
|
|
623
|
-
min_cruise_altitude_ft = flight.MIN_CRUISE_ALTITUDE
|
|
624
|
-
|
|
625
|
-
# Flight duration
|
|
626
|
-
segment_duration = flight.segment_duration(messages["timestamp"].to_numpy())
|
|
627
|
-
is_short_haul = np.nansum(segment_duration).item() < flight.SHORT_HAUL_DURATION
|
|
628
|
-
|
|
629
|
-
# Flight phase
|
|
630
|
-
altitude_ft = messages["altitude_baro"].to_numpy()
|
|
631
|
-
segment_rocd = flight.segment_rocd(segment_duration, altitude_ft)
|
|
632
|
-
segment_phase = flight.segment_phase(
|
|
633
|
-
segment_rocd,
|
|
634
|
-
altitude_ft,
|
|
635
|
-
threshold_rocd=250.0,
|
|
636
|
-
min_cruise_altitude_ft=min_cruise_altitude_ft,
|
|
637
|
-
)
|
|
638
|
-
|
|
639
|
-
# Find any anomalous messages with low altitudes between
|
|
640
|
-
# the start and end of the cruise phase
|
|
641
|
-
# See `_separate_by_cruise_phase` for more comments on logic
|
|
642
|
-
cruise = segment_phase == flight.FlightPhase.CRUISE
|
|
643
|
-
within_cruise = np.bitwise_xor.accumulate(cruise) | cruise
|
|
644
|
-
is_low_altitude = altitude_ft < min_cruise_altitude_ft
|
|
645
|
-
anomalous_phase = within_cruise & is_low_altitude
|
|
646
|
-
|
|
647
|
-
# Validate flight trajectory
|
|
648
|
-
has_enough_messages = len(messages) > minimum_messages
|
|
649
|
-
has_cruise_phase = np.any(segment_phase == flight.FlightPhase.CRUISE).item()
|
|
650
|
-
has_no_anomalous_phase = np.any(anomalous_phase).item()
|
|
651
|
-
|
|
652
|
-
# Relax constraint for short-haul flights
|
|
653
|
-
if is_short_haul and (not has_cruise_phase) and (not has_no_anomalous_phase):
|
|
654
|
-
has_cruise_phase = np.any(segment_phase == flight.FlightPhase.LEVEL_FLIGHT).item()
|
|
655
|
-
has_no_anomalous_phase = True
|
|
656
|
-
|
|
657
|
-
if not (has_enough_messages and has_cruise_phase and has_no_anomalous_phase):
|
|
658
|
-
return False
|
|
659
|
-
|
|
660
|
-
# Check that flight is complete
|
|
661
|
-
# First option is the flight is on the ground and low
|
|
662
|
-
final_message = messages.iloc[-1]
|
|
663
|
-
complete_1 = (
|
|
664
|
-
final_message["on_ground"]
|
|
665
|
-
& (final_message["altitude_baro"] < flight.MAX_AIRPORT_ELEVATION)
|
|
666
|
-
& (final_message["speed"] < flight.MAX_ON_GROUND_SPEED)
|
|
667
|
-
)
|
|
668
|
-
|
|
669
|
-
# Second option is the flight is in descent and 2 hours of data are available after
|
|
670
|
-
if final_time_available:
|
|
671
|
-
is_descent = np.any(segment_phase[-5:] == flight.FlightPhase.DESCENT) | np.any(
|
|
672
|
-
segment_phase[-5:] == flight.FlightPhase.LEVEL_FLIGHT
|
|
673
|
-
)
|
|
674
|
-
elapsed_time_hrs = (final_time_available - final_message["timestamp"]) / np.timedelta64(
|
|
675
|
-
1, "h"
|
|
676
|
-
)
|
|
677
|
-
complete_2 = (
|
|
678
|
-
(final_message["altitude_baro"] < flight.MAX_AIRPORT_ELEVATION)
|
|
679
|
-
& is_descent
|
|
680
|
-
& (elapsed_time_hrs > 2.0)
|
|
681
|
-
)
|
|
682
|
-
|
|
683
|
-
# Third option is 12 hours of data are available after
|
|
684
|
-
complete_3 = elapsed_time_hrs > 12.0
|
|
685
|
-
else:
|
|
686
|
-
complete_2 = False
|
|
687
|
-
complete_3 = False
|
|
688
|
-
|
|
689
|
-
# Complete is defined as one of these criteria being satisfied
|
|
690
|
-
is_complete = complete_1 | complete_2 | complete_3
|
|
691
|
-
|
|
692
|
-
return has_enough_messages and has_cruise_phase and has_no_anomalous_phase and is_complete
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
def _downsample_flight(
|
|
696
|
-
messages: pd.DataFrame,
|
|
697
|
-
*,
|
|
698
|
-
time_resolution: str | pd.DateOffset | pd.Timedelta = "10s",
|
|
699
|
-
) -> pd.DataFrame:
|
|
700
|
-
"""
|
|
701
|
-
Downsample ADS-B messages to a specified time resolution.
|
|
702
|
-
|
|
703
|
-
.. warning::
|
|
704
|
-
This function is not used.
|
|
705
|
-
Use :meth:`flight.Flight.resample_and_fill` after creating `Flight`
|
|
706
|
-
instead.
|
|
707
|
-
|
|
708
|
-
.. note::
|
|
709
|
-
This function does not interpolate when upsampling.
|
|
710
|
-
Nan values will be dropped.
|
|
711
|
-
|
|
712
|
-
Parameters
|
|
713
|
-
----------
|
|
714
|
-
messages: pd.DataFrame
|
|
715
|
-
ADS-B messages from a single flight trajectory.
|
|
716
|
-
time_resolution: str | pd.DateOffset | pd.Timedelta
|
|
717
|
-
Downsampled time resolution.
|
|
718
|
-
Any input compatible with :meth:`pandas.DataFrame.resample`.
|
|
719
|
-
Defaults to "10s" (10 seconds).
|
|
720
|
-
|
|
721
|
-
Returns
|
|
722
|
-
-------
|
|
723
|
-
pd.DataFrame
|
|
724
|
-
Downsampled ADS-B messages for a single flight trajectory.
|
|
725
|
-
|
|
726
|
-
See Also
|
|
727
|
-
--------
|
|
728
|
-
:meth:`pandas.DataFrame.resample`
|
|
729
|
-
:meth:`flight.Flight.resample_and_fill`
|
|
730
|
-
"""
|
|
731
|
-
mdf = messages.copy()
|
|
732
|
-
mdf = mdf.set_index("timestamp", drop=False)
|
|
733
|
-
resampled = mdf.resample(time_resolution).first()
|
|
734
|
-
|
|
735
|
-
# remove rows that do not align with a previous time
|
|
736
|
-
resampled = resampled.loc[resampled["longitude"].notna()]
|
|
737
|
-
|
|
738
|
-
# reset original index and return
|
|
739
|
-
return resampled.reset_index(drop=True)
|