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.

Files changed (122) hide show
  1. pycontrails/__init__.py +70 -0
  2. pycontrails/_version.py +34 -0
  3. pycontrails/core/__init__.py +30 -0
  4. pycontrails/core/aircraft_performance.py +679 -0
  5. pycontrails/core/airports.py +228 -0
  6. pycontrails/core/cache.py +889 -0
  7. pycontrails/core/coordinates.py +174 -0
  8. pycontrails/core/fleet.py +483 -0
  9. pycontrails/core/flight.py +2185 -0
  10. pycontrails/core/flightplan.py +228 -0
  11. pycontrails/core/fuel.py +140 -0
  12. pycontrails/core/interpolation.py +702 -0
  13. pycontrails/core/met.py +2931 -0
  14. pycontrails/core/met_var.py +387 -0
  15. pycontrails/core/models.py +1321 -0
  16. pycontrails/core/polygon.py +549 -0
  17. pycontrails/core/rgi_cython.cpython-314-darwin.so +0 -0
  18. pycontrails/core/vector.py +2249 -0
  19. pycontrails/datalib/__init__.py +12 -0
  20. pycontrails/datalib/_met_utils/metsource.py +746 -0
  21. pycontrails/datalib/ecmwf/__init__.py +73 -0
  22. pycontrails/datalib/ecmwf/arco_era5.py +345 -0
  23. pycontrails/datalib/ecmwf/common.py +114 -0
  24. pycontrails/datalib/ecmwf/era5.py +554 -0
  25. pycontrails/datalib/ecmwf/era5_model_level.py +490 -0
  26. pycontrails/datalib/ecmwf/hres.py +804 -0
  27. pycontrails/datalib/ecmwf/hres_model_level.py +466 -0
  28. pycontrails/datalib/ecmwf/ifs.py +287 -0
  29. pycontrails/datalib/ecmwf/model_levels.py +435 -0
  30. pycontrails/datalib/ecmwf/static/model_level_dataframe_v20240418.csv +139 -0
  31. pycontrails/datalib/ecmwf/variables.py +268 -0
  32. pycontrails/datalib/geo_utils.py +261 -0
  33. pycontrails/datalib/gfs/__init__.py +28 -0
  34. pycontrails/datalib/gfs/gfs.py +656 -0
  35. pycontrails/datalib/gfs/variables.py +104 -0
  36. pycontrails/datalib/goes.py +757 -0
  37. pycontrails/datalib/himawari/__init__.py +27 -0
  38. pycontrails/datalib/himawari/header_struct.py +266 -0
  39. pycontrails/datalib/himawari/himawari.py +667 -0
  40. pycontrails/datalib/landsat.py +589 -0
  41. pycontrails/datalib/leo_utils/__init__.py +5 -0
  42. pycontrails/datalib/leo_utils/correction.py +266 -0
  43. pycontrails/datalib/leo_utils/landsat_metadata.py +300 -0
  44. pycontrails/datalib/leo_utils/search.py +250 -0
  45. pycontrails/datalib/leo_utils/sentinel_metadata.py +748 -0
  46. pycontrails/datalib/leo_utils/static/bq_roi_query.sql +6 -0
  47. pycontrails/datalib/leo_utils/vis.py +59 -0
  48. pycontrails/datalib/sentinel.py +650 -0
  49. pycontrails/datalib/spire/__init__.py +5 -0
  50. pycontrails/datalib/spire/exceptions.py +62 -0
  51. pycontrails/datalib/spire/spire.py +604 -0
  52. pycontrails/ext/bada.py +42 -0
  53. pycontrails/ext/cirium.py +14 -0
  54. pycontrails/ext/empirical_grid.py +140 -0
  55. pycontrails/ext/synthetic_flight.py +431 -0
  56. pycontrails/models/__init__.py +1 -0
  57. pycontrails/models/accf.py +425 -0
  58. pycontrails/models/apcemm/__init__.py +8 -0
  59. pycontrails/models/apcemm/apcemm.py +983 -0
  60. pycontrails/models/apcemm/inputs.py +226 -0
  61. pycontrails/models/apcemm/static/apcemm_yaml_template.yaml +183 -0
  62. pycontrails/models/apcemm/utils.py +437 -0
  63. pycontrails/models/cocip/__init__.py +29 -0
  64. pycontrails/models/cocip/cocip.py +2742 -0
  65. pycontrails/models/cocip/cocip_params.py +305 -0
  66. pycontrails/models/cocip/cocip_uncertainty.py +291 -0
  67. pycontrails/models/cocip/contrail_properties.py +1530 -0
  68. pycontrails/models/cocip/output_formats.py +2270 -0
  69. pycontrails/models/cocip/radiative_forcing.py +1260 -0
  70. pycontrails/models/cocip/radiative_heating.py +520 -0
  71. pycontrails/models/cocip/unterstrasser_wake_vortex.py +508 -0
  72. pycontrails/models/cocip/wake_vortex.py +396 -0
  73. pycontrails/models/cocip/wind_shear.py +120 -0
  74. pycontrails/models/cocipgrid/__init__.py +9 -0
  75. pycontrails/models/cocipgrid/cocip_grid.py +2552 -0
  76. pycontrails/models/cocipgrid/cocip_grid_params.py +138 -0
  77. pycontrails/models/dry_advection.py +602 -0
  78. pycontrails/models/emissions/__init__.py +21 -0
  79. pycontrails/models/emissions/black_carbon.py +599 -0
  80. pycontrails/models/emissions/emissions.py +1353 -0
  81. pycontrails/models/emissions/ffm2.py +336 -0
  82. pycontrails/models/emissions/static/default-engine-uids.csv +239 -0
  83. pycontrails/models/emissions/static/edb-gaseous-v29b-engines.csv +596 -0
  84. pycontrails/models/emissions/static/edb-nvpm-v29b-engines.csv +215 -0
  85. pycontrails/models/extended_k15.py +1327 -0
  86. pycontrails/models/humidity_scaling/__init__.py +37 -0
  87. pycontrails/models/humidity_scaling/humidity_scaling.py +1075 -0
  88. pycontrails/models/humidity_scaling/quantiles/era5-model-level-quantiles.pq +0 -0
  89. pycontrails/models/humidity_scaling/quantiles/era5-pressure-level-quantiles.pq +0 -0
  90. pycontrails/models/issr.py +210 -0
  91. pycontrails/models/pcc.py +326 -0
  92. pycontrails/models/pcr.py +154 -0
  93. pycontrails/models/ps_model/__init__.py +18 -0
  94. pycontrails/models/ps_model/ps_aircraft_params.py +381 -0
  95. pycontrails/models/ps_model/ps_grid.py +701 -0
  96. pycontrails/models/ps_model/ps_model.py +1000 -0
  97. pycontrails/models/ps_model/ps_operational_limits.py +525 -0
  98. pycontrails/models/ps_model/static/ps-aircraft-params-20250328.csv +69 -0
  99. pycontrails/models/ps_model/static/ps-synonym-list-20250328.csv +104 -0
  100. pycontrails/models/sac.py +442 -0
  101. pycontrails/models/tau_cirrus.py +183 -0
  102. pycontrails/physics/__init__.py +1 -0
  103. pycontrails/physics/constants.py +117 -0
  104. pycontrails/physics/geo.py +1138 -0
  105. pycontrails/physics/jet.py +968 -0
  106. pycontrails/physics/static/iata-cargo-load-factors-20250221.csv +74 -0
  107. pycontrails/physics/static/iata-passenger-load-factors-20250221.csv +74 -0
  108. pycontrails/physics/thermo.py +551 -0
  109. pycontrails/physics/units.py +472 -0
  110. pycontrails/py.typed +0 -0
  111. pycontrails/utils/__init__.py +1 -0
  112. pycontrails/utils/dependencies.py +66 -0
  113. pycontrails/utils/iteration.py +13 -0
  114. pycontrails/utils/json.py +187 -0
  115. pycontrails/utils/temp.py +50 -0
  116. pycontrails/utils/types.py +163 -0
  117. pycontrails-0.58.0.dist-info/METADATA +180 -0
  118. pycontrails-0.58.0.dist-info/RECORD +122 -0
  119. pycontrails-0.58.0.dist-info/WHEEL +6 -0
  120. pycontrails-0.58.0.dist-info/licenses/LICENSE +178 -0
  121. pycontrails-0.58.0.dist-info/licenses/NOTICE +43 -0
  122. 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)