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