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.

Files changed (43) hide show
  1. pycontrails/__init__.py +1 -1
  2. pycontrails/_version.py +9 -4
  3. pycontrails/core/aircraft_performance.py +12 -30
  4. pycontrails/core/airports.py +4 -1
  5. pycontrails/core/cache.py +4 -0
  6. pycontrails/core/flight.py +4 -4
  7. pycontrails/core/flightplan.py +10 -2
  8. pycontrails/core/met.py +53 -40
  9. pycontrails/core/met_var.py +18 -0
  10. pycontrails/core/models.py +79 -3
  11. pycontrails/core/rgi_cython.cpython-313-darwin.so +0 -0
  12. pycontrails/core/vector.py +74 -0
  13. pycontrails/datalib/spire/__init__.py +5 -0
  14. pycontrails/datalib/spire/exceptions.py +62 -0
  15. pycontrails/datalib/spire/spire.py +604 -0
  16. pycontrails/models/accf.py +4 -4
  17. pycontrails/models/cocip/cocip.py +52 -6
  18. pycontrails/models/cocip/cocip_params.py +10 -1
  19. pycontrails/models/cocip/contrail_properties.py +4 -6
  20. pycontrails/models/cocip/output_formats.py +12 -4
  21. pycontrails/models/cocip/radiative_forcing.py +2 -8
  22. pycontrails/models/cocip/unterstrasser_wake_vortex.py +132 -30
  23. pycontrails/models/cocipgrid/cocip_grid.py +14 -11
  24. pycontrails/models/emissions/black_carbon.py +19 -14
  25. pycontrails/models/emissions/emissions.py +8 -8
  26. pycontrails/models/humidity_scaling/humidity_scaling.py +49 -4
  27. pycontrails/models/ps_model/ps_aircraft_params.py +1 -1
  28. pycontrails/models/ps_model/ps_grid.py +22 -22
  29. pycontrails/models/ps_model/ps_model.py +4 -7
  30. pycontrails/models/ps_model/static/{ps-aircraft-params-20240524.csv → ps-aircraft-params-20250328.csv} +58 -57
  31. pycontrails/models/ps_model/static/{ps-synonym-list-20240524.csv → ps-synonym-list-20250328.csv} +1 -0
  32. pycontrails/models/tau_cirrus.py +1 -0
  33. pycontrails/physics/constants.py +2 -1
  34. pycontrails/physics/jet.py +5 -4
  35. pycontrails/physics/static/{iata-cargo-load-factors-20241115.csv → iata-cargo-load-factors-20250221.csv} +3 -0
  36. pycontrails/physics/static/{iata-passenger-load-factors-20241115.csv → iata-passenger-load-factors-20250221.csv} +3 -0
  37. {pycontrails-0.54.6.dist-info → pycontrails-0.54.8.dist-info}/METADATA +5 -4
  38. {pycontrails-0.54.6.dist-info → pycontrails-0.54.8.dist-info}/RECORD +42 -40
  39. {pycontrails-0.54.6.dist-info → pycontrails-0.54.8.dist-info}/WHEEL +2 -1
  40. {pycontrails-0.54.6.dist-info → pycontrails-0.54.8.dist-info/licenses}/NOTICE +1 -1
  41. pycontrails/datalib/spire.py +0 -739
  42. {pycontrails-0.54.6.dist-info → pycontrails-0.54.8.dist-info/licenses}/LICENSE +0 -0
  43. {pycontrails-0.54.6.dist-info → pycontrails-0.54.8.dist-info}/top_level.txt +0 -0
@@ -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)