pycontrails 0.53.0__cp313-cp313-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.

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 +2312 -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.cp313-win_amd64.pyd +0 -0
  18. pycontrails/core/vector.py +2191 -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 +743 -0
  24. pycontrails/datalib/ecmwf/__init__.py +53 -0
  25. pycontrails/datalib/ecmwf/arco_era5.py +527 -0
  26. pycontrails/datalib/ecmwf/common.py +109 -0
  27. pycontrails/datalib/ecmwf/era5.py +538 -0
  28. pycontrails/datalib/ecmwf/era5_model_level.py +482 -0
  29. pycontrails/datalib/ecmwf/hres.py +782 -0
  30. pycontrails/datalib/ecmwf/hres_model_level.py +495 -0
  31. pycontrails/datalib/ecmwf/ifs.py +284 -0
  32. pycontrails/datalib/ecmwf/model_levels.py +79 -0
  33. pycontrails/datalib/ecmwf/static/model_level_dataframe_v20240418.csv +139 -0
  34. pycontrails/datalib/ecmwf/variables.py +256 -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 +568 -0
  40. pycontrails/datalib/sentinel.py +512 -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 +426 -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 +983 -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 +2617 -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 +486 -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.53.0.dist-info/LICENSE +178 -0
  105. pycontrails-0.53.0.dist-info/METADATA +181 -0
  106. pycontrails-0.53.0.dist-info/NOTICE +43 -0
  107. pycontrails-0.53.0.dist-info/RECORD +109 -0
  108. pycontrails-0.53.0.dist-info/WHEEL +5 -0
  109. pycontrails-0.53.0.dist-info/top_level.txt +3 -0
@@ -0,0 +1,41 @@
1
+ """
2
+ Interface to ``pycontrails-bada`` extension.
3
+
4
+ Requires data files obtained with a
5
+ `BADA License <https://www.eurocontrol.int/model/bada>`_ from Eurocontrol.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ try:
11
+ from pycontrails_bada import bada3, bada4, bada_model
12
+ from pycontrails_bada.bada3 import BADA3
13
+ from pycontrails_bada.bada4 import BADA4
14
+ from pycontrails_bada.bada_interface import BADA
15
+ from pycontrails_bada.bada_model import (
16
+ BADAFlight,
17
+ BADAFlightParams,
18
+ BADAGrid,
19
+ BADAGridParams,
20
+ BADAParams,
21
+ )
22
+
23
+ except ImportError as e:
24
+ raise ImportError(
25
+ "Failed to import the 'pycontrails-bada' package. Install with 'pip install"
26
+ ' "pycontrails-bada @ git+ssh://git@github.com/contrailcirrus/pycontrails-bada.git"\'.'
27
+ ) from e
28
+ else:
29
+ __all__ = [
30
+ "BADA",
31
+ "BADA3",
32
+ "BADA4",
33
+ "BADAFlight",
34
+ "BADAFlightParams",
35
+ "BADAGrid",
36
+ "BADAGridParams",
37
+ "BADAParams",
38
+ "bada3",
39
+ "bada4",
40
+ "bada_model",
41
+ ]
@@ -0,0 +1,14 @@
1
+ """Interface to `pycontrails-cirium` extension."""
2
+
3
+ from __future__ import annotations
4
+
5
+ try:
6
+ from pycontrails_cirium import Cirium
7
+
8
+ except ImportError as e:
9
+ raise ImportError(
10
+ "Failed to import the 'pycontrails-cirium' package. Install with 'pip install"
11
+ ' "pycontrails-cirium @ git+ssh://git@github.com/contrailcirrus/pycontrails-cirium.git"\'.'
12
+ ) from e
13
+ else:
14
+ __all__ = ["Cirium"]
@@ -0,0 +1,140 @@
1
+ """Simulate aircraft performance using empirical historical data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ from typing import Any, NoReturn, overload
7
+
8
+ import numpy as np
9
+ import numpy.typing as npt
10
+ import pandas as pd
11
+
12
+ from pycontrails.core.aircraft_performance import (
13
+ AircraftPerformanceGrid,
14
+ AircraftPerformanceGridParams,
15
+ )
16
+ from pycontrails.core.met import MetDataset
17
+ from pycontrails.core.vector import GeoVectorDataset
18
+
19
+
20
+ @dataclasses.dataclass
21
+ class EmpiricalGridParams(AircraftPerformanceGridParams):
22
+ """Parameters for :class:`EmpiricalGrid`."""
23
+
24
+ #: Random state to use for sampling
25
+ random_state: int | np.random.Generator | None = None
26
+
27
+ #: Empirical data to use for sampling. Must include columns:
28
+ #: - altitude_ft
29
+ #: - true_airspeed
30
+ #: - aircraft_mass
31
+ #: - fuel_flow
32
+ #: - engine_efficiency
33
+ #: - aircraft_type
34
+ #: - wingspan
35
+ #: If None, an error will be raised at runtime.
36
+ data: pd.DataFrame | None = None
37
+
38
+
39
+ class EmpiricalGrid(AircraftPerformanceGrid):
40
+ """Simulate aircraft performance using empirical historical data.
41
+
42
+ For each altitude, the model samples from the empirical data to obtain
43
+ hypothetical aircraft performance. The data is sampled with replacement,
44
+ so the same data may be used multiple times.
45
+
46
+ .. warning:: This model is experimental and will change in the future.
47
+
48
+ .. versionadded:: 0.47.0
49
+ """
50
+
51
+ name = "empirical_grid"
52
+ long_name = "Empirical Grid Aircraft Performance Model"
53
+
54
+ source: GeoVectorDataset
55
+ default_params = EmpiricalGridParams
56
+
57
+ variables = (
58
+ "true_airspeed",
59
+ "aircraft_mass",
60
+ "fuel_flow",
61
+ "engine_efficiency",
62
+ "aircraft_type",
63
+ "wingspan",
64
+ )
65
+
66
+ @overload
67
+ def eval(self, source: GeoVectorDataset, **params: Any) -> GeoVectorDataset: ...
68
+
69
+ @overload
70
+ def eval(self, source: MetDataset | None = ..., **params: Any) -> NoReturn: ...
71
+
72
+ def eval(
73
+ self, source: GeoVectorDataset | MetDataset | None = None, **params: Any
74
+ ) -> GeoVectorDataset:
75
+ """Query the provided historical data and sample from it.
76
+
77
+ Parameters
78
+ ----------
79
+ source : GeoVectorDataset, optional
80
+ The source vector dataset to evaluate the model on. Presently, only
81
+ altitude is used to query the ``data`` parameter.
82
+ **params
83
+ Parameters to update the model with.
84
+
85
+ Returns
86
+ -------
87
+ GeoVectorDataset
88
+ The evaluated vector dataset.
89
+ """
90
+
91
+ self.update_params(**params)
92
+ self.set_source(source)
93
+ self.require_source_type(GeoVectorDataset)
94
+
95
+ altitude_ft = self.source.altitude_ft.copy()
96
+ altitude_ft.round(-3, out=altitude_ft) # round to flight levels
97
+
98
+ # Fill the source with sampled data at each flight level
99
+ self._sample(altitude_ft)
100
+
101
+ return self.source
102
+
103
+ def _query_data(self) -> pd.DataFrame:
104
+ """Query ``self.params["data"]`` for the source aircraft type."""
105
+
106
+ # Take only the columns that are not already in the source
107
+ columns = [v for v in self.variables if v not in self.source]
108
+ data = self.params["data"]
109
+ if data is None:
110
+ raise ValueError("No data provided")
111
+
112
+ aircraft_type = self.source.attrs.get("aircraft_type", self.params["aircraft_type"])
113
+ data = data.query(f"aircraft_type == '{aircraft_type}'")
114
+ assert not data.empty, f"No data for aircraft type: {aircraft_type}"
115
+
116
+ # Round to flight levels
117
+ data.loc[:, "altitude_ft"] = data["altitude_ft"].round(-3)
118
+
119
+ return data[["altitude_ft", *columns]].drop(columns=["aircraft_type"])
120
+
121
+ def _sample(self, altitude_ft: npt.NDArray[np.float64]) -> None:
122
+ """Sample the data and update the source."""
123
+
124
+ df = self._query_data()
125
+ grouped = df.groupby("altitude_ft")
126
+ rng = self.params["random_state"]
127
+
128
+ source = self.source
129
+ for k in df:
130
+ source[k] = np.full_like(altitude_ft, np.nan)
131
+
132
+ for altitude, group in grouped:
133
+ filt = altitude_ft == altitude
134
+ n = filt.sum()
135
+ if n == 0:
136
+ continue
137
+
138
+ sample = group.sample(n=n, replace=True, random_state=rng)
139
+ for k, v in sample.items():
140
+ source[k][filt] = v
@@ -0,0 +1,426 @@
1
+ """Tools for creating synthetic data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import pathlib
7
+ import warnings
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+ from pyproj.geod import Geod
13
+
14
+ from pycontrails.core.flight import Flight
15
+ from pycontrails.core.met import MetDataArray
16
+ from pycontrails.physics import constants, geo, units
17
+ from pycontrails.utils.types import ArrayOrFloat
18
+
19
+ try:
20
+ from pycontrails.ext.bada import bada_model
21
+ except ImportError as e:
22
+ raise ImportError(
23
+ 'SyntheticFlight requires BADA extension. Install with `pip install "pycontrails-bada @'
24
+ ' git+ssh://git@github.com/contrailcirrus/pycontrails-bada.git"`'
25
+ ) from e
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ SAMPLE_AIRCRAFT_TYPES = [
30
+ "A20N",
31
+ "A319",
32
+ "A320",
33
+ "A321",
34
+ "A332",
35
+ "A333",
36
+ "A359",
37
+ "A388",
38
+ "B38M",
39
+ "B737",
40
+ "B738",
41
+ "B739",
42
+ "B744",
43
+ "B752",
44
+ "B763",
45
+ "B772",
46
+ "B77W",
47
+ "B788",
48
+ "B789",
49
+ "CRJ2",
50
+ "CRJ7",
51
+ "CRJ9",
52
+ "E145",
53
+ "E190",
54
+ "E195",
55
+ "E75L",
56
+ "E75S",
57
+ ]
58
+
59
+
60
+ class SyntheticFlight:
61
+ """Create a synthetic flight."""
62
+
63
+ # Random number generator
64
+ rng = np.random.default_rng(None)
65
+
66
+ # Maximum queue size. When generating many thousands of flights, performance is slightly
67
+ # improved if this parameter is increased.
68
+ max_queue_size = 100
69
+
70
+ # Minimum number of waypoints in flight
71
+ min_n_waypoints = 10
72
+
73
+ # Type hint a few instance variables
74
+ bada: bada_model.BADA | None
75
+ aircraft_type: str
76
+
77
+ longitude_min: float
78
+ longitude_max: float
79
+ latitude_min: float
80
+ latitude_max: float
81
+ level_min: float
82
+ level_max: float
83
+ time_min: np.datetime64
84
+ time_max: np.datetime64
85
+
86
+ def __init__(
87
+ self,
88
+ bounds: dict[str, np.ndarray],
89
+ aircraft_type: str | None = None,
90
+ bada3_path: str | pathlib.Path | None = None,
91
+ bada4_path: str | pathlib.Path | None = None,
92
+ speed_m_per_s: float | None = None,
93
+ seed: int | None = None,
94
+ u_wind: MetDataArray | None = None,
95
+ v_wind: MetDataArray | None = None,
96
+ ) -> None:
97
+ """Create a synthetic flight generator.
98
+
99
+ Parameters
100
+ ----------
101
+ bounds : dict[str, np.ndarray]
102
+ A dictionary with keys "longitude", "latitude", "level", and "time". All synthetic
103
+ flights will have coordinates bounded by the extreme values in this dictionary.
104
+ aircraft_type : str
105
+ A flight type, assumed to exist in the BADA3 or BADA4 dataset.
106
+ If None provided, a random aircraft type will be chosen from
107
+ ``SAMPLE_AIRCRAFT`` on every call.
108
+ bada3_path : str | pathlib.Path, optional
109
+ A path to a local BADA3 data source.
110
+ Defaults to None.
111
+ bada4_path : str | pathlib.Path, optional
112
+ A path to a local BADA4 data source.
113
+ Defaults to None.
114
+ speed_m_per_s : float, optional
115
+ Directly define cruising air speed. Only used if `bada3_path` and `bada4_path` are
116
+ not specified. By default None.
117
+ seed : int, optional
118
+ Reseed the random generator. By default None.
119
+ u_wind, v_wind : MetDataArray, optional
120
+ Eastward and northward wind data. If provided, flight true airspeed is computed
121
+ with respect to wind.
122
+ max_queue_size : int, optional
123
+ Maximum queue size. When generating many thousands of flights, performance is slightly
124
+ improved if this parameter is increased.
125
+ min_n_waypoints : int, optional
126
+ Minimum number of waypoints in flight
127
+ """
128
+ if seed is not None:
129
+ self.rng = np.random.default_rng(seed)
130
+
131
+ self.bounds = bounds
132
+ self.constant_aircraft_type = aircraft_type
133
+ self.bada3_path = bada3_path
134
+ self.bada4_path = bada4_path
135
+ self.speed_m_per_s = speed_m_per_s
136
+ self.geod = Geod(a=constants.radius_earth)
137
+
138
+ def extremes(arr: np.ndarray) -> tuple[Any, Any]:
139
+ arr = np.asarray(arr)
140
+ return arr.min(), arr.max()
141
+
142
+ self.longitude_min, self.longitude_max = extremes(bounds["longitude"])
143
+ self.latitude_min, self.latitude_max = extremes(bounds["latitude"])
144
+ self.level_min, self.level_max = extremes(bounds["level"])
145
+ self.time_min, self.time_max = extremes(bounds["time"])
146
+
147
+ self.u_wind = u_wind
148
+ self.v_wind = v_wind
149
+
150
+ self._queue: list[Flight] = []
151
+ self._depth = 0
152
+
153
+ def __repr__(self) -> str:
154
+ """Get string representation."""
155
+ msg = "Synthetic flight generator with parameters:"
156
+ for key in ["longitude", "latitude", "level", "time"]:
157
+ msg += "\n"
158
+ for bound in ["min", "max"]:
159
+ attr = f"{key}_{bound}"
160
+ val = getattr(self, attr)
161
+ msg += f"{attr}: {val} "
162
+ msg += f"\nmax_queue_size: {self.max_queue_size} min_n_waypoints: {self.min_n_waypoints}"
163
+ return msg
164
+
165
+ def __call__(self, timestep: np.timedelta64 | None = None) -> Flight:
166
+ """Create random flight within `bounds` at a constant altitude.
167
+
168
+ BADA4 data is used to determine flight speed at a randomly chosen altitude within
169
+ the level constraints defined by `bounds`. The flight trajectory follows a great circle
170
+ from a uniformly random chosen source point to a uniformly randomly chosen destination.
171
+
172
+ For generating a large number of random flights, it's best to call this within a
173
+ generator expression, ie,
174
+ ``fls = (syn() for _ in range(100_000))``
175
+
176
+ Parameters
177
+ ----------
178
+ timestep : np.timedelta64, optional
179
+ Time interval between waypoints. By default, 1 minute
180
+
181
+ Returns
182
+ -------
183
+ Flight
184
+ Random `Flight` instance constrained by bounds.
185
+ """
186
+ if timestep is None:
187
+ timestep = np.timedelta64(1, "m")
188
+ # Building flights with `u_wind` and `v_wind` involved in the true airspeed calculation is
189
+ # slow. BUT, we can do it in a vectorized way. So we maintain a short queue that gets
190
+ # repeatedly replenished.
191
+ if self.u_wind is not None and self.v_wind is not None:
192
+ while not self._queue:
193
+ # Need to do some significant refactor to use generators seemlessly
194
+ # The parameter timestep can change between calls
195
+ new_batch = self._generate_with_wind(self.max_queue_size, timestep)
196
+ self._queue.extend(new_batch)
197
+ fl = self._queue.pop()
198
+ while np.any(np.diff(fl["time"]) != timestep):
199
+ logger.debug("Found flight in queue with bad timestep.")
200
+ fl = self._queue.pop()
201
+ return fl
202
+
203
+ self._depth = 0
204
+ self._define_aircraft()
205
+ return self._generate_single_flight_no_wind(timestep)
206
+
207
+ def _id(self) -> int:
208
+ """Get random flight ID."""
209
+ return self.rng.integers(100_000, 999_999)
210
+
211
+ def _define_aircraft(self) -> None:
212
+ """Define or update instance variables pertaining to flight aircrafts.
213
+
214
+ Specify
215
+ - aircraft_type
216
+ - bada_enabled
217
+ - fl_all
218
+ - cruise_tas
219
+
220
+ Raises
221
+ ------
222
+ FileNotFoundError
223
+ BADA files not found despite non-default BADA3 or BADA4 paths
224
+ ValueError
225
+ BADA files not found under default paths AND speed_m_per_s not defined
226
+ """
227
+ if self.constant_aircraft_type is None:
228
+ self.aircraft_type = self.rng.choice(SAMPLE_AIRCRAFT_TYPES)
229
+ else:
230
+ self.aircraft_type = self.constant_aircraft_type
231
+
232
+ try:
233
+ self.bada = bada_model.get_bada(
234
+ self.aircraft_type,
235
+ bada3_path=self.bada3_path,
236
+ bada4_path=self.bada4_path,
237
+ bada_priority=4,
238
+ )
239
+
240
+ except FileNotFoundError as err:
241
+ logger.warning("BADA files not found")
242
+
243
+ # If non-default bada paths were passed into __init__, we should raise an error
244
+ if self.bada3_path is not None or self.bada4_path is not None:
245
+ raise FileNotFoundError from err
246
+
247
+ # If bada paths were not passed into __init__, we expect to know speed_m_per_s
248
+ if self.speed_m_per_s is None:
249
+ msg = "Either specify 'bada3_path', 'bada4_path', or 'speed_m_per_s'."
250
+ raise ValueError(msg) from err
251
+ self.bada = None
252
+
253
+ def _calc_speed_m_per_s(self, level: ArrayOrFloat) -> ArrayOrFloat:
254
+ if self.bada is not None:
255
+ alt_ft = units.pl_to_ft(level)
256
+ return self.bada.nominal_cruising_speed(self.aircraft_type, alt_ft)
257
+
258
+ if self.speed_m_per_s is None:
259
+ raise ValueError("Either specify `bada3_path`, `bada4_path` or `speed_m_per_s`.")
260
+
261
+ if isinstance(level, np.ndarray):
262
+ return np.full_like(level, self.speed_m_per_s)
263
+ return self.speed_m_per_s
264
+
265
+ def _generate_single_flight_no_wind(self, timestep: np.timedelta64) -> Flight:
266
+ src_lon = self.rng.uniform(self.longitude_min, self.longitude_max)
267
+ src_lat = self.rng.uniform(self.latitude_min, self.latitude_max)
268
+ dest_lon = self.rng.uniform(self.longitude_min, self.longitude_max)
269
+ dest_lat = self.rng.uniform(self.latitude_min, self.latitude_max)
270
+ src = src_lon, src_lat
271
+ dest = dest_lon, dest_lat
272
+ az, _, dist = self.geod.inv(*src, *dest)
273
+
274
+ level = self.rng.uniform(self.level_min, self.level_max)
275
+ speed_m_per_s = self._calc_speed_m_per_s(level)
276
+
277
+ m_per_timestep = speed_m_per_s * (timestep / np.timedelta64(1, "s"))
278
+ npts = int(dist // m_per_timestep) # need to cast: dist is np.float64
279
+
280
+ # Dealing with situations of npts too small or too big
281
+ if npts > (self.time_max - self.time_min) / timestep:
282
+ msg = (
283
+ "Not enough available time in `bounds` to create good flight between "
284
+ f"source {src} and destination {dest}. Try enlarging the time dimension, "
285
+ "or reducing the longitude and latitude dimensions."
286
+ )
287
+
288
+ new_npts = int((self.time_max - self.time_min) / timestep)
289
+ logger.debug("Override npts from %s to %s", npts, new_npts)
290
+ npts = new_npts
291
+
292
+ if npts < self.min_n_waypoints:
293
+ raise ValueError(msg)
294
+ warnings.warn(msg)
295
+
296
+ if npts < self.min_n_waypoints:
297
+ # Try 10 times, then give up.
298
+ self._depth += 1
299
+ if self._depth > 10:
300
+ raise ValueError("Cannot create flight. Increase dimensions in `bounds`.")
301
+ return self._generate_single_flight_no_wind(timestep) # recursive
302
+
303
+ result = self.geod.fwd_intermediate(
304
+ *src, az, npts, m_per_timestep, return_back_azimuth=False # type: ignore
305
+ )
306
+ longitude = np.asarray(result.lons)
307
+ latitude = np.asarray(result.lats)
308
+ if geo.haversine(longitude[-1], latitude[-1], *dest) > m_per_timestep:
309
+ logger.debug(
310
+ "Synthetic flight did not arrive at destination. "
311
+ "This is likely due to overriding npts."
312
+ )
313
+
314
+ rand_range = int((self.time_max - self.time_min - npts * timestep) / timestep) + 1
315
+ time0 = self.time_min + self.rng.integers(rand_range) * timestep
316
+ time: np.ndarray = np.arange(time0, time0 + npts * timestep, timestep)
317
+
318
+ df = {"longitude": longitude, "latitude": latitude, "level": level, "time": time}
319
+ return Flight(pd.DataFrame(df), flight_id=self._id(), aircraft_type=self.aircraft_type)
320
+
321
+ def _generate_with_wind(self, n: int, timestep: np.timedelta64) -> list[Flight]:
322
+ logger.debug("Generate %s new flights with wind", n)
323
+
324
+ self._define_aircraft()
325
+
326
+ # Step 1: Randomly select longitude, latitude, level
327
+ src_lon = self.rng.uniform(self.longitude_min, self.longitude_max, n)
328
+ src_lat = self.rng.uniform(self.latitude_min, self.latitude_max, n)
329
+ dest_lon = self.rng.uniform(self.longitude_min, self.longitude_max, n)
330
+ dest_lat = self.rng.uniform(self.latitude_min, self.latitude_max, n)
331
+ level = self.rng.uniform(self.level_min, self.level_max, n)
332
+ src = src_lon, src_lat
333
+ dest = dest_lon, dest_lat
334
+ az: np.ndarray
335
+ dist: np.ndarray
336
+ az, _, dist = self.geod.inv(*src, *dest)
337
+
338
+ # Step 2: Compute approximate flight times according to nominal (no wind) TAS
339
+ nom_speed_m_per_s = self._calc_speed_m_per_s(level)
340
+ # NOTE: Because of casting, the multiplication below is NOT associative!
341
+ # In other words, the parentheses are needed on the right-most term
342
+ nom_m_per_timestep = nom_speed_m_per_s * (timestep / np.timedelta64(1, "s"))
343
+ approx_flight_duration_s = dist / nom_speed_m_per_s * np.timedelta64(1, "s")
344
+
345
+ # Step 3: Randomly select start time -- use 0.9 for a small buffer
346
+ rand_float = 0.9 * self.rng.random(n)
347
+ time_windows = self.time_max - self.time_min - approx_flight_duration_s
348
+
349
+ # Here `time_windows` can have negative timedeltas, which is not good.
350
+ n_negative = np.sum(time_windows < np.timedelta64(0, "s"))
351
+ logger.debug(
352
+ "Found %s / %s src-dist pairs not fitting into the time dimension", n_negative, n
353
+ )
354
+ if n_negative >= 0.1 * n:
355
+ warnings.warn(
356
+ "Not enough available time in `bounds` to create reasonable random flights. Try "
357
+ "enlarging the time dimension, or reducing the longitude and latitude dimensions."
358
+ )
359
+ # Manually clip at 0
360
+ time_windows = np.maximum(time_windows, np.timedelta64(0, "s"))
361
+
362
+ rand_start = rand_float * time_windows
363
+ start_time = self.time_min + rand_start
364
+
365
+ # Step 4: Iterate and collect
366
+ lons = []
367
+ lats = []
368
+ times = []
369
+
370
+ def take_step(
371
+ cur_lon: np.ndarray,
372
+ cur_lat: np.ndarray,
373
+ cur_time: np.typing.NDArray[np.datetime64],
374
+ cur_az: np.ndarray,
375
+ cur_active: np.ndarray,
376
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
377
+ sin, cos = geo.azimuth_to_direction(cur_az, cur_lat)
378
+
379
+ if self.u_wind is None or self.v_wind is None:
380
+ raise TypeError("Define attributes `u_wind` and `v_wind`.")
381
+ u_wind = self.u_wind.interpolate(cur_lon, cur_lat, level, cur_time)
382
+ v_wind = self.v_wind.interpolate(cur_lon, cur_lat, level, cur_time)
383
+
384
+ nom_x = nom_m_per_timestep * cos
385
+ nom_y = nom_m_per_timestep * sin
386
+ # NOTE: Because of casting, the multiplication below is NOT associative.
387
+ # In other words, the parentheses are needed on the right-most term
388
+ tas_x = nom_x + u_wind * (timestep / np.timedelta64(1, "s"))
389
+ tas_y = nom_y + v_wind * (timestep / np.timedelta64(1, "s"))
390
+ tas = (tas_x**2 + tas_y**2) ** 0.5
391
+
392
+ next_lon, next_lat, _ = self.geod.fwd(cur_lon, cur_lat, cur_az, tas)
393
+ next_az: np.ndarray
394
+ next_dist: np.ndarray
395
+ next_az, _, next_dist = self.geod.inv(next_lon, next_lat, *dest)
396
+ next_time = cur_time + timestep
397
+ next_active = (
398
+ cur_active & (next_dist > nom_m_per_timestep) & (next_time < self.time_max)
399
+ )
400
+
401
+ return next_lon, next_lat, next_time, next_az, next_active
402
+
403
+ lon = src_lon
404
+ lat = src_lat
405
+ time = start_time
406
+ active = np.ones(lon.shape, dtype=bool)
407
+
408
+ while active.sum():
409
+ lons.append(np.where(active, lon, np.nan))
410
+ lats.append(np.where(active, lat, np.nan))
411
+ times.append(time)
412
+ lon, lat, time, az, active = take_step(lon, lat, time, az, active)
413
+
414
+ # Step 5: Assemble and return
415
+ lons_arr = np.asarray(lons).T
416
+ lats_arr = np.asarray(lats).T
417
+ times_arr = np.asarray(times).T
418
+ data = [
419
+ {"longitude": lon, "latitude": lat, "level": level, "time": time}
420
+ for lon, lat, level, time in zip(lons_arr, lats_arr, level, times_arr, strict=True)
421
+ ]
422
+ dfs = [pd.DataFrame(d).dropna() for d in data]
423
+ dfs = [df for df in dfs if len(df) >= self.min_n_waypoints]
424
+ dfs = [df.assign(altitude=units.pl_to_m(df["level"])).drop(columns="level") for df in dfs]
425
+
426
+ return [Flight(df, flight_id=self._id(), aircraft_type=self.aircraft_type) for df in dfs]
@@ -0,0 +1 @@
1
+ """Contrail modeling support."""