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