pycontrails 0.58.0__cp314-cp314-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 +70 -0
- pycontrails/_version.py +34 -0
- pycontrails/core/__init__.py +30 -0
- pycontrails/core/aircraft_performance.py +679 -0
- pycontrails/core/airports.py +228 -0
- pycontrails/core/cache.py +889 -0
- pycontrails/core/coordinates.py +174 -0
- pycontrails/core/fleet.py +483 -0
- pycontrails/core/flight.py +2185 -0
- pycontrails/core/flightplan.py +228 -0
- pycontrails/core/fuel.py +140 -0
- pycontrails/core/interpolation.py +702 -0
- pycontrails/core/met.py +2931 -0
- pycontrails/core/met_var.py +387 -0
- pycontrails/core/models.py +1321 -0
- pycontrails/core/polygon.py +549 -0
- pycontrails/core/rgi_cython.cpython-314-darwin.so +0 -0
- pycontrails/core/vector.py +2249 -0
- pycontrails/datalib/__init__.py +12 -0
- pycontrails/datalib/_met_utils/metsource.py +746 -0
- pycontrails/datalib/ecmwf/__init__.py +73 -0
- pycontrails/datalib/ecmwf/arco_era5.py +345 -0
- pycontrails/datalib/ecmwf/common.py +114 -0
- pycontrails/datalib/ecmwf/era5.py +554 -0
- pycontrails/datalib/ecmwf/era5_model_level.py +490 -0
- pycontrails/datalib/ecmwf/hres.py +804 -0
- pycontrails/datalib/ecmwf/hres_model_level.py +466 -0
- pycontrails/datalib/ecmwf/ifs.py +287 -0
- pycontrails/datalib/ecmwf/model_levels.py +435 -0
- pycontrails/datalib/ecmwf/static/model_level_dataframe_v20240418.csv +139 -0
- pycontrails/datalib/ecmwf/variables.py +268 -0
- pycontrails/datalib/geo_utils.py +261 -0
- pycontrails/datalib/gfs/__init__.py +28 -0
- pycontrails/datalib/gfs/gfs.py +656 -0
- pycontrails/datalib/gfs/variables.py +104 -0
- pycontrails/datalib/goes.py +757 -0
- pycontrails/datalib/himawari/__init__.py +27 -0
- pycontrails/datalib/himawari/header_struct.py +266 -0
- pycontrails/datalib/himawari/himawari.py +667 -0
- pycontrails/datalib/landsat.py +589 -0
- pycontrails/datalib/leo_utils/__init__.py +5 -0
- pycontrails/datalib/leo_utils/correction.py +266 -0
- pycontrails/datalib/leo_utils/landsat_metadata.py +300 -0
- pycontrails/datalib/leo_utils/search.py +250 -0
- pycontrails/datalib/leo_utils/sentinel_metadata.py +748 -0
- pycontrails/datalib/leo_utils/static/bq_roi_query.sql +6 -0
- pycontrails/datalib/leo_utils/vis.py +59 -0
- pycontrails/datalib/sentinel.py +650 -0
- pycontrails/datalib/spire/__init__.py +5 -0
- pycontrails/datalib/spire/exceptions.py +62 -0
- pycontrails/datalib/spire/spire.py +604 -0
- pycontrails/ext/bada.py +42 -0
- pycontrails/ext/cirium.py +14 -0
- pycontrails/ext/empirical_grid.py +140 -0
- pycontrails/ext/synthetic_flight.py +431 -0
- pycontrails/models/__init__.py +1 -0
- pycontrails/models/accf.py +425 -0
- pycontrails/models/apcemm/__init__.py +8 -0
- pycontrails/models/apcemm/apcemm.py +983 -0
- pycontrails/models/apcemm/inputs.py +226 -0
- pycontrails/models/apcemm/static/apcemm_yaml_template.yaml +183 -0
- pycontrails/models/apcemm/utils.py +437 -0
- pycontrails/models/cocip/__init__.py +29 -0
- pycontrails/models/cocip/cocip.py +2742 -0
- pycontrails/models/cocip/cocip_params.py +305 -0
- pycontrails/models/cocip/cocip_uncertainty.py +291 -0
- pycontrails/models/cocip/contrail_properties.py +1530 -0
- pycontrails/models/cocip/output_formats.py +2270 -0
- pycontrails/models/cocip/radiative_forcing.py +1260 -0
- pycontrails/models/cocip/radiative_heating.py +520 -0
- pycontrails/models/cocip/unterstrasser_wake_vortex.py +508 -0
- pycontrails/models/cocip/wake_vortex.py +396 -0
- pycontrails/models/cocip/wind_shear.py +120 -0
- pycontrails/models/cocipgrid/__init__.py +9 -0
- pycontrails/models/cocipgrid/cocip_grid.py +2552 -0
- pycontrails/models/cocipgrid/cocip_grid_params.py +138 -0
- pycontrails/models/dry_advection.py +602 -0
- pycontrails/models/emissions/__init__.py +21 -0
- pycontrails/models/emissions/black_carbon.py +599 -0
- pycontrails/models/emissions/emissions.py +1353 -0
- pycontrails/models/emissions/ffm2.py +336 -0
- pycontrails/models/emissions/static/default-engine-uids.csv +239 -0
- pycontrails/models/emissions/static/edb-gaseous-v29b-engines.csv +596 -0
- pycontrails/models/emissions/static/edb-nvpm-v29b-engines.csv +215 -0
- pycontrails/models/extended_k15.py +1327 -0
- pycontrails/models/humidity_scaling/__init__.py +37 -0
- pycontrails/models/humidity_scaling/humidity_scaling.py +1075 -0
- pycontrails/models/humidity_scaling/quantiles/era5-model-level-quantiles.pq +0 -0
- pycontrails/models/humidity_scaling/quantiles/era5-pressure-level-quantiles.pq +0 -0
- pycontrails/models/issr.py +210 -0
- pycontrails/models/pcc.py +326 -0
- pycontrails/models/pcr.py +154 -0
- pycontrails/models/ps_model/__init__.py +18 -0
- pycontrails/models/ps_model/ps_aircraft_params.py +381 -0
- pycontrails/models/ps_model/ps_grid.py +701 -0
- pycontrails/models/ps_model/ps_model.py +1000 -0
- pycontrails/models/ps_model/ps_operational_limits.py +525 -0
- pycontrails/models/ps_model/static/ps-aircraft-params-20250328.csv +69 -0
- pycontrails/models/ps_model/static/ps-synonym-list-20250328.csv +104 -0
- pycontrails/models/sac.py +442 -0
- pycontrails/models/tau_cirrus.py +183 -0
- pycontrails/physics/__init__.py +1 -0
- pycontrails/physics/constants.py +117 -0
- pycontrails/physics/geo.py +1138 -0
- pycontrails/physics/jet.py +968 -0
- pycontrails/physics/static/iata-cargo-load-factors-20250221.csv +74 -0
- pycontrails/physics/static/iata-passenger-load-factors-20250221.csv +74 -0
- pycontrails/physics/thermo.py +551 -0
- pycontrails/physics/units.py +472 -0
- pycontrails/py.typed +0 -0
- pycontrails/utils/__init__.py +1 -0
- pycontrails/utils/dependencies.py +66 -0
- pycontrails/utils/iteration.py +13 -0
- pycontrails/utils/json.py +187 -0
- pycontrails/utils/temp.py +50 -0
- pycontrails/utils/types.py +163 -0
- pycontrails-0.58.0.dist-info/METADATA +180 -0
- pycontrails-0.58.0.dist-info/RECORD +122 -0
- pycontrails-0.58.0.dist-info/WHEEL +6 -0
- pycontrails-0.58.0.dist-info/licenses/LICENSE +178 -0
- pycontrails-0.58.0.dist-info/licenses/NOTICE +43 -0
- pycontrails-0.58.0.dist-info/top_level.txt +3 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Coordinates utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import numpy.typing as npt
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def slice_domain(
|
|
13
|
+
domain: np.ndarray,
|
|
14
|
+
request: npt.ArrayLike,
|
|
15
|
+
buffer: tuple[float | np.timedelta64, float | np.timedelta64] = (0.0, 0.0),
|
|
16
|
+
) -> slice:
|
|
17
|
+
"""Return slice of ``domain`` containing coordinates overlapping ``request``.
|
|
18
|
+
|
|
19
|
+
Computes index-based slice (to be used with :meth:`xarray.Dataset.isel` method) as
|
|
20
|
+
opposed to value-based slice.
|
|
21
|
+
|
|
22
|
+
Returns ``slice(None, None)`` when ``domain`` has a length <= 2 or the ``request``
|
|
23
|
+
has all ``nan`` values.
|
|
24
|
+
|
|
25
|
+
.. versionchanged:: 0.24.1
|
|
26
|
+
|
|
27
|
+
The returned slice is the minimum index-based slice that contains the
|
|
28
|
+
requested coordinates. In other words, it's now possible for
|
|
29
|
+
``domain[sl][0]`` to equal ``min(request)`` where ``sl`` is the output
|
|
30
|
+
of this function. Previously, we were guaranteed that ``domain[sl][0]``
|
|
31
|
+
would be less than ``min(request)``.
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
domain : np.ndarray
|
|
37
|
+
Full set of domain values
|
|
38
|
+
request : npt.ArrayLike
|
|
39
|
+
Requested values. Only the nanmin and nanmax values are considered.
|
|
40
|
+
buffer : tuple[float | np.timedelta64, float | np.timedelta64], optional
|
|
41
|
+
Extend the domain past the requested coordinates by ``buffer[0]`` on the low side
|
|
42
|
+
and ``buffer[1]`` on the high side.
|
|
43
|
+
Units of ``buffer`` must be the same as ``domain``.
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
slice
|
|
48
|
+
Slice object for slicing out encompassing, or nearest, domain values
|
|
49
|
+
|
|
50
|
+
Raises
|
|
51
|
+
------
|
|
52
|
+
ValueError
|
|
53
|
+
Raises a ValueError when ``domain`` has all ``nan`` values.
|
|
54
|
+
|
|
55
|
+
Examples
|
|
56
|
+
--------
|
|
57
|
+
>>> domain = np.arange(-180, 180, 0.25)
|
|
58
|
+
|
|
59
|
+
>>> # Call with request as np.array
|
|
60
|
+
>>> request = np.linspace(-20, 20, 100)
|
|
61
|
+
>>> slice_domain(domain, request)
|
|
62
|
+
slice(np.int64(640), np.int64(801), None)
|
|
63
|
+
|
|
64
|
+
>>> # Call with request as tuple
|
|
65
|
+
>>> request = -20, 20
|
|
66
|
+
>>> slice_domain(domain, request)
|
|
67
|
+
slice(np.int64(640), np.int64(801), None)
|
|
68
|
+
|
|
69
|
+
>>> # Call with a buffer
|
|
70
|
+
>>> request = -16, 13
|
|
71
|
+
>>> buffer = 4, 7
|
|
72
|
+
>>> slice_domain(domain, request, buffer)
|
|
73
|
+
slice(np.int64(640), np.int64(801), None)
|
|
74
|
+
|
|
75
|
+
>>> # Call with request as a single number
|
|
76
|
+
>>> request = -20
|
|
77
|
+
>>> slice_domain(domain, request)
|
|
78
|
+
slice(np.int64(640), np.int64(641), None)
|
|
79
|
+
|
|
80
|
+
>>> request = -19.9
|
|
81
|
+
>>> slice_domain(domain, request)
|
|
82
|
+
slice(np.int64(640), np.int64(642), None)
|
|
83
|
+
|
|
84
|
+
"""
|
|
85
|
+
# if the length of domain coordinates is <= 2, return the whole domain
|
|
86
|
+
if len(domain) <= 2:
|
|
87
|
+
return slice(None, None)
|
|
88
|
+
|
|
89
|
+
if buffer == (None, None):
|
|
90
|
+
return slice(None, None)
|
|
91
|
+
|
|
92
|
+
# Remove nans from request
|
|
93
|
+
request = np.asarray(request)
|
|
94
|
+
mask = np.isnan(request)
|
|
95
|
+
if mask.all():
|
|
96
|
+
return slice(None, None)
|
|
97
|
+
|
|
98
|
+
request = request[~mask]
|
|
99
|
+
|
|
100
|
+
# if the whole domain or request is nan, then there is nothing to slice
|
|
101
|
+
if np.isnan(domain).all():
|
|
102
|
+
raise ValueError("Domain is all nan on request")
|
|
103
|
+
|
|
104
|
+
# ensure domain is sorted
|
|
105
|
+
zero: float | np.timedelta64
|
|
106
|
+
zero = np.timedelta64(0) if pd.api.types.is_datetime64_dtype(domain.dtype) else 0.0
|
|
107
|
+
if not np.all(np.diff(domain) >= zero):
|
|
108
|
+
raise ValueError("Domain must be sorted in ascending order")
|
|
109
|
+
|
|
110
|
+
buf0, buf1 = buffer
|
|
111
|
+
if buf0 < zero or buf1 < zero:
|
|
112
|
+
warnings.warn(
|
|
113
|
+
"Found buffer with negative value. This is unexpected "
|
|
114
|
+
"and will reduce the size of the requested domain instead of "
|
|
115
|
+
"extending it. Both the left and right buffer values should be "
|
|
116
|
+
"nonnegative."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# get the index of the closest value to request min and max
|
|
120
|
+
# side left returns `i`: domain[i-1] < request <= domain[i]
|
|
121
|
+
# side right returns `i`: domain[i-1] <= request < domain[i]
|
|
122
|
+
idx_min = np.searchsorted(domain, np.min(request) - buf0, side="right") - 1
|
|
123
|
+
idx_max = np.searchsorted(domain, np.max(request) + buf1, side="left") + 1
|
|
124
|
+
|
|
125
|
+
# clip idx_min between [0, len(domain) - 2]
|
|
126
|
+
idx_min = min(len(domain) - 2, max(idx_min, 0))
|
|
127
|
+
|
|
128
|
+
# clip idx_max between [2, len(domain)]
|
|
129
|
+
idx_max = min(len(domain), max(idx_max, 2))
|
|
130
|
+
|
|
131
|
+
return slice(idx_min, idx_max)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def intersect_domain(
|
|
135
|
+
domain: np.ndarray,
|
|
136
|
+
request: np.ndarray,
|
|
137
|
+
) -> np.ndarray:
|
|
138
|
+
"""Return boolean mask of ``request`` that are within the bounds of ``domain``.
|
|
139
|
+
|
|
140
|
+
Parameters
|
|
141
|
+
----------
|
|
142
|
+
domain : np.ndarray
|
|
143
|
+
Full set of domain values
|
|
144
|
+
request : np.ndarray
|
|
145
|
+
Full set of requested values
|
|
146
|
+
|
|
147
|
+
Returns
|
|
148
|
+
-------
|
|
149
|
+
np.ndarray
|
|
150
|
+
Boolean array of ``request`` values within the bounds of ``domain``
|
|
151
|
+
|
|
152
|
+
Raises
|
|
153
|
+
------
|
|
154
|
+
ValueError
|
|
155
|
+
Raises a ValueError when ``domain`` has all ``nan`` values.
|
|
156
|
+
|
|
157
|
+
Examples
|
|
158
|
+
--------
|
|
159
|
+
>>> domain = np.array([3.0, 4.0, 2.0])
|
|
160
|
+
>>> request = np.arange(1.0, 6.0)
|
|
161
|
+
>>> intersect_domain(domain, request)
|
|
162
|
+
array([False, True, True, True, False])
|
|
163
|
+
|
|
164
|
+
>>> domain = np.array([3.0, np.nan, np.nan])
|
|
165
|
+
>>> request = np.arange(1.0, 6.0)
|
|
166
|
+
>>> intersect_domain(domain, request)
|
|
167
|
+
array([False, False, True, False, False])
|
|
168
|
+
"""
|
|
169
|
+
isnan = np.isnan(domain)
|
|
170
|
+
if isnan.all():
|
|
171
|
+
raise ValueError("Domain is all nan on request")
|
|
172
|
+
|
|
173
|
+
domain = domain[~isnan]
|
|
174
|
+
return (request >= np.min(domain)) & (request <= np.max(domain))
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"""A single data structure encompassing a sequence of :class:`Flight` instances."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import warnings
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from typing import Any, NoReturn, Self
|
|
9
|
+
|
|
10
|
+
if sys.version_info >= (3, 12):
|
|
11
|
+
from typing import override
|
|
12
|
+
else:
|
|
13
|
+
from typing_extensions import override
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import numpy.typing as npt
|
|
17
|
+
import pandas as pd
|
|
18
|
+
|
|
19
|
+
from pycontrails.core.flight import Flight
|
|
20
|
+
from pycontrails.core.fuel import Fuel, JetA
|
|
21
|
+
from pycontrails.core.vector import GeoVectorDataset, VectorDataDict, VectorDataset
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Fleet(Flight):
|
|
25
|
+
"""Data structure for holding a sequence of :class:`Flight` instances.
|
|
26
|
+
|
|
27
|
+
Flight waypoints are merged into a single :class:`Flight`-like object.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
__slots__ = ("final_waypoints", "fl_attrs")
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
data: (
|
|
35
|
+
dict[str, npt.ArrayLike] | pd.DataFrame | VectorDataDict | VectorDataset | None
|
|
36
|
+
) = None,
|
|
37
|
+
*,
|
|
38
|
+
longitude: npt.ArrayLike | None = None,
|
|
39
|
+
latitude: npt.ArrayLike | None = None,
|
|
40
|
+
altitude: npt.ArrayLike | None = None,
|
|
41
|
+
altitude_ft: npt.ArrayLike | None = None,
|
|
42
|
+
level: npt.ArrayLike | None = None,
|
|
43
|
+
time: npt.ArrayLike | None = None,
|
|
44
|
+
attrs: dict[str, Any] | None = None,
|
|
45
|
+
copy: bool = True,
|
|
46
|
+
fuel: Fuel | None = None,
|
|
47
|
+
fl_attrs: dict[str, Any] | None = None,
|
|
48
|
+
**attrs_kwargs: Any,
|
|
49
|
+
) -> None:
|
|
50
|
+
# Do not want to call Flight.__init__
|
|
51
|
+
# The Flight constructor assumes a sorted time column
|
|
52
|
+
GeoVectorDataset.__init__(
|
|
53
|
+
self,
|
|
54
|
+
data=data,
|
|
55
|
+
longitude=longitude,
|
|
56
|
+
latitude=latitude,
|
|
57
|
+
altitude=altitude,
|
|
58
|
+
altitude_ft=altitude_ft,
|
|
59
|
+
level=level,
|
|
60
|
+
time=time,
|
|
61
|
+
attrs=attrs,
|
|
62
|
+
copy=copy,
|
|
63
|
+
**attrs_kwargs,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
self.fuel = fuel or JetA()
|
|
67
|
+
self.final_waypoints, self.fl_attrs = self._validate(fl_attrs)
|
|
68
|
+
|
|
69
|
+
def _validate(
|
|
70
|
+
self, fl_attrs: dict[str, Any] | None = None
|
|
71
|
+
) -> tuple[npt.NDArray[np.bool_], dict[str, Any]]:
|
|
72
|
+
"""Validate data, update fl_attrs and calculate the final waypoint of each flight.
|
|
73
|
+
|
|
74
|
+
Parameters
|
|
75
|
+
----------
|
|
76
|
+
fl_attrs : dict[str, Any] | None, optional
|
|
77
|
+
Dictionary of individual :class:`Flight` attributes.
|
|
78
|
+
|
|
79
|
+
Returns
|
|
80
|
+
-------
|
|
81
|
+
final_waypoints : npt.NDArray[np.bool_]
|
|
82
|
+
A boolean array in which True values correspond to final waypoint of each flight.
|
|
83
|
+
fl_attrs : dict[str, Any]
|
|
84
|
+
Updated dictionary of individual :class:`Flight` attributes.
|
|
85
|
+
|
|
86
|
+
Raises
|
|
87
|
+
------
|
|
88
|
+
KeyError, ValueError
|
|
89
|
+
Fleet :attr:`data` does not take the expected form.
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
flight_id = self["flight_id"]
|
|
93
|
+
except KeyError as exc:
|
|
94
|
+
msg = "Fleet must have a 'flight_id' key in its 'data'."
|
|
95
|
+
raise KeyError(msg) from exc
|
|
96
|
+
|
|
97
|
+
# Some pandas groupby magic to ensure flights are arranged in blocks
|
|
98
|
+
df = pd.DataFrame({"flight_id": flight_id, "index": np.arange(self.size)})
|
|
99
|
+
grouped = df.groupby("flight_id", sort=False)
|
|
100
|
+
groups = grouped.agg({"flight_id": "size", "index": ["first", "last"]})
|
|
101
|
+
|
|
102
|
+
expected_size = groups[("index", "last")] - groups[("index", "first")] + 1
|
|
103
|
+
actual_size = groups[("flight_id", "size")]
|
|
104
|
+
if not np.array_equal(expected_size, actual_size):
|
|
105
|
+
msg = (
|
|
106
|
+
"Fleet must have contiguous waypoint blocks with constant flight_id. "
|
|
107
|
+
"If instantiating from a DataFrame, call df.sort_values(by=['flight_id', 'time']) "
|
|
108
|
+
"before passing to Fleet."
|
|
109
|
+
)
|
|
110
|
+
raise ValueError(msg)
|
|
111
|
+
|
|
112
|
+
# Calculate boolean array of final waypoints by flight
|
|
113
|
+
final_waypoints = np.zeros(self.size, dtype=bool)
|
|
114
|
+
final_waypoint_indices = groups[("index", "last")].to_numpy()
|
|
115
|
+
final_waypoints[final_waypoint_indices] = True
|
|
116
|
+
|
|
117
|
+
# Set default fl_attrs if not provided
|
|
118
|
+
fl_attrs = fl_attrs or {}
|
|
119
|
+
for flight_id in groups.index:
|
|
120
|
+
fl_attrs.setdefault(flight_id, {})
|
|
121
|
+
|
|
122
|
+
extra = fl_attrs.keys() - groups.index
|
|
123
|
+
if extra:
|
|
124
|
+
msg = f"Unexpected flight_id(s) {extra} in fl_attrs."
|
|
125
|
+
raise ValueError(msg)
|
|
126
|
+
|
|
127
|
+
return final_waypoints, fl_attrs
|
|
128
|
+
|
|
129
|
+
@override
|
|
130
|
+
def copy(self, **kwargs: Any) -> Self:
|
|
131
|
+
kwargs.setdefault("fl_attrs", self.fl_attrs)
|
|
132
|
+
kwargs.setdefault("final_waypoints", self.final_waypoints)
|
|
133
|
+
return super().copy(**kwargs)
|
|
134
|
+
|
|
135
|
+
@override
|
|
136
|
+
def filter(self, mask: npt.NDArray[np.bool_], copy: bool = True, **kwargs: Any) -> Self:
|
|
137
|
+
flight_ids = set(np.unique(self["flight_id"][mask]))
|
|
138
|
+
fl_attrs = {k: v for k, v in self.fl_attrs.items() if k in flight_ids}
|
|
139
|
+
kwargs.setdefault("fl_attrs", fl_attrs)
|
|
140
|
+
|
|
141
|
+
final_waypoints = np.array(self.final_waypoints[mask], copy=copy)
|
|
142
|
+
kwargs.setdefault("final_waypoints", final_waypoints)
|
|
143
|
+
|
|
144
|
+
return super().filter(mask, copy=copy, **kwargs)
|
|
145
|
+
|
|
146
|
+
@override
|
|
147
|
+
def sort(self, by: str | list[str]) -> NoReturn:
|
|
148
|
+
msg = (
|
|
149
|
+
"Fleet.sort is not implemented. A Fleet instance must be sorted "
|
|
150
|
+
"by ['flight_id', 'time'] (this is enforced in Fleet._validate). "
|
|
151
|
+
"To force sorting, create a GeoVectorDataset instance "
|
|
152
|
+
"and call the 'sort' method."
|
|
153
|
+
)
|
|
154
|
+
raise ValueError(msg)
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def from_seq(
|
|
158
|
+
cls,
|
|
159
|
+
seq: Iterable[Flight],
|
|
160
|
+
broadcast_numeric: bool = True,
|
|
161
|
+
attrs: dict[str, Any] | None = None,
|
|
162
|
+
) -> Self:
|
|
163
|
+
"""Instantiate a :class:`Fleet` instance from an iterable of :class:`Flight`.
|
|
164
|
+
|
|
165
|
+
.. versionchanged:: 0.49.3
|
|
166
|
+
|
|
167
|
+
Empty flights are now filtered out before concatenation.
|
|
168
|
+
|
|
169
|
+
Parameters
|
|
170
|
+
----------
|
|
171
|
+
seq : Iterable[Flight]
|
|
172
|
+
An iterable of :class:`Flight` instances.
|
|
173
|
+
broadcast_numeric : bool, optional
|
|
174
|
+
If True, broadcast numeric attributes to data variables.
|
|
175
|
+
attrs : dict[str, Any] | None, optional
|
|
176
|
+
Global attribute to attach to instance.
|
|
177
|
+
|
|
178
|
+
Returns
|
|
179
|
+
-------
|
|
180
|
+
Self
|
|
181
|
+
A `Fleet` instance made from concatenating the :class:`Flight`
|
|
182
|
+
instances in ``seq``. The fuel type is taken from the first :class:`Flight`
|
|
183
|
+
in ``seq``.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
# Create a shallow copy because we add additional keys in _validate_fl
|
|
187
|
+
def _shallow_copy(fl: Flight) -> Flight:
|
|
188
|
+
return Flight._from_fastpath(fl.data, fl.attrs, fuel=fl.fuel)
|
|
189
|
+
|
|
190
|
+
def _maybe_warn(fl: Flight) -> Flight:
|
|
191
|
+
if not fl:
|
|
192
|
+
warnings.warn("Empty flight found in sequence. It will be filtered out.")
|
|
193
|
+
return fl
|
|
194
|
+
|
|
195
|
+
seq = tuple(_shallow_copy(fl) for fl in seq if _maybe_warn(fl))
|
|
196
|
+
|
|
197
|
+
if not seq:
|
|
198
|
+
msg = "Cannot create Fleet from empty sequence."
|
|
199
|
+
raise ValueError(msg)
|
|
200
|
+
|
|
201
|
+
fl_attrs: dict[str, Any] = {}
|
|
202
|
+
|
|
203
|
+
# Pluck from the first flight to get fuel and data_keys
|
|
204
|
+
fuel = seq[0].fuel
|
|
205
|
+
data_keys = set(seq[0]) # convert to a new instance to because we mutate seq[0]
|
|
206
|
+
|
|
207
|
+
for fl in seq:
|
|
208
|
+
_validate_fl(
|
|
209
|
+
fl,
|
|
210
|
+
fl_attrs=fl_attrs,
|
|
211
|
+
data_keys=data_keys,
|
|
212
|
+
fuel=fuel,
|
|
213
|
+
broadcast_numeric=broadcast_numeric,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
data = {var: np.concatenate([fl[var] for fl in seq]) for var in seq[0]}
|
|
217
|
+
|
|
218
|
+
final_waypoints = np.zeros(data["time"].size, dtype=bool)
|
|
219
|
+
final_waypoint_indices = np.cumsum([fl.size for fl in seq]) - 1
|
|
220
|
+
final_waypoints[final_waypoint_indices] = True
|
|
221
|
+
|
|
222
|
+
return cls._from_fastpath(
|
|
223
|
+
data,
|
|
224
|
+
attrs,
|
|
225
|
+
fuel=fuel,
|
|
226
|
+
fl_attrs=fl_attrs,
|
|
227
|
+
final_waypoints=final_waypoints,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def n_flights(self) -> int:
|
|
232
|
+
"""Return number of distinct flights.
|
|
233
|
+
|
|
234
|
+
Returns
|
|
235
|
+
-------
|
|
236
|
+
int
|
|
237
|
+
Number of flights
|
|
238
|
+
"""
|
|
239
|
+
return len(self.fl_attrs)
|
|
240
|
+
|
|
241
|
+
def to_flight_list(self, copy: bool = True) -> list[Flight]:
|
|
242
|
+
"""De-concatenate merged waypoints into a list of :class:`Flight` instances.
|
|
243
|
+
|
|
244
|
+
Any global :attr:`attrs` are lost.
|
|
245
|
+
|
|
246
|
+
Parameters
|
|
247
|
+
----------
|
|
248
|
+
copy : bool, optional
|
|
249
|
+
If True, make copy of each :class:`Flight` instance.
|
|
250
|
+
|
|
251
|
+
Returns
|
|
252
|
+
-------
|
|
253
|
+
list[Flight]
|
|
254
|
+
List of Flights in the same order as was passed into the ``Fleet`` instance.
|
|
255
|
+
"""
|
|
256
|
+
indices = self.dataframe.groupby("flight_id", sort=False).indices
|
|
257
|
+
if copy:
|
|
258
|
+
return [
|
|
259
|
+
Flight._from_fastpath(
|
|
260
|
+
{k: v[idx] for k, v in self.data.items()},
|
|
261
|
+
self.fl_attrs[flight_id],
|
|
262
|
+
fuel=self.fuel,
|
|
263
|
+
).copy()
|
|
264
|
+
for flight_id, idx in indices.items()
|
|
265
|
+
]
|
|
266
|
+
return [
|
|
267
|
+
Flight._from_fastpath(
|
|
268
|
+
{k: v[idx] for k, v in self.data.items()},
|
|
269
|
+
self.fl_attrs[flight_id],
|
|
270
|
+
fuel=self.fuel,
|
|
271
|
+
)
|
|
272
|
+
for flight_id, idx in indices.items()
|
|
273
|
+
]
|
|
274
|
+
|
|
275
|
+
###################################
|
|
276
|
+
# Flight methods involving segments
|
|
277
|
+
###################################
|
|
278
|
+
|
|
279
|
+
def segment_true_airspeed(
|
|
280
|
+
self,
|
|
281
|
+
u_wind: npt.NDArray[np.floating] | float = 0.0,
|
|
282
|
+
v_wind: npt.NDArray[np.floating] | float = 0.0,
|
|
283
|
+
smooth: bool = True,
|
|
284
|
+
window_length: int = 7,
|
|
285
|
+
polyorder: int = 1,
|
|
286
|
+
) -> npt.NDArray[np.floating]:
|
|
287
|
+
"""Calculate the true airspeed [:math:`m / s`] from the ground speed and horizontal winds.
|
|
288
|
+
|
|
289
|
+
Because Flight.segment_true_airspeed uses a smoothing pattern, waypoints in :attr:`data`
|
|
290
|
+
are not independent. Moreover, we expect the final waypoint of each flight to have a nan
|
|
291
|
+
value associated to any segment property. Consequently, we need to define a custom method
|
|
292
|
+
here to deal with these issues when applying this method on a fleet of flights.
|
|
293
|
+
|
|
294
|
+
See docstring for :meth:`Flight.segment_true_airspeed`.
|
|
295
|
+
|
|
296
|
+
Raises
|
|
297
|
+
------
|
|
298
|
+
RuntimeError
|
|
299
|
+
Unexpected key `__u_wind` or `__v_wind` found in :attr:`data`.
|
|
300
|
+
"""
|
|
301
|
+
if isinstance(u_wind, np.ndarray):
|
|
302
|
+
# Choosing a key we don't think exists
|
|
303
|
+
key = "__u_wind"
|
|
304
|
+
if key in self:
|
|
305
|
+
msg = f"Unexpected key {key} found"
|
|
306
|
+
raise RuntimeError(msg)
|
|
307
|
+
self[key] = u_wind
|
|
308
|
+
|
|
309
|
+
if isinstance(v_wind, np.ndarray):
|
|
310
|
+
# Choosing a key we don't think exists
|
|
311
|
+
key = "__v_wind"
|
|
312
|
+
if key in self:
|
|
313
|
+
msg = f"Unexpected key {key} found"
|
|
314
|
+
raise RuntimeError(msg)
|
|
315
|
+
self[key] = v_wind
|
|
316
|
+
|
|
317
|
+
# Calculate TAS on each flight individually
|
|
318
|
+
def calc_tas(fl: Flight) -> npt.NDArray[np.floating]:
|
|
319
|
+
u = fl.get("__u_wind", u_wind)
|
|
320
|
+
v = fl.get("__v_wind", v_wind)
|
|
321
|
+
|
|
322
|
+
return fl.segment_true_airspeed(
|
|
323
|
+
u, v, smooth=smooth, window_length=window_length, polyorder=polyorder
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
fls = self.to_flight_list(copy=False)
|
|
327
|
+
tas = [calc_tas(fl) for fl in fls]
|
|
328
|
+
|
|
329
|
+
# Cleanup
|
|
330
|
+
self.data.pop("__u_wind", None)
|
|
331
|
+
self.data.pop("__v_wind", None)
|
|
332
|
+
|
|
333
|
+
# Making an assumption here that Fleet was instantiated by `from_seq`
|
|
334
|
+
# method. If this is not the case, the order may be off when to_flight_list
|
|
335
|
+
# is called.
|
|
336
|
+
# Currently, we expect to only use Fleet "internally", so this more general
|
|
337
|
+
# use case isn't seen.
|
|
338
|
+
return np.concatenate(tas)
|
|
339
|
+
|
|
340
|
+
@override
|
|
341
|
+
def segment_groundspeed(self, *args: Any, **kwargs: Any) -> npt.NDArray[np.floating]:
|
|
342
|
+
fls = self.to_flight_list(copy=False)
|
|
343
|
+
gs = [fl.segment_groundspeed(*args, **kwargs) for fl in fls]
|
|
344
|
+
return np.concatenate(gs)
|
|
345
|
+
|
|
346
|
+
@override
|
|
347
|
+
def resample_and_fill(self, *args: Any, **kwargs: Any) -> Self:
|
|
348
|
+
flights = self.to_flight_list(copy=False)
|
|
349
|
+
|
|
350
|
+
# We need to ensure that each flight has an flight_id attrs field
|
|
351
|
+
# When we call fl.resample_and_fill, any flight_id data field
|
|
352
|
+
# will be lost, so the call to Fleet.from_seq will fail.
|
|
353
|
+
for fl in flights:
|
|
354
|
+
if "flight_id" not in fl.attrs:
|
|
355
|
+
fl.attrs["flight_id"] = _extract_flight_id(fl)
|
|
356
|
+
|
|
357
|
+
flights = [fl.resample_and_fill(*args, **kwargs) for fl in flights]
|
|
358
|
+
return type(self).from_seq(flights, broadcast_numeric=False, attrs=self.attrs)
|
|
359
|
+
|
|
360
|
+
@override
|
|
361
|
+
def segment_length(self) -> npt.NDArray[np.floating]:
|
|
362
|
+
return np.where(self.final_waypoints, np.nan, super().segment_length())
|
|
363
|
+
|
|
364
|
+
@property
|
|
365
|
+
@override
|
|
366
|
+
def max_distance_gap(self) -> float:
|
|
367
|
+
return np.nanmax(self.segment_length()).item()
|
|
368
|
+
|
|
369
|
+
@override
|
|
370
|
+
def segment_azimuth(self) -> npt.NDArray[np.floating]:
|
|
371
|
+
return np.where(self.final_waypoints, np.nan, super().segment_azimuth())
|
|
372
|
+
|
|
373
|
+
@override
|
|
374
|
+
def segment_angle(self) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]:
|
|
375
|
+
sin_a, cos_a = super().segment_angle()
|
|
376
|
+
sin_a[self.final_waypoints] = np.nan
|
|
377
|
+
cos_a[self.final_waypoints] = np.nan
|
|
378
|
+
return sin_a, cos_a
|
|
379
|
+
|
|
380
|
+
@override
|
|
381
|
+
def clean_and_resample(
|
|
382
|
+
self,
|
|
383
|
+
freq: str = "1min",
|
|
384
|
+
fill_method: str = "geodesic",
|
|
385
|
+
geodesic_threshold: float = 100e3,
|
|
386
|
+
nominal_rocd: float = 0.0,
|
|
387
|
+
kernel_size: int = 17,
|
|
388
|
+
cruise_threshold: float = 120,
|
|
389
|
+
force_filter: bool = False,
|
|
390
|
+
drop: bool = True,
|
|
391
|
+
keep_original_index: bool = False,
|
|
392
|
+
) -> NoReturn:
|
|
393
|
+
msg = "Only implemented for Flight instances"
|
|
394
|
+
raise NotImplementedError(msg)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _extract_flight_id(fl: Flight) -> str:
|
|
398
|
+
"""Extract flight_id from Flight instance."""
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
return fl.attrs["flight_id"]
|
|
402
|
+
except KeyError:
|
|
403
|
+
pass
|
|
404
|
+
|
|
405
|
+
try:
|
|
406
|
+
flight_ids = fl["flight_id"]
|
|
407
|
+
except KeyError as exc:
|
|
408
|
+
msg = "Each flight must have a 'flight_id' key in its 'attrs'."
|
|
409
|
+
raise KeyError(msg) from exc
|
|
410
|
+
|
|
411
|
+
tmp = np.unique(flight_ids)
|
|
412
|
+
if len(tmp) > 1:
|
|
413
|
+
msg = f"Multiple flight_ids {tmp} found in Flight."
|
|
414
|
+
raise ValueError(msg)
|
|
415
|
+
if len(tmp) == 0:
|
|
416
|
+
msg = "Flight has no flight_id."
|
|
417
|
+
raise ValueError(msg)
|
|
418
|
+
return tmp[0]
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _validate_fl(
|
|
422
|
+
fl: Flight,
|
|
423
|
+
*,
|
|
424
|
+
fl_attrs: dict[str, Any],
|
|
425
|
+
data_keys: set[str],
|
|
426
|
+
fuel: Fuel,
|
|
427
|
+
broadcast_numeric: bool,
|
|
428
|
+
) -> None:
|
|
429
|
+
"""Attach "flight_id" and "waypoint" columns to flight :attr:`data`.
|
|
430
|
+
|
|
431
|
+
Mutates parameter ``fl`` and ``fl_attrs`` in place.
|
|
432
|
+
|
|
433
|
+
Parameters
|
|
434
|
+
----------
|
|
435
|
+
fl : Flight
|
|
436
|
+
Flight instance to process.
|
|
437
|
+
fl_attrs : dict[str, Any]
|
|
438
|
+
Dictionary of `Flight` attributes. Attributes belonging to `fl` are attached
|
|
439
|
+
to `fl_attrs` under the "flight_id" key.
|
|
440
|
+
data_keys : set[str]
|
|
441
|
+
Set of data keys expected in each flight.
|
|
442
|
+
fuel : Fuel
|
|
443
|
+
Fuel used all flights
|
|
444
|
+
broadcast_numeric : bool
|
|
445
|
+
If True, broadcast numeric attributes to data variables.
|
|
446
|
+
|
|
447
|
+
Raises
|
|
448
|
+
------
|
|
449
|
+
KeyError
|
|
450
|
+
``fl`` does not have a ``flight_id`` key in :attr:`attrs`.
|
|
451
|
+
ValueError
|
|
452
|
+
If ``flight_id`` is duplicated or if ``fuel`` or ``data_keys`` are inconsistent.
|
|
453
|
+
"""
|
|
454
|
+
flight_id = _extract_flight_id(fl)
|
|
455
|
+
|
|
456
|
+
if flight_id in fl_attrs:
|
|
457
|
+
msg = f"Duplicate 'flight_id' {flight_id} found."
|
|
458
|
+
raise ValueError(msg)
|
|
459
|
+
fl_attrs[flight_id] = fl.attrs
|
|
460
|
+
|
|
461
|
+
# Verify consistency across flights
|
|
462
|
+
if fl.fuel != fuel:
|
|
463
|
+
msg = (
|
|
464
|
+
f"Fuel type on Flight {flight_id} ({fl.fuel.fuel_name}) "
|
|
465
|
+
f"is not inconsistent with previous flights ({fuel.fuel_name}). "
|
|
466
|
+
"The 'fuel' attributes must be consistent between flights in a Fleet."
|
|
467
|
+
)
|
|
468
|
+
raise ValueError(msg)
|
|
469
|
+
if fl.data.keys() != data_keys:
|
|
470
|
+
msg = (
|
|
471
|
+
f"Data keys on Flight {flight_id} ({fl.data.keys()}) "
|
|
472
|
+
f"is not inconsistent with previous flights ({data_keys}). "
|
|
473
|
+
"The 'data_keys' attributes must be consistent between flights in a Fleet."
|
|
474
|
+
)
|
|
475
|
+
raise ValueError(msg)
|
|
476
|
+
|
|
477
|
+
# Expand data
|
|
478
|
+
if broadcast_numeric:
|
|
479
|
+
fl.broadcast_numeric_attrs()
|
|
480
|
+
if "waypoint" not in fl:
|
|
481
|
+
fl["waypoint"] = np.arange(fl.size)
|
|
482
|
+
if "flight_id" not in fl:
|
|
483
|
+
fl["flight_id"] = np.full(fl.size, flight_id)
|