pycontrails 0.41.0__cp39-cp39-win_amd64.whl → 0.42.2__cp39-cp39-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/_version.py +2 -2
- pycontrails/core/airports.py +228 -0
- pycontrails/core/cache.py +4 -6
- pycontrails/core/datalib.py +13 -6
- pycontrails/core/fleet.py +72 -20
- pycontrails/core/flight.py +485 -134
- pycontrails/core/flightplan.py +238 -0
- pycontrails/core/interpolation.py +11 -15
- pycontrails/core/met.py +5 -5
- pycontrails/core/models.py +4 -0
- pycontrails/core/rgi_cython.cp39-win_amd64.pyd +0 -0
- pycontrails/core/vector.py +80 -63
- pycontrails/datalib/__init__.py +1 -1
- pycontrails/datalib/ecmwf/common.py +14 -19
- pycontrails/datalib/spire/__init__.py +19 -0
- pycontrails/datalib/spire/spire.py +739 -0
- pycontrails/ext/bada/__init__.py +6 -6
- pycontrails/ext/cirium/__init__.py +2 -2
- pycontrails/models/cocip/cocip.py +37 -39
- pycontrails/models/cocip/cocip_params.py +37 -30
- pycontrails/models/cocip/cocip_uncertainty.py +47 -58
- pycontrails/models/cocip/radiative_forcing.py +220 -193
- pycontrails/models/cocip/wake_vortex.py +96 -91
- pycontrails/models/cocip/wind_shear.py +2 -2
- pycontrails/models/emissions/emissions.py +1 -1
- pycontrails/models/humidity_scaling.py +266 -9
- pycontrails/models/issr.py +2 -2
- pycontrails/models/pcr.py +1 -1
- pycontrails/models/quantiles/era5_ensemble_quantiles.npy +0 -0
- pycontrails/models/quantiles/iagos_quantiles.npy +0 -0
- pycontrails/models/sac.py +7 -5
- pycontrails/physics/geo.py +5 -3
- pycontrails/physics/jet.py +66 -113
- pycontrails/utils/json.py +3 -3
- {pycontrails-0.41.0.dist-info → pycontrails-0.42.2.dist-info}/METADATA +4 -7
- {pycontrails-0.41.0.dist-info → pycontrails-0.42.2.dist-info}/RECORD +40 -34
- {pycontrails-0.41.0.dist-info → pycontrails-0.42.2.dist-info}/LICENSE +0 -0
- {pycontrails-0.41.0.dist-info → pycontrails-0.42.2.dist-info}/NOTICE +0 -0
- {pycontrails-0.41.0.dist-info → pycontrails-0.42.2.dist-info}/WHEEL +0 -0
- {pycontrails-0.41.0.dist-info → pycontrails-0.42.2.dist-info}/top_level.txt +0 -0
pycontrails/core/flight.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import dataclasses
|
|
6
|
+
import enum
|
|
6
7
|
import logging
|
|
7
8
|
import warnings
|
|
8
9
|
from typing import TYPE_CHECKING, Any
|
|
@@ -26,6 +27,31 @@ if TYPE_CHECKING:
|
|
|
26
27
|
import traffic
|
|
27
28
|
|
|
28
29
|
|
|
30
|
+
class FlightPhase(enum.IntEnum):
|
|
31
|
+
"""Flight phase enumeration."""
|
|
32
|
+
|
|
33
|
+
CLIMB = enum.auto()
|
|
34
|
+
CRUISE = enum.auto()
|
|
35
|
+
DESCENT = enum.auto()
|
|
36
|
+
LEVEL_FLIGHT = enum.auto()
|
|
37
|
+
NAN = enum.auto()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
#: Max airport elevation, [:math:`ft`]
|
|
41
|
+
#: See `Daocheng_Yading_Airport <https://en.wikipedia.org/wiki/Daocheng_Yading_Airport>`_
|
|
42
|
+
MAX_AIRPORT_ELEVATION = 15_000.0
|
|
43
|
+
|
|
44
|
+
#: Min estimated cruise altitude, [:math:`ft`]
|
|
45
|
+
MIN_CRUISE_ALTITUDE = 20_000.0
|
|
46
|
+
|
|
47
|
+
#: Short haul duration cutoff, [:math:`s`]
|
|
48
|
+
SHORT_HAUL_DURATION = 3600.0
|
|
49
|
+
|
|
50
|
+
#: Set maximum speed compatible with "on_ground" indicator, [:math:`mph`]
|
|
51
|
+
#: Thresholds assessed based on scatter plot (150 knots = 278 km/h)
|
|
52
|
+
MAX_ON_GROUND_SPEED = 150.0
|
|
53
|
+
|
|
54
|
+
|
|
29
55
|
@dataclasses.dataclass
|
|
30
56
|
class Aircraft:
|
|
31
57
|
"""Base class for the physical parameters of the aircraft."""
|
|
@@ -52,51 +78,12 @@ class Aircraft:
|
|
|
52
78
|
max_altitude: float | None = None
|
|
53
79
|
|
|
54
80
|
|
|
55
|
-
@dataclasses.dataclass
|
|
56
|
-
class FlightPhase:
|
|
57
|
-
"""Container for boolean arrays describing phase of the flight.
|
|
58
|
-
|
|
59
|
-
Each array is expected to have the same shape. Arrays should be pairwise disjoint and cover
|
|
60
|
-
each waypoint (for each waypoint, exactly one of the four arrays should be True.)
|
|
61
|
-
|
|
62
|
-
.. todo::
|
|
63
|
-
|
|
64
|
-
Refactor to include this data as property in :class:`Flight` with enum for FlightPhase
|
|
65
|
-
"""
|
|
66
|
-
|
|
67
|
-
cruise: np.ndarray
|
|
68
|
-
climb: np.ndarray
|
|
69
|
-
descent: np.ndarray
|
|
70
|
-
nan: np.ndarray
|
|
71
|
-
|
|
72
|
-
@classmethod
|
|
73
|
-
def all_cruise(cls, size: int) -> FlightPhase:
|
|
74
|
-
"""Generate `FlightPhase` instance in which all waypoints are at cruise.
|
|
75
|
-
|
|
76
|
-
Parameters
|
|
77
|
-
----------
|
|
78
|
-
size : int
|
|
79
|
-
Number of waypoints
|
|
80
|
-
|
|
81
|
-
Returns
|
|
82
|
-
-------
|
|
83
|
-
FlightPhase
|
|
84
|
-
All waypoints are given a cruise state..
|
|
85
|
-
"""
|
|
86
|
-
return cls(
|
|
87
|
-
cruise=np.ones(size).astype(bool),
|
|
88
|
-
climb=np.zeros(size).astype(bool),
|
|
89
|
-
descent=np.zeros(size).astype(bool),
|
|
90
|
-
nan=np.zeros(size).astype(bool),
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
|
|
94
81
|
class Flight(GeoVectorDataset):
|
|
95
82
|
"""A single flight trajectory.
|
|
96
83
|
|
|
97
|
-
Expect latitude-longitude
|
|
84
|
+
Expect latitude-longitude coordinates in WGS 84.
|
|
98
85
|
Expect altitude in [:math:`m`].
|
|
99
|
-
Expect level in [:math:`hPa`].
|
|
86
|
+
Expect pressure level (`level`) in [:math:`hPa`].
|
|
100
87
|
|
|
101
88
|
Use the attribute :attr:`attrs["crs"]` to specify coordinate reference system
|
|
102
89
|
using `PROJ <https://proj.org/>`_ or `EPSG <https://epsg.org/home.html>`_ syntax.
|
|
@@ -110,19 +97,19 @@ class Flight(GeoVectorDataset):
|
|
|
110
97
|
will override ``data`` inputs. Expects ``altitude`` in meters and ``time`` as a
|
|
111
98
|
DatetimeLike (or array that can processed with :func:`pd.to_datetime`).
|
|
112
99
|
Additional waypoint-specific data can be included as additional keys/columns.
|
|
113
|
-
longitude :
|
|
100
|
+
longitude : npt.ArrayLike, optional
|
|
114
101
|
Flight trajectory waypoint longitude.
|
|
115
102
|
Defaults to None.
|
|
116
|
-
latitude :
|
|
103
|
+
latitude : npt.ArrayLike, optional
|
|
117
104
|
Flight trajectory waypoint latitude.
|
|
118
105
|
Defaults to None.
|
|
119
|
-
altitude :
|
|
106
|
+
altitude : npt.ArrayLike, optional
|
|
120
107
|
Flight trajectory waypoint altitude, [:math:`m`].
|
|
121
108
|
Defaults to None.
|
|
122
|
-
level :
|
|
109
|
+
level : npt.ArrayLike, optional
|
|
123
110
|
Flight trajectory waypoint pressure level, [:math:`hPa`].
|
|
124
111
|
Defaults to None.
|
|
125
|
-
time :
|
|
112
|
+
time : npt.ArrayLike, optional
|
|
126
113
|
Flight trajectory waypoint time.
|
|
127
114
|
Defaults to None.
|
|
128
115
|
attrs : dict[str, Any], optional
|
|
@@ -149,6 +136,12 @@ class Flight(GeoVectorDataset):
|
|
|
149
136
|
Raises if ``data`` input does not contain at least ``time``, ``latitude``, ``longitude``,
|
|
150
137
|
(``altitude`` or ``level``).
|
|
151
138
|
|
|
139
|
+
Notes
|
|
140
|
+
-----
|
|
141
|
+
The `Traffic <https://traffic-viz.github.io/index.html>`_ library has many helpful
|
|
142
|
+
flight processing utilities.
|
|
143
|
+
|
|
144
|
+
See :class:`traffic.core.Flight` for more information.
|
|
152
145
|
|
|
153
146
|
Examples
|
|
154
147
|
--------
|
|
@@ -207,11 +200,15 @@ class Flight(GeoVectorDataset):
|
|
|
207
200
|
|
|
208
201
|
def __init__(
|
|
209
202
|
self,
|
|
210
|
-
data: dict[str,
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
203
|
+
data: dict[str, npt.ArrayLike]
|
|
204
|
+
| pd.DataFrame
|
|
205
|
+
| VectorDataDict
|
|
206
|
+
| VectorDataset
|
|
207
|
+
| None = None,
|
|
208
|
+
longitude: npt.ArrayLike | None = None,
|
|
209
|
+
latitude: npt.ArrayLike | None = None,
|
|
210
|
+
altitude: npt.ArrayLike | None = None,
|
|
211
|
+
time: npt.ArrayLike | None = None,
|
|
215
212
|
attrs: dict[str, Any] | AttrDict | None = None,
|
|
216
213
|
copy: bool = True,
|
|
217
214
|
aircraft: Aircraft | None = None,
|
|
@@ -245,7 +242,7 @@ class Flight(GeoVectorDataset):
|
|
|
245
242
|
self.fuel = fuel or JetA()
|
|
246
243
|
|
|
247
244
|
# Check flight data for possible errors
|
|
248
|
-
if np.any(self.altitude > 16000):
|
|
245
|
+
if np.any(self.altitude > 16000.0):
|
|
249
246
|
flight_id = self.attrs.get("flight_id", "")
|
|
250
247
|
flight_id = flight_id and f" for flight {flight_id}"
|
|
251
248
|
warnings.warn(
|
|
@@ -260,35 +257,36 @@ class Flight(GeoVectorDataset):
|
|
|
260
257
|
"with segment-based methods (e.g. 'segment_true_airspeed')."
|
|
261
258
|
)
|
|
262
259
|
|
|
263
|
-
|
|
260
|
+
time_diff = np.diff(self["time"])
|
|
264
261
|
|
|
265
262
|
# Ensure that time is sorted
|
|
266
|
-
if self and np.any(
|
|
263
|
+
if self and np.any(time_diff < np.timedelta64(0)):
|
|
267
264
|
if not copy:
|
|
268
265
|
raise ValueError(
|
|
269
|
-
"
|
|
266
|
+
"The 'time' array must be sorted if 'copy=False' on creation. "
|
|
270
267
|
"Set copy=False, or sort data before creating Flight."
|
|
271
268
|
)
|
|
272
|
-
warnings.warn("Sorting Flight data by
|
|
269
|
+
warnings.warn("Sorting Flight data by time.")
|
|
273
270
|
|
|
274
271
|
sorted_flight = self.sort("time")
|
|
275
272
|
self.data = sorted_flight.data
|
|
276
|
-
|
|
277
|
-
|
|
273
|
+
|
|
274
|
+
# Update time_diff ... we use it again below
|
|
275
|
+
time_diff = np.diff(self["time"])
|
|
278
276
|
|
|
279
277
|
# Check for duplicate times. If dropping duplicates,
|
|
280
|
-
# keep the *first* occurrence of each time.
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if not np.all(filt):
|
|
278
|
+
# keep the *first* occurrence of each time.
|
|
279
|
+
duplicated_times = time_diff == np.timedelta64(0)
|
|
280
|
+
if self and np.any(duplicated_times):
|
|
284
281
|
if drop_duplicated_times:
|
|
285
|
-
|
|
282
|
+
mask = np.insert(duplicated_times, 0, False)
|
|
283
|
+
filtered_flight = self.filter(~mask, copy=False)
|
|
286
284
|
self.data = filtered_flight.data
|
|
287
285
|
else:
|
|
288
286
|
warnings.warn(
|
|
289
|
-
"Flight contains duplicate times.
|
|
290
|
-
"with segment-based methods
|
|
291
|
-
"'
|
|
287
|
+
f"Flight contains {duplicated_times.sum()} duplicate times. "
|
|
288
|
+
"This will cause errors with segment-based methods. Set "
|
|
289
|
+
"'drop_duplicated_times=True' or call the 'resample_and_fill' method."
|
|
292
290
|
)
|
|
293
291
|
|
|
294
292
|
@overrides
|
|
@@ -420,7 +418,7 @@ class Flight(GeoVectorDataset):
|
|
|
420
418
|
# Segment Properties
|
|
421
419
|
# ------------
|
|
422
420
|
|
|
423
|
-
def segment_duration(self, dtype:
|
|
421
|
+
def segment_duration(self, dtype: npt.DTypeLike = np.float32) -> npt.NDArray[np.float_]:
|
|
424
422
|
r"""Compute time elapsed between waypoints in seconds.
|
|
425
423
|
|
|
426
424
|
``np.nan`` appended so the length of the output is the same as number of waypoints.
|
|
@@ -433,14 +431,14 @@ class Flight(GeoVectorDataset):
|
|
|
433
431
|
|
|
434
432
|
Returns
|
|
435
433
|
-------
|
|
436
|
-
np.
|
|
434
|
+
npt.NDArray[np.float_]
|
|
437
435
|
Time difference between waypoints, [:math:`s`].
|
|
438
436
|
Returns an array with dtype specified by``dtype``
|
|
439
437
|
"""
|
|
440
438
|
|
|
441
|
-
return
|
|
439
|
+
return segment_duration(self.data["time"], dtype=dtype)
|
|
442
440
|
|
|
443
|
-
def segment_length(self) -> np.
|
|
441
|
+
def segment_length(self) -> npt.NDArray[np.float_]:
|
|
444
442
|
"""Compute spherical distance between flight waypoints.
|
|
445
443
|
|
|
446
444
|
Helper function used in :meth:`length` and :meth:`length_met`.
|
|
@@ -448,7 +446,7 @@ class Flight(GeoVectorDataset):
|
|
|
448
446
|
|
|
449
447
|
Returns
|
|
450
448
|
-------
|
|
451
|
-
np.
|
|
449
|
+
npt.NDArray[np.float_]
|
|
452
450
|
Array of distances in [:math:`m`] between waypoints
|
|
453
451
|
|
|
454
452
|
Raises
|
|
@@ -478,7 +476,7 @@ class Flight(GeoVectorDataset):
|
|
|
478
476
|
|
|
479
477
|
return geo.segment_length(self["longitude"], self["latitude"], self.altitude)
|
|
480
478
|
|
|
481
|
-
def segment_angle(self) -> tuple[np.
|
|
479
|
+
def segment_angle(self) -> tuple[npt.NDArray[np.float_], npt.NDArray[np.float_]]:
|
|
482
480
|
"""Calculate sin/cos for the angle between each segment and the longitudinal axis.
|
|
483
481
|
|
|
484
482
|
This is different from the usual navigational angle between two points known as *bearing*.
|
|
@@ -498,7 +496,7 @@ class Flight(GeoVectorDataset):
|
|
|
498
496
|
|
|
499
497
|
Returns
|
|
500
498
|
-------
|
|
501
|
-
np.
|
|
499
|
+
npt.NDArray[np.float_], npt.NDArray[np.float_]
|
|
502
500
|
sin(a), cos(a), where ``a`` is the angle between the segment and the longitudinal axis
|
|
503
501
|
The final values are of both arrays are ``np.nan``.
|
|
504
502
|
|
|
@@ -528,7 +526,7 @@ class Flight(GeoVectorDataset):
|
|
|
528
526
|
"""
|
|
529
527
|
return geo.segment_angle(self["longitude"], self["latitude"])
|
|
530
528
|
|
|
531
|
-
def segment_azimuth(self) -> np.
|
|
529
|
+
def segment_azimuth(self) -> npt.NDArray[np.float_]:
|
|
532
530
|
"""Calculate (forward) azimuth at each waypoint.
|
|
533
531
|
|
|
534
532
|
Method calls `pyproj.Geod.inv`, which is slow. See `geo.forward_azimuth`
|
|
@@ -540,7 +538,7 @@ class Flight(GeoVectorDataset):
|
|
|
540
538
|
|
|
541
539
|
Returns
|
|
542
540
|
-------
|
|
543
|
-
np.
|
|
541
|
+
npt.NDArray[np.float_]
|
|
544
542
|
Array of azimuths.
|
|
545
543
|
"""
|
|
546
544
|
lon = self["longitude"]
|
|
@@ -564,7 +562,7 @@ class Flight(GeoVectorDataset):
|
|
|
564
562
|
|
|
565
563
|
def segment_groundspeed(
|
|
566
564
|
self, smooth: bool = False, window_length: int = 7, polyorder: int = 1
|
|
567
|
-
) -> np.
|
|
565
|
+
) -> npt.NDArray[np.float_]:
|
|
568
566
|
"""Return groundspeed across segments.
|
|
569
567
|
|
|
570
568
|
Calculate by dividing the horizontal segment length by the difference in waypoint times.
|
|
@@ -581,16 +579,14 @@ class Flight(GeoVectorDataset):
|
|
|
581
579
|
|
|
582
580
|
Returns
|
|
583
581
|
-------
|
|
584
|
-
np.
|
|
582
|
+
npt.NDArray[np.float_]
|
|
585
583
|
Groundspeed of the segment, [:math:`m s^{-1}`]
|
|
586
584
|
"""
|
|
587
|
-
# get horizontal distance
|
|
585
|
+
# get horizontal distance (altitude is ignored)
|
|
588
586
|
horizontal_segment_length = geo.segment_haversine(self["longitude"], self["latitude"])
|
|
589
587
|
|
|
590
588
|
# time between waypoints, in seconds
|
|
591
|
-
dt_sec =
|
|
592
|
-
dt_sec[:-1] = np.diff(self["time"]) / np.timedelta64(1, "s")
|
|
593
|
-
dt_sec[-1] = np.nan
|
|
589
|
+
dt_sec = self.segment_duration(dtype=horizontal_segment_length.dtype)
|
|
594
590
|
|
|
595
591
|
# calculate groundspeed
|
|
596
592
|
groundspeed = horizontal_segment_length / dt_sec
|
|
@@ -605,8 +601,8 @@ class Flight(GeoVectorDataset):
|
|
|
605
601
|
|
|
606
602
|
def segment_true_airspeed(
|
|
607
603
|
self,
|
|
608
|
-
u_wind: npt.NDArray[np.float_] |
|
|
609
|
-
v_wind: npt.NDArray[np.float_] |
|
|
604
|
+
u_wind: npt.NDArray[np.float_] | float = 0.0,
|
|
605
|
+
v_wind: npt.NDArray[np.float_] | float = 0.0,
|
|
610
606
|
smooth: bool = True,
|
|
611
607
|
window_length: int = 7,
|
|
612
608
|
polyorder: int = 1,
|
|
@@ -617,10 +613,10 @@ class Flight(GeoVectorDataset):
|
|
|
617
613
|
|
|
618
614
|
Parameters
|
|
619
615
|
----------
|
|
620
|
-
u_wind : npt.NDArray[np.float_]
|
|
616
|
+
u_wind : npt.NDArray[np.float_] | float
|
|
621
617
|
U wind speed, [:math:`m \ s^{-1}`].
|
|
622
618
|
Defaults to 0 for all waypoints.
|
|
623
|
-
v_wind : npt.NDArray[np.float_]
|
|
619
|
+
v_wind : npt.NDArray[np.float_] | float
|
|
624
620
|
V wind speed, [:math:`m \ s^{-1}`].
|
|
625
621
|
Defaults to 0 for all waypoints.
|
|
626
622
|
smooth : bool, optional
|
|
@@ -638,12 +634,6 @@ class Flight(GeoVectorDataset):
|
|
|
638
634
|
"""
|
|
639
635
|
groundspeed = self.segment_groundspeed(smooth, window_length, polyorder)
|
|
640
636
|
|
|
641
|
-
if u_wind is None:
|
|
642
|
-
u_wind = np.zeros_like(groundspeed)
|
|
643
|
-
|
|
644
|
-
if v_wind is None:
|
|
645
|
-
v_wind = np.zeros_like(groundspeed)
|
|
646
|
-
|
|
647
637
|
sin_a, cos_a = self.segment_angle()
|
|
648
638
|
gs_x = groundspeed * cos_a
|
|
649
639
|
gs_y = groundspeed * sin_a
|
|
@@ -653,25 +643,76 @@ class Flight(GeoVectorDataset):
|
|
|
653
643
|
return np.sqrt(tas_x * tas_x + tas_y * tas_y)
|
|
654
644
|
|
|
655
645
|
def segment_mach_number(
|
|
656
|
-
self, true_airspeed: np.
|
|
657
|
-
) -> np.
|
|
646
|
+
self, true_airspeed: npt.NDArray[np.float_], air_temperature: npt.NDArray[np.float_]
|
|
647
|
+
) -> npt.NDArray[np.float_]:
|
|
658
648
|
r"""Calculate the mach number of each segment.
|
|
659
649
|
|
|
660
650
|
Parameters
|
|
661
651
|
----------
|
|
662
|
-
true_airspeed : np.
|
|
652
|
+
true_airspeed : npt.NDArray[np.float_]
|
|
663
653
|
True airspeed of the segment, [:math:`m \ s^{-1}`].
|
|
664
654
|
See :meth:`segment_true_airspeed`.
|
|
665
|
-
air_temperature : np.
|
|
655
|
+
air_temperature : npt.NDArray[np.float_]
|
|
666
656
|
Average air temperature of each segment, [:math:`K`]
|
|
667
657
|
|
|
668
658
|
Returns
|
|
669
659
|
-------
|
|
670
|
-
np.
|
|
660
|
+
npt.NDArray[np.float_]
|
|
671
661
|
Mach number of each segment
|
|
672
662
|
"""
|
|
673
663
|
return units.tas_to_mach_number(true_airspeed, air_temperature)
|
|
674
664
|
|
|
665
|
+
def segment_rocd(self) -> npt.NDArray[np.float_]:
|
|
666
|
+
"""Calculate the rate of climb and descent (ROCD).
|
|
667
|
+
|
|
668
|
+
Returns
|
|
669
|
+
-------
|
|
670
|
+
npt.NDArray[np.float_]
|
|
671
|
+
Rate of climb and descent over segment, [:math:`ft min^{-1}`]
|
|
672
|
+
|
|
673
|
+
See Also
|
|
674
|
+
--------
|
|
675
|
+
:func:`segment_rocd`
|
|
676
|
+
"""
|
|
677
|
+
return segment_rocd(self.segment_duration(), self.altitude_ft)
|
|
678
|
+
|
|
679
|
+
def segment_phase(
|
|
680
|
+
self,
|
|
681
|
+
threshold_rocd: float = 250.0,
|
|
682
|
+
min_cruise_altitude_ft: float = 20000.0,
|
|
683
|
+
) -> npt.NDArray[np.uint8]:
|
|
684
|
+
"""Identify the phase of flight (climb, cruise, descent) for each segment.
|
|
685
|
+
|
|
686
|
+
Parameters
|
|
687
|
+
----------
|
|
688
|
+
threshold_rocd : float, optional
|
|
689
|
+
ROCD threshold to identify climb and descent, [:math:`ft min^{-1}`].
|
|
690
|
+
Currently set to 250 ft/min.
|
|
691
|
+
min_cruise_altitude_ft : float, optional
|
|
692
|
+
Minimum altitude for cruise, [:math:`ft`]
|
|
693
|
+
This is specific for each aircraft type,
|
|
694
|
+
and can be approximated as 50% of the altitude ceiling.
|
|
695
|
+
Defaults to 20000 ft.
|
|
696
|
+
|
|
697
|
+
Returns
|
|
698
|
+
-------
|
|
699
|
+
npt.NDArray[np.uint8]
|
|
700
|
+
Array of values enumerating the flight phase.
|
|
701
|
+
See :attr:`flight.FlightPhase` for enumeration.
|
|
702
|
+
|
|
703
|
+
See Also
|
|
704
|
+
--------
|
|
705
|
+
:attr:`FlightPhase`
|
|
706
|
+
:func:`segment_phase`
|
|
707
|
+
:func:`segment_rocd`
|
|
708
|
+
"""
|
|
709
|
+
return segment_phase(
|
|
710
|
+
self.segment_rocd(),
|
|
711
|
+
self.altitude_ft,
|
|
712
|
+
threshold_rocd=threshold_rocd,
|
|
713
|
+
min_cruise_altitude_ft=min_cruise_altitude_ft,
|
|
714
|
+
)
|
|
715
|
+
|
|
675
716
|
# ------------
|
|
676
717
|
# Filter/Resample
|
|
677
718
|
# ------------
|
|
@@ -772,20 +813,20 @@ class Flight(GeoVectorDataset):
|
|
|
772
813
|
2 50.0 0.0 0.0 2020-01-01 02:00:00
|
|
773
814
|
|
|
774
815
|
>>> fl.resample_and_fill('10T').dataframe # resample with 10 minute frequency
|
|
775
|
-
|
|
776
|
-
0
|
|
777
|
-
1
|
|
778
|
-
2
|
|
779
|
-
3
|
|
780
|
-
4
|
|
781
|
-
5
|
|
782
|
-
6
|
|
783
|
-
7
|
|
784
|
-
8
|
|
785
|
-
9
|
|
786
|
-
10 2020-01-01 01:40:00
|
|
787
|
-
11 2020-01-01 01:50:00
|
|
788
|
-
12 2020-01-01 02:00:00
|
|
816
|
+
longitude latitude altitude time
|
|
817
|
+
0 0.000000 0.0 0.0 2020-01-01 00:00:00
|
|
818
|
+
1 0.000000 0.0 0.0 2020-01-01 00:10:00
|
|
819
|
+
2 0.000000 0.0 0.0 2020-01-01 00:20:00
|
|
820
|
+
3 0.000000 0.0 0.0 2020-01-01 00:30:00
|
|
821
|
+
4 0.000000 0.0 0.0 2020-01-01 00:40:00
|
|
822
|
+
5 0.000000 0.0 0.0 2020-01-01 00:50:00
|
|
823
|
+
6 0.000000 0.0 0.0 2020-01-01 01:00:00
|
|
824
|
+
7 8.928571 0.0 0.0 2020-01-01 01:10:00
|
|
825
|
+
8 16.964286 0.0 0.0 2020-01-01 01:20:00
|
|
826
|
+
9 25.892857 0.0 0.0 2020-01-01 01:30:00
|
|
827
|
+
10 33.928571 0.0 0.0 2020-01-01 01:40:00
|
|
828
|
+
11 41.964286 0.0 0.0 2020-01-01 01:50:00
|
|
829
|
+
12 50.000000 0.0 0.0 2020-01-01 02:00:00
|
|
789
830
|
"""
|
|
790
831
|
methods = "geodesic", "linear"
|
|
791
832
|
if fill_method not in methods:
|
|
@@ -840,7 +881,7 @@ class Flight(GeoVectorDataset):
|
|
|
840
881
|
# STEP 5: Resample flight to freq
|
|
841
882
|
df = df.resample(freq).first()
|
|
842
883
|
|
|
843
|
-
# STEP 6: Linearly
|
|
884
|
+
# STEP 6: Linearly interpolate small horizontal gaps and account
|
|
844
885
|
# for previous longitude shift.
|
|
845
886
|
keys = ["latitude", "longitude"]
|
|
846
887
|
df.loc[:, keys] = df.loc[:, keys].interpolate(method="linear")
|
|
@@ -860,6 +901,65 @@ class Flight(GeoVectorDataset):
|
|
|
860
901
|
df = df.reset_index()
|
|
861
902
|
return Flight(data=df, attrs=self.attrs)
|
|
862
903
|
|
|
904
|
+
def fit_altitude(
|
|
905
|
+
self,
|
|
906
|
+
max_segments: int = 30,
|
|
907
|
+
pop: int = 3,
|
|
908
|
+
r2_target: float = 0.999,
|
|
909
|
+
max_cruise_rocd: float = 10.0,
|
|
910
|
+
sg_window: int = 7,
|
|
911
|
+
sg_polyorder: int = 1,
|
|
912
|
+
) -> Flight:
|
|
913
|
+
"""Use piecewise linear fitting to smooth a flight profile.
|
|
914
|
+
|
|
915
|
+
Fit a flight profile to a series of line segments. Segments that have a
|
|
916
|
+
small rocd will be set to have a slope of zero and snapped to the
|
|
917
|
+
nearest thousand foot level. A Savitzky-Golay filter will then be
|
|
918
|
+
applied to the profile to smooth the climbs and descents. This filter
|
|
919
|
+
works best for high frequency flight data, sampled at a 1-3 second
|
|
920
|
+
sampling period.
|
|
921
|
+
|
|
922
|
+
Parameters
|
|
923
|
+
----------
|
|
924
|
+
max_segments : int, optional
|
|
925
|
+
The maximum number of line segements to fit to the flight profile.
|
|
926
|
+
pop: int, optional
|
|
927
|
+
Population parameter used for the stocastic optimization routine
|
|
928
|
+
used to fit the flight profile.
|
|
929
|
+
r2_target: float, optional
|
|
930
|
+
Target r^2 value for solver. Solver will continue to add line
|
|
931
|
+
segments until the resulting r^2 value is greater than this.
|
|
932
|
+
max_cruise_rocd: float, optional
|
|
933
|
+
The maximum ROCD for a segment that will be forced to a slope of
|
|
934
|
+
zero, [:math:`ft s^{-1}`]
|
|
935
|
+
sg_window: int, optional
|
|
936
|
+
Parameter for :func:`scipy.signal.savgol_filter`
|
|
937
|
+
sg_polyorder: int, optional
|
|
938
|
+
Parameter for :func:`scipy.signal.savgol_filter`
|
|
939
|
+
|
|
940
|
+
Returns
|
|
941
|
+
-------
|
|
942
|
+
Flight
|
|
943
|
+
Smoothed flight
|
|
944
|
+
"""
|
|
945
|
+
# np.roll pushes the last NaN value from `segment_duration` to the front
|
|
946
|
+
# so the elapsed time at the first waypoint will be 0
|
|
947
|
+
seg_dur = self.segment_duration(dtype=np.float64)
|
|
948
|
+
elapsed_time = np.nancumsum(np.roll(seg_dur, 1))
|
|
949
|
+
alt_ft = fit_altitude(
|
|
950
|
+
elapsed_time,
|
|
951
|
+
np.copy(self.altitude_ft),
|
|
952
|
+
max_segments,
|
|
953
|
+
pop,
|
|
954
|
+
r2_target,
|
|
955
|
+
max_cruise_rocd,
|
|
956
|
+
sg_window,
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
flight = self.copy()
|
|
960
|
+
flight.update(altitude_ft=alt_ft)
|
|
961
|
+
return flight
|
|
962
|
+
|
|
863
963
|
def _geodesic_interpolation(self, geodesic_threshold: float) -> pd.DataFrame | None:
|
|
864
964
|
"""Geodesic interpolate between large gaps between waypoints.
|
|
865
965
|
|
|
@@ -1075,7 +1175,7 @@ class Flight(GeoVectorDataset):
|
|
|
1075
1175
|
>>> variables = ["air_temperature", "specific_humidity"]
|
|
1076
1176
|
>>> levels = [300, 250, 200]
|
|
1077
1177
|
>>> era5 = ERA5(time=times, variables=variables, pressure_levels=levels)
|
|
1078
|
-
>>> met = era5.open_metdataset(
|
|
1178
|
+
>>> met = era5.open_metdataset()
|
|
1079
1179
|
|
|
1080
1180
|
>>> # Build flight
|
|
1081
1181
|
>>> df = pd.DataFrame()
|
|
@@ -1169,12 +1269,12 @@ class Flight(GeoVectorDataset):
|
|
|
1169
1269
|
return ax
|
|
1170
1270
|
|
|
1171
1271
|
|
|
1172
|
-
def _return_linestring(data: dict[str, np.
|
|
1272
|
+
def _return_linestring(data: dict[str, npt.NDArray[np.float_]]) -> list[list[float]]:
|
|
1173
1273
|
"""Return list of coordinates for geojson constructions.
|
|
1174
1274
|
|
|
1175
1275
|
Parameters
|
|
1176
1276
|
----------
|
|
1177
|
-
data : dict[str, np.
|
|
1277
|
+
data : dict[str, npt.NDArray[np.float_]]
|
|
1178
1278
|
:attr:`data` containing `longitude`, `latitude`, and `altitude` keys
|
|
1179
1279
|
|
|
1180
1280
|
Returns
|
|
@@ -1184,9 +1284,9 @@ def _return_linestring(data: dict[str, np.ndarray]) -> list[list[float]]:
|
|
|
1184
1284
|
"""
|
|
1185
1285
|
# rounding to reduce the size of resultant json arrays
|
|
1186
1286
|
points = zip( # pylint: disable=zip-builtin-not-iterating
|
|
1187
|
-
np.
|
|
1188
|
-
np.
|
|
1189
|
-
np.
|
|
1287
|
+
np.round(data["longitude"], decimals=4),
|
|
1288
|
+
np.round(data["latitude"], decimals=4),
|
|
1289
|
+
np.round(data["altitude"], decimals=4),
|
|
1190
1290
|
)
|
|
1191
1291
|
return [list(p) for p in points]
|
|
1192
1292
|
|
|
@@ -1248,16 +1348,16 @@ def _antimeridian_index(longitude: pd.Series, crs: str = "EPSG:4326") -> int:
|
|
|
1248
1348
|
return -1
|
|
1249
1349
|
|
|
1250
1350
|
|
|
1251
|
-
def _sg_filter(
|
|
1351
|
+
def _sg_filter(
|
|
1352
|
+
vals: npt.NDArray[np.float_], window_length: int = 7, polyorder: int = 1
|
|
1353
|
+
) -> npt.NDArray[np.float_]:
|
|
1252
1354
|
"""Apply Savitzky-Golay filter to smooth out noise in the time-series data.
|
|
1253
1355
|
|
|
1254
|
-
Used to smooth true airspeed
|
|
1255
|
-
|
|
1256
|
-
TODO: move to a more centralized location in the codebase if used again
|
|
1356
|
+
Used to smooth true airspeed, fuel flow, and altitude.
|
|
1257
1357
|
|
|
1258
1358
|
Parameters
|
|
1259
1359
|
----------
|
|
1260
|
-
vals : np.
|
|
1360
|
+
vals : npt.NDArray[np.float_]
|
|
1261
1361
|
Input array
|
|
1262
1362
|
window_length : int, optional
|
|
1263
1363
|
Parameter for :func:`scipy.signal.savgol_filter`
|
|
@@ -1266,7 +1366,7 @@ def _sg_filter(vals: np.ndarray, window_length: int = 7, polyorder: int = 1) ->
|
|
|
1266
1366
|
|
|
1267
1367
|
Returns
|
|
1268
1368
|
-------
|
|
1269
|
-
np.
|
|
1369
|
+
npt.NDArray[np.float_]
|
|
1270
1370
|
Smoothed values
|
|
1271
1371
|
|
|
1272
1372
|
Raises
|
|
@@ -1292,8 +1392,8 @@ def _sg_filter(vals: np.ndarray, window_length: int = 7, polyorder: int = 1) ->
|
|
|
1292
1392
|
|
|
1293
1393
|
|
|
1294
1394
|
def _altitude_interpolation(
|
|
1295
|
-
altitude: np.
|
|
1296
|
-
) -> np.
|
|
1395
|
+
altitude: npt.NDArray[np.float_], nominal_rocd: float, freq: np.timedelta64
|
|
1396
|
+
) -> npt.NDArray[np.float_]:
|
|
1297
1397
|
"""Interpolate nan values in `altitude` array.
|
|
1298
1398
|
|
|
1299
1399
|
Suppose each group of consecutive nan values is enclosed by `a0` and `a1` with
|
|
@@ -1304,7 +1404,7 @@ def _altitude_interpolation(
|
|
|
1304
1404
|
|
|
1305
1405
|
Parameters
|
|
1306
1406
|
----------
|
|
1307
|
-
altitude : np.
|
|
1407
|
+
altitude : npt.NDArray[np.float_]
|
|
1308
1408
|
Array of altitude values containing nan values. This function will raise
|
|
1309
1409
|
an error if `altitude` does not contain nan values. Moreover, this function
|
|
1310
1410
|
assumes the initial and final entries in `altitude` are not nan.
|
|
@@ -1315,7 +1415,7 @@ def _altitude_interpolation(
|
|
|
1315
1415
|
|
|
1316
1416
|
Returns
|
|
1317
1417
|
-------
|
|
1318
|
-
np.
|
|
1418
|
+
npt.NDArray[np.float_]
|
|
1319
1419
|
Altitude after nan values have been filled
|
|
1320
1420
|
"""
|
|
1321
1421
|
# Determine nan state of altitude
|
|
@@ -1360,12 +1460,14 @@ def _altitude_interpolation(
|
|
|
1360
1460
|
return np.where(sign == 1, fill_climb, fill_descent)
|
|
1361
1461
|
|
|
1362
1462
|
|
|
1363
|
-
def _verify_altitude(
|
|
1463
|
+
def _verify_altitude(
|
|
1464
|
+
altitude: npt.NDArray[np.float_], nominal_rocd: float, freq: np.timedelta64
|
|
1465
|
+
) -> None:
|
|
1364
1466
|
"""Confirm that the time derivative of `altitude` does not exceed twice `nominal_rocd`.
|
|
1365
1467
|
|
|
1366
1468
|
Parameters
|
|
1367
1469
|
----------
|
|
1368
|
-
altitude : np.
|
|
1470
|
+
altitude : npt.NDArray[np.float_]
|
|
1369
1471
|
Array of filled altitude values containing nan values.
|
|
1370
1472
|
nominal_rocd : float
|
|
1371
1473
|
Nominal rate of climb/descent, in m/s
|
|
@@ -1386,14 +1488,94 @@ def _verify_altitude(altitude: np.ndarray, nominal_rocd: float, freq: np.timedel
|
|
|
1386
1488
|
)
|
|
1387
1489
|
|
|
1388
1490
|
|
|
1389
|
-
def
|
|
1390
|
-
|
|
1491
|
+
def filter_altitude(
|
|
1492
|
+
altitude: npt.NDArray[np.float_], *, kernel_size: int = 17
|
|
1493
|
+
) -> npt.NDArray[np.float_]:
|
|
1494
|
+
"""
|
|
1495
|
+
Filter noisy altitude on a single flight.
|
|
1496
|
+
|
|
1497
|
+
Currently runs altitude through a median filter using :func:`scipy.signal.medfilt`
|
|
1498
|
+
with ``kernel_size``, then a Savitzky-Golay filter to filter noise.
|
|
1499
|
+
|
|
1500
|
+
.. todo::
|
|
1501
|
+
|
|
1502
|
+
This method assumes that the time interval between altitude points
|
|
1503
|
+
(:func:`segment_duration`) is moderately small (e.g. minutes).
|
|
1504
|
+
This filter may not work as well when waypoints are close (seconds) or
|
|
1505
|
+
farther apart in time (e.g. 30 minutes).
|
|
1506
|
+
|
|
1507
|
+
The optimal altitude filter is a work in a progress
|
|
1508
|
+
and may change in the future.
|
|
1509
|
+
|
|
1510
|
+
Parameters
|
|
1511
|
+
----------
|
|
1512
|
+
altitude : npt.NDArray[np.float_]
|
|
1513
|
+
Altitude signal
|
|
1514
|
+
kernel_size : int, optional
|
|
1515
|
+
Passed directly to :func:`scipy.signal.medfilt`, by default 11.
|
|
1516
|
+
Passed also to :func:`scipy.signal.medfilt`
|
|
1517
|
+
|
|
1518
|
+
Returns
|
|
1519
|
+
-------
|
|
1520
|
+
npt.NDArray[np.float_]
|
|
1521
|
+
Filtered altitude
|
|
1522
|
+
|
|
1523
|
+
Notes
|
|
1524
|
+
-----
|
|
1525
|
+
Algorithm is derived from :meth:`traffic.core.flight.Flight.filter`.
|
|
1526
|
+
|
|
1527
|
+
The `traffic
|
|
1528
|
+
<https://traffic-viz.github.io/api_reference/traffic.core.flight.html#traffic.core.Flight.filter>`_
|
|
1529
|
+
algorithm also computes thresholds on sliding windows
|
|
1530
|
+
and replaces unacceptable values with NaNs.
|
|
1531
|
+
|
|
1532
|
+
Errors may raised if the ``kernel_size`` is too large.
|
|
1533
|
+
|
|
1534
|
+
See Also
|
|
1535
|
+
--------
|
|
1536
|
+
:meth:`traffic.core.flight.Flight.filter`
|
|
1537
|
+
:func:`scipy.signal.medfilt`
|
|
1538
|
+
""" # noqa: E501
|
|
1539
|
+
if not len(altitude):
|
|
1540
|
+
raise ValueError("Altitude must have non-zero length to filter")
|
|
1541
|
+
|
|
1542
|
+
# The kernel_size must be less than or equal to the number of data points available.
|
|
1543
|
+
kernel_size = min(kernel_size, altitude.size)
|
|
1544
|
+
|
|
1545
|
+
# The kernel_size must be odd.
|
|
1546
|
+
if (kernel_size % 2) == 0:
|
|
1547
|
+
kernel_size -= 1
|
|
1548
|
+
|
|
1549
|
+
# Apply a median filter above a certain threshold
|
|
1550
|
+
altitude_filt = scipy.signal.medfilt(altitude, kernel_size=kernel_size)
|
|
1551
|
+
|
|
1552
|
+
# TODO: I think this makes sense because it smooths the climb/descent phases
|
|
1553
|
+
altitude_filt = _sg_filter(altitude_filt, window_length=kernel_size)
|
|
1554
|
+
|
|
1555
|
+
# TODO: The right way to do this is with a low pass filter at
|
|
1556
|
+
# a reasonable rocd threshold ~300-500 ft/min, e.g.
|
|
1557
|
+
# sos = scipy.signal.butter(4, 250, 'low', output='sos')
|
|
1558
|
+
# return scipy.signal.sosfilt(sos, altitude_filt1)
|
|
1559
|
+
#
|
|
1560
|
+
# Remove noise manually
|
|
1561
|
+
# only remove above max airport elevation
|
|
1562
|
+
d_alt_ft = np.diff(altitude_filt, append=np.nan)
|
|
1563
|
+
is_noise = (np.abs(d_alt_ft) <= 25.0) & (altitude_filt > MAX_AIRPORT_ELEVATION)
|
|
1564
|
+
altitude_filt[is_noise] = np.round(altitude_filt[is_noise], -3)
|
|
1565
|
+
|
|
1566
|
+
return altitude_filt
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
def segment_duration(
|
|
1570
|
+
time: npt.NDArray[np.datetime64], dtype: npt.DTypeLike = np.float32
|
|
1391
1571
|
) -> npt.NDArray[np.float_]:
|
|
1392
1572
|
"""Calculate the time difference between waypoints.
|
|
1393
1573
|
|
|
1574
|
+
``np.nan`` appended so the length of the output is the same as number of waypoints.
|
|
1575
|
+
|
|
1394
1576
|
Parameters
|
|
1395
1577
|
----------
|
|
1396
|
-
time : np.
|
|
1578
|
+
time : npt.NDArray[np.datetime64]
|
|
1397
1579
|
Waypoint time in ``np.datetime64`` format.
|
|
1398
1580
|
dtype : np.dtype
|
|
1399
1581
|
Numpy dtype for time difference.
|
|
@@ -1401,11 +1583,180 @@ def _dt_waypoints(
|
|
|
1401
1583
|
|
|
1402
1584
|
Returns
|
|
1403
1585
|
-------
|
|
1404
|
-
np.
|
|
1586
|
+
npt.NDArray[np.float_]
|
|
1405
1587
|
Time difference between waypoints, [:math:`s`].
|
|
1406
|
-
This returns an array with dtype specified by``dtype
|
|
1588
|
+
This returns an array with dtype specified by``dtype``.
|
|
1407
1589
|
"""
|
|
1408
1590
|
out = np.empty_like(time, dtype=dtype)
|
|
1409
1591
|
out[-1] = np.nan
|
|
1410
1592
|
out[:-1] = np.diff(time) / np.timedelta64(1, "s")
|
|
1411
1593
|
return out
|
|
1594
|
+
|
|
1595
|
+
|
|
1596
|
+
def segment_phase(
|
|
1597
|
+
rocd: npt.NDArray[np.float_],
|
|
1598
|
+
altitude_ft: npt.NDArray[np.float_],
|
|
1599
|
+
*,
|
|
1600
|
+
threshold_rocd: float = 250.0,
|
|
1601
|
+
min_cruise_altitude_ft: float = MIN_CRUISE_ALTITUDE,
|
|
1602
|
+
) -> npt.NDArray[np.uint8]:
|
|
1603
|
+
"""Identify the phase of flight (climb, cruise, descent) for each segment.
|
|
1604
|
+
|
|
1605
|
+
Parameters
|
|
1606
|
+
----------
|
|
1607
|
+
rocd: pt.NDArray[np.float_]
|
|
1608
|
+
Rate of climb and descent across segment, [:math:`ft min^{-1}`].
|
|
1609
|
+
See output from :func:`segment_rocd`.
|
|
1610
|
+
altitude_ft: npt.NDArray[np.float_]
|
|
1611
|
+
Altitude, [:math:`ft`]
|
|
1612
|
+
threshold_rocd: float, optional
|
|
1613
|
+
ROCD threshold to identify climb and descent, [:math:`ft min^{-1}`].
|
|
1614
|
+
Defaults to 250 ft/min.
|
|
1615
|
+
min_cruise_altitude_ft: float, optional
|
|
1616
|
+
Minimum threshold altitude for cruise, [:math:`ft`]
|
|
1617
|
+
This is specific for each aircraft type,
|
|
1618
|
+
and can be approximated as 50% of the altitude ceiling.
|
|
1619
|
+
Defaults to :attr:`MIN_CRUISE_ALTITUDE`.
|
|
1620
|
+
|
|
1621
|
+
Returns
|
|
1622
|
+
-------
|
|
1623
|
+
npt.NDArray[np.uint8]
|
|
1624
|
+
Array of values enumerating the flight phase.
|
|
1625
|
+
See :attr:`flight.FlightPhase` for enumeration.
|
|
1626
|
+
|
|
1627
|
+
Notes
|
|
1628
|
+
-----
|
|
1629
|
+
Flight data derived from ADS-B and radar sources could contain noise leading
|
|
1630
|
+
to small changes in altitude and ROCD. Hence, an arbitrary ``threshold_rocd``
|
|
1631
|
+
is specified to identify the different phases of flight.
|
|
1632
|
+
|
|
1633
|
+
The flight phase "level-flight" is when an aircraft is holding at lower altitudes.
|
|
1634
|
+
The cruise phase of flight only occurs above a certain threshold altitude.
|
|
1635
|
+
|
|
1636
|
+
See Also
|
|
1637
|
+
--------
|
|
1638
|
+
:attr:`FlightPhase`
|
|
1639
|
+
:func:`segment_rocd`
|
|
1640
|
+
"""
|
|
1641
|
+
nan = np.isnan(rocd)
|
|
1642
|
+
cruise = (
|
|
1643
|
+
(rocd < threshold_rocd) & (rocd > -threshold_rocd) & (altitude_ft > min_cruise_altitude_ft)
|
|
1644
|
+
)
|
|
1645
|
+
climb = ~cruise & (rocd > 0)
|
|
1646
|
+
descent = ~cruise & (rocd < 0)
|
|
1647
|
+
level_flight = ~(nan | cruise | climb | descent)
|
|
1648
|
+
|
|
1649
|
+
phase = np.empty(rocd.shape, dtype=np.uint8)
|
|
1650
|
+
phase[cruise] = FlightPhase.CRUISE
|
|
1651
|
+
phase[climb] = FlightPhase.CLIMB
|
|
1652
|
+
phase[descent] = FlightPhase.DESCENT
|
|
1653
|
+
phase[level_flight] = FlightPhase.LEVEL_FLIGHT
|
|
1654
|
+
phase[nan] = FlightPhase.NAN
|
|
1655
|
+
|
|
1656
|
+
return phase
|
|
1657
|
+
|
|
1658
|
+
|
|
1659
|
+
def segment_rocd(
|
|
1660
|
+
segment_duration: npt.NDArray[np.float_], altitude_ft: npt.NDArray[np.float_]
|
|
1661
|
+
) -> npt.NDArray[np.float_]:
|
|
1662
|
+
"""Calculate the rate of climb and descent (ROCD).
|
|
1663
|
+
|
|
1664
|
+
Parameters
|
|
1665
|
+
----------
|
|
1666
|
+
segment_duration: npt.NDArray[np.float_]
|
|
1667
|
+
Time difference between waypoints, [:math:`s`].
|
|
1668
|
+
Expected to have numeric `dtype`, not `"timedelta64"`.
|
|
1669
|
+
See output from :func:`segment_duration`.
|
|
1670
|
+
altitude_ft: npt.NDArray[np.float_]
|
|
1671
|
+
Altitude of each waypoint, [:math:`ft`]
|
|
1672
|
+
|
|
1673
|
+
Returns
|
|
1674
|
+
-------
|
|
1675
|
+
npt.NDArray[np.float_]
|
|
1676
|
+
Rate of climb and descent over segment, [:math:`ft min^{-1}`]
|
|
1677
|
+
|
|
1678
|
+
See Also
|
|
1679
|
+
--------
|
|
1680
|
+
:func:`segment_duration`
|
|
1681
|
+
"""
|
|
1682
|
+
dt_min = segment_duration / 60.0
|
|
1683
|
+
|
|
1684
|
+
out = np.empty_like(altitude_ft)
|
|
1685
|
+
out[:-1] = np.diff(altitude_ft) / dt_min[:-1]
|
|
1686
|
+
out[-1] = np.nan
|
|
1687
|
+
|
|
1688
|
+
return out
|
|
1689
|
+
|
|
1690
|
+
|
|
1691
|
+
def fit_altitude(
|
|
1692
|
+
elapsed_time: npt.NDArray[np.float_],
|
|
1693
|
+
altitude_ft: npt.NDArray[np.float_],
|
|
1694
|
+
max_segments: int = 30,
|
|
1695
|
+
pop: int = 3,
|
|
1696
|
+
r2_target: float = 0.999,
|
|
1697
|
+
max_cruise_rocd: float = 10.0,
|
|
1698
|
+
sg_window: int = 7,
|
|
1699
|
+
sg_polyorder: int = 1,
|
|
1700
|
+
) -> npt.NDArray[np.float_]:
|
|
1701
|
+
"""Use piecewise linear fitting to smooth a flight profile.
|
|
1702
|
+
|
|
1703
|
+
Fit a flight profile to a series of line segments. Segments that have a
|
|
1704
|
+
small rocd will be set to have a slope of zero and snapped to the
|
|
1705
|
+
nearest thousand foot level. A Savitzky-Golay filter will then be
|
|
1706
|
+
applied to the profile to smooth the climbs and descents. This filter
|
|
1707
|
+
works best for high frequency flight data, sampled at a 1-3 second
|
|
1708
|
+
sampling period.
|
|
1709
|
+
|
|
1710
|
+
Parameters
|
|
1711
|
+
----------
|
|
1712
|
+
elapsed_time: npt.NDArray[np.float_]
|
|
1713
|
+
Cumulative time of flight between waypoints, [:math:`s`]
|
|
1714
|
+
altitude_ft: npt.NDArray[np.float_]
|
|
1715
|
+
Altitude of each waypoint, [:math:`ft`
|
|
1716
|
+
max_segments: int, optional
|
|
1717
|
+
The maximum number of line segements to fit to the flight profile.
|
|
1718
|
+
pop: int, optional
|
|
1719
|
+
Population parameter used for the stocastic optimization routine
|
|
1720
|
+
used to fit the flight profile.
|
|
1721
|
+
r2_target: float, optional
|
|
1722
|
+
Target r^2 value for solver. Solver will continue to add line
|
|
1723
|
+
segments until the resulting r^2 value is greater than this.
|
|
1724
|
+
max_cruise_rocd: float, optional
|
|
1725
|
+
The maximum ROCD for a segment that will be forced to a slope of
|
|
1726
|
+
zero, [:math:`ft s^{-1}`]
|
|
1727
|
+
sg_window: int, optional
|
|
1728
|
+
Parameter for :func:`scipy.signal.savgol_filter`
|
|
1729
|
+
sg_polyorder: int, optional
|
|
1730
|
+
Parameter for :func:`scipy.signal.savgol_filter`
|
|
1731
|
+
|
|
1732
|
+
Returns
|
|
1733
|
+
-------
|
|
1734
|
+
npt.NDArray[np.float_]
|
|
1735
|
+
Smoothed flight altitudes
|
|
1736
|
+
"""
|
|
1737
|
+
try:
|
|
1738
|
+
import pwlf
|
|
1739
|
+
except ModuleNotFoundError:
|
|
1740
|
+
raise ModuleNotFoundError(
|
|
1741
|
+
"The 'fit_altitude' function requires the 'pwlf' package."
|
|
1742
|
+
"This can be installed with 'pip install pwlf'."
|
|
1743
|
+
)
|
|
1744
|
+
for i in range(1, max_segments):
|
|
1745
|
+
m2 = pwlf.PiecewiseLinFit(elapsed_time, altitude_ft)
|
|
1746
|
+
r = m2.fitfast(i, pop)
|
|
1747
|
+
r2 = m2.r_squared()
|
|
1748
|
+
if r2 > r2_target:
|
|
1749
|
+
break
|
|
1750
|
+
|
|
1751
|
+
mask = abs(m2.slopes) < max_cruise_rocd / 60.0
|
|
1752
|
+
bounds = r[:-1][mask], r[1:][mask]
|
|
1753
|
+
lvl = np.round(m2.intercepts[mask], -3)
|
|
1754
|
+
time_stack = np.repeat(elapsed_time[:, np.newaxis], lvl.size, axis=1)
|
|
1755
|
+
filt = (time_stack >= bounds[0]) & (time_stack <= bounds[1])
|
|
1756
|
+
altitude_ft = np.copy(altitude_ft)
|
|
1757
|
+
for i in range(lvl.size):
|
|
1758
|
+
altitude_ft[filt[:, i]] = lvl[i]
|
|
1759
|
+
|
|
1760
|
+
altitude_ft = scipy.signal.savgol_filter(altitude_ft, sg_window, sg_polyorder)
|
|
1761
|
+
|
|
1762
|
+
return altitude_ft
|