pycontrails 0.54.7__cp310-cp310-macosx_11_0_arm64.whl → 0.54.9__cp310-cp310-macosx_11_0_arm64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of pycontrails might be problematic. Click here for more details.
- pycontrails/_version.py +9 -4
- pycontrails/core/aircraft_performance.py +48 -35
- pycontrails/core/cache.py +4 -0
- pycontrails/core/flightplan.py +10 -2
- pycontrails/core/met.py +12 -7
- pycontrails/core/models.py +54 -9
- pycontrails/core/rgi_cython.cpython-310-darwin.so +0 -0
- pycontrails/core/vector.py +11 -3
- pycontrails/datalib/goes.py +146 -2
- pycontrails/datalib/spire/spire.py +6 -8
- pycontrails/ext/synthetic_flight.py +1 -1
- pycontrails/models/cocip/cocip.py +14 -5
- pycontrails/models/cocip/contrail_properties.py +4 -6
- pycontrails/models/cocip/output_formats.py +12 -4
- pycontrails/models/cocip/radiative_forcing.py +2 -8
- pycontrails/models/cocipgrid/cocip_grid.py +11 -11
- pycontrails/models/humidity_scaling/humidity_scaling.py +49 -4
- pycontrails/models/ps_model/ps_aircraft_params.py +1 -1
- pycontrails/models/ps_model/ps_grid.py +22 -22
- pycontrails/models/ps_model/ps_model.py +7 -13
- pycontrails/models/ps_model/static/{ps-aircraft-params-20240524.csv → ps-aircraft-params-20250328.csv} +58 -57
- pycontrails/models/ps_model/static/{ps-synonym-list-20240524.csv → ps-synonym-list-20250328.csv} +1 -0
- pycontrails/models/tau_cirrus.py +1 -0
- pycontrails/physics/jet.py +5 -4
- pycontrails/physics/static/{iata-cargo-load-factors-20241115.csv → iata-cargo-load-factors-20250221.csv} +3 -0
- pycontrails/physics/static/{iata-passenger-load-factors-20241115.csv → iata-passenger-load-factors-20250221.csv} +3 -0
- {pycontrails-0.54.7.dist-info → pycontrails-0.54.9.dist-info}/METADATA +3 -2
- {pycontrails-0.54.7.dist-info → pycontrails-0.54.9.dist-info}/RECORD +32 -32
- {pycontrails-0.54.7.dist-info → pycontrails-0.54.9.dist-info}/WHEEL +2 -1
- {pycontrails-0.54.7.dist-info → pycontrails-0.54.9.dist-info/licenses}/LICENSE +0 -0
- {pycontrails-0.54.7.dist-info → pycontrails-0.54.9.dist-info/licenses}/NOTICE +0 -0
- {pycontrails-0.54.7.dist-info → pycontrails-0.54.9.dist-info}/top_level.txt +0 -0
pycontrails/_version.py
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
# file generated by
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
2
|
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
|
|
5
|
+
|
|
3
6
|
TYPE_CHECKING = False
|
|
4
7
|
if TYPE_CHECKING:
|
|
5
|
-
from typing import Tuple
|
|
8
|
+
from typing import Tuple
|
|
9
|
+
from typing import Union
|
|
10
|
+
|
|
6
11
|
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
7
12
|
else:
|
|
8
13
|
VERSION_TUPLE = object
|
|
@@ -12,5 +17,5 @@ __version__: str
|
|
|
12
17
|
__version_tuple__: VERSION_TUPLE
|
|
13
18
|
version_tuple: VERSION_TUPLE
|
|
14
19
|
|
|
15
|
-
__version__ = version = '0.54.
|
|
16
|
-
__version_tuple__ = version_tuple = (0, 54,
|
|
20
|
+
__version__ = version = '0.54.9'
|
|
21
|
+
__version_tuple__ = version_tuple = (0, 54, 9)
|
|
@@ -4,15 +4,9 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import abc
|
|
6
6
|
import dataclasses
|
|
7
|
-
import sys
|
|
8
7
|
import warnings
|
|
9
8
|
from typing import Any, Generic, NoReturn, overload
|
|
10
9
|
|
|
11
|
-
if sys.version_info >= (3, 12):
|
|
12
|
-
from typing import override
|
|
13
|
-
else:
|
|
14
|
-
from typing_extensions import override
|
|
15
|
-
|
|
16
10
|
import numpy as np
|
|
17
11
|
import numpy.typing as npt
|
|
18
12
|
|
|
@@ -20,6 +14,7 @@ from pycontrails.core import flight, fuel
|
|
|
20
14
|
from pycontrails.core.fleet import Fleet
|
|
21
15
|
from pycontrails.core.flight import Flight
|
|
22
16
|
from pycontrails.core.met import MetDataset
|
|
17
|
+
from pycontrails.core.met_var import AirTemperature, EastwardWind, MetVariable, NorthwardWind
|
|
23
18
|
from pycontrails.core.models import Model, ModelParams, interpolate_met
|
|
24
19
|
from pycontrails.core.vector import GeoVectorDataset
|
|
25
20
|
from pycontrails.physics import jet
|
|
@@ -97,6 +92,9 @@ class AircraftPerformance(Model):
|
|
|
97
92
|
"""
|
|
98
93
|
|
|
99
94
|
source: Flight
|
|
95
|
+
met_variables: tuple[MetVariable, ...] = ()
|
|
96
|
+
optional_met_variables: tuple[MetVariable, ...] = (AirTemperature, EastwardWind, NorthwardWind)
|
|
97
|
+
default_params = AircraftPerformanceParams
|
|
100
98
|
|
|
101
99
|
@overload
|
|
102
100
|
def eval(self, source: Fleet, **params: Any) -> Fleet: ...
|
|
@@ -129,7 +127,8 @@ class AircraftPerformance(Model):
|
|
|
129
127
|
self.set_source_met()
|
|
130
128
|
self._cleanup_indices()
|
|
131
129
|
|
|
132
|
-
# Calculate true airspeed if not included on source
|
|
130
|
+
# Calculate temperature and true airspeed if not included on source
|
|
131
|
+
self.ensure_air_temperature_on_source()
|
|
133
132
|
self.ensure_true_airspeed_on_source()
|
|
134
133
|
|
|
135
134
|
if isinstance(self.source, Fleet):
|
|
@@ -162,23 +161,6 @@ class AircraftPerformance(Model):
|
|
|
162
161
|
- ``total_fuel_burn``: total fuel burn, [:math:`kg`]
|
|
163
162
|
"""
|
|
164
163
|
|
|
165
|
-
@override
|
|
166
|
-
def set_source_met(self, *args: Any, **kwargs: Any) -> None:
|
|
167
|
-
fill_with_isa = self.params["fill_low_altitude_with_isa_temperature"]
|
|
168
|
-
if fill_with_isa and (self.met is None or "air_temperature" not in self.met):
|
|
169
|
-
if "air_temperature" in self.source:
|
|
170
|
-
_fill_low_altitude_with_isa_temperature(self.source, 0.0)
|
|
171
|
-
else:
|
|
172
|
-
self.source["air_temperature"] = self.source.T_isa()
|
|
173
|
-
fill_with_isa = False # we've just filled it
|
|
174
|
-
|
|
175
|
-
super().set_source_met(*args, **kwargs)
|
|
176
|
-
if not fill_with_isa:
|
|
177
|
-
return
|
|
178
|
-
|
|
179
|
-
met_level_0 = self.met.data["level"][-1].item() # type: ignore[union-attr]
|
|
180
|
-
_fill_low_altitude_with_isa_temperature(self.source, met_level_0)
|
|
181
|
-
|
|
182
164
|
def simulate_fuel_and_performance(
|
|
183
165
|
self,
|
|
184
166
|
*,
|
|
@@ -491,25 +473,57 @@ class AircraftPerformance(Model):
|
|
|
491
473
|
Derived performance metrics at each waypoint.
|
|
492
474
|
"""
|
|
493
475
|
|
|
494
|
-
def
|
|
476
|
+
def ensure_air_temperature_on_source(self) -> None:
|
|
477
|
+
"""Add ``air_temperature`` field to :attr:`source` data if not already present.
|
|
478
|
+
|
|
479
|
+
This function operates in-place. If ``air_temperature`` is not already present
|
|
480
|
+
on :attr:`source`, it is calculated by interpolation from met data.
|
|
481
|
+
"""
|
|
482
|
+
fill_with_isa = self.params["fill_low_altitude_with_isa_temperature"]
|
|
483
|
+
|
|
484
|
+
if "air_temperature" in self.source:
|
|
485
|
+
if not fill_with_isa:
|
|
486
|
+
return
|
|
487
|
+
_fill_low_altitude_with_isa_temperature(self.source, 0.0)
|
|
488
|
+
return
|
|
489
|
+
|
|
490
|
+
temp_available = self.met is not None and "air_temperature" in self.met
|
|
491
|
+
|
|
492
|
+
if not temp_available:
|
|
493
|
+
if fill_with_isa:
|
|
494
|
+
self.source["air_temperature"] = self.source.T_isa()
|
|
495
|
+
return
|
|
496
|
+
msg = (
|
|
497
|
+
"Cannot compute air temperature without providing met data that includes an "
|
|
498
|
+
"'air_temperature' variable. Either include met data with 'air_temperature' "
|
|
499
|
+
"in the model constructor, define 'air_temperature' data on the flight, or set "
|
|
500
|
+
"'fill_low_altitude_with_isa_temperature' to True."
|
|
501
|
+
)
|
|
502
|
+
raise ValueError(msg)
|
|
503
|
+
|
|
504
|
+
interpolate_met(self.met, self.source, "air_temperature", **self.interp_kwargs)
|
|
505
|
+
|
|
506
|
+
if not fill_with_isa:
|
|
507
|
+
return
|
|
508
|
+
|
|
509
|
+
met_level_0 = self.met.data["level"][-1].item() # type: ignore[union-attr]
|
|
510
|
+
_fill_low_altitude_with_isa_temperature(self.source, met_level_0)
|
|
511
|
+
|
|
512
|
+
def ensure_true_airspeed_on_source(self) -> None:
|
|
495
513
|
"""Add ``true_airspeed`` field to :attr:`source` data if not already present.
|
|
496
514
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
npt.NDArray[np.floating]
|
|
500
|
-
True airspeed, [:math:`m s^{-1}`]. If ``true_airspeed`` is already present
|
|
501
|
-
on :attr:`source`, this is returned directly. Otherwise, it is calculated
|
|
502
|
-
using :meth:`Flight.segment_true_airspeed`.
|
|
515
|
+
This function operates in-place. If ``true_airspeed`` is not already present
|
|
516
|
+
on :attr:`source`, it is calculated using :meth:`Flight.segment_true_airspeed`.
|
|
503
517
|
"""
|
|
504
518
|
tas = self.source.get("true_airspeed")
|
|
505
519
|
fill_with_groundspeed = self.params["fill_low_altitude_with_zero_wind"]
|
|
506
520
|
|
|
507
521
|
if tas is not None:
|
|
508
522
|
if not fill_with_groundspeed:
|
|
509
|
-
return
|
|
523
|
+
return
|
|
510
524
|
cond = np.isnan(tas)
|
|
511
525
|
tas[cond] = self.source.segment_groundspeed()[cond]
|
|
512
|
-
return
|
|
526
|
+
return
|
|
513
527
|
|
|
514
528
|
# Use current cocip convention: eastward_wind on met, u_wind on source
|
|
515
529
|
wind_available = ("u_wind" in self.source and "v_wind" in self.source) or (
|
|
@@ -520,7 +534,7 @@ class AircraftPerformance(Model):
|
|
|
520
534
|
if fill_with_groundspeed:
|
|
521
535
|
tas = self.source.segment_groundspeed()
|
|
522
536
|
self.source["true_airspeed"] = tas
|
|
523
|
-
return
|
|
537
|
+
return
|
|
524
538
|
msg = (
|
|
525
539
|
"Cannot compute 'true_airspeed' without 'eastward_wind' and 'northward_wind' "
|
|
526
540
|
"met data. Either include met data in the model constructor, define "
|
|
@@ -545,7 +559,6 @@ class AircraftPerformance(Model):
|
|
|
545
559
|
|
|
546
560
|
out = self.source.segment_true_airspeed(u, v)
|
|
547
561
|
self.source["true_airspeed"] = out
|
|
548
|
-
return out
|
|
549
562
|
|
|
550
563
|
|
|
551
564
|
@dataclasses.dataclass
|
pycontrails/core/cache.py
CHANGED
|
@@ -266,6 +266,8 @@ class DiskCacheStore(CacheStore):
|
|
|
266
266
|
>>> # put a file directly
|
|
267
267
|
>>> disk_cache.put("README.md", "test/file.md")
|
|
268
268
|
'test/file.md'
|
|
269
|
+
|
|
270
|
+
>>> disk_cache.clear() # cleanup
|
|
269
271
|
"""
|
|
270
272
|
|
|
271
273
|
if not pathlib.Path(data_path).is_file():
|
|
@@ -312,6 +314,8 @@ class DiskCacheStore(CacheStore):
|
|
|
312
314
|
>>> # returns a path
|
|
313
315
|
>>> disk_cache.get("test/file.md")
|
|
314
316
|
'cache/test/file.md'
|
|
317
|
+
|
|
318
|
+
>>> disk_cache.clear() # cleanup
|
|
315
319
|
"""
|
|
316
320
|
return self.path(cache_path)
|
|
317
321
|
|
pycontrails/core/flightplan.py
CHANGED
|
@@ -38,10 +38,13 @@ def to_atc_plan(plan: dict[str, Any]) -> str:
|
|
|
38
38
|
if "second_alt_icao" in plan:
|
|
39
39
|
ret += f" {plan['second_alt_icao']}"
|
|
40
40
|
ret += "\n"
|
|
41
|
-
ret += f"-{plan['other_info']}
|
|
41
|
+
ret += f"-{plan['other_info']}"
|
|
42
42
|
if "supplementary_info" in plan:
|
|
43
|
+
ret += "\n-"
|
|
43
44
|
ret += " ".join([f"{i[0]}/{i[1]}" for i in plan["supplementary_info"].items()])
|
|
44
45
|
|
|
46
|
+
ret += ")"
|
|
47
|
+
|
|
45
48
|
if ret[-1] == "\n":
|
|
46
49
|
ret = ret[:-1]
|
|
47
50
|
|
|
@@ -194,7 +197,12 @@ def parse_atc_plan(atc_plan: str) -> dict[str, str]:
|
|
|
194
197
|
|
|
195
198
|
# Other info
|
|
196
199
|
if len(basic) > 8:
|
|
197
|
-
|
|
200
|
+
info = basic[8]
|
|
201
|
+
idx = info.find("DOF")
|
|
202
|
+
if idx != -1:
|
|
203
|
+
flightplan["departure_date"] = info[idx + 4 : idx + 10]
|
|
204
|
+
|
|
205
|
+
flightplan["other_info"] = info.strip()
|
|
198
206
|
|
|
199
207
|
# Supl. Info
|
|
200
208
|
if len(basic) > 9:
|
pycontrails/core/met.py
CHANGED
|
@@ -2643,19 +2643,24 @@ def downselect(data: XArrayType, bbox: tuple[float, ...]) -> XArrayType:
|
|
|
2643
2643
|
"or length 6 [west, south, min-level, east, north, max-level]"
|
|
2644
2644
|
)
|
|
2645
2645
|
|
|
2646
|
+
if west <= east:
|
|
2647
|
+
# Return a view of the data
|
|
2648
|
+
# If data is lazy, this will not load the data
|
|
2649
|
+
return data.sel(
|
|
2650
|
+
longitude=slice(west, east),
|
|
2651
|
+
latitude=slice(south, north),
|
|
2652
|
+
level=slice(level_min, level_max),
|
|
2653
|
+
)
|
|
2654
|
+
|
|
2655
|
+
# In this case, the bbox spans the antimeridian
|
|
2656
|
+
# If data is lazy, this will load the data (data.where is not lazy AFAIK)
|
|
2646
2657
|
cond = (
|
|
2647
2658
|
(data["latitude"] >= south)
|
|
2648
2659
|
& (data["latitude"] <= north)
|
|
2649
2660
|
& (data["level"] >= level_min)
|
|
2650
2661
|
& (data["level"] <= level_max)
|
|
2662
|
+
& ((data["longitude"] >= west) | (data["longitude"] <= east))
|
|
2651
2663
|
)
|
|
2652
|
-
|
|
2653
|
-
# wrapping longitude
|
|
2654
|
-
if west <= east:
|
|
2655
|
-
cond = cond & (data["longitude"] >= west) & (data["longitude"] <= east)
|
|
2656
|
-
else:
|
|
2657
|
-
cond = cond & ((data["longitude"] >= west) | (data["longitude"] <= east))
|
|
2658
|
-
|
|
2659
2664
|
return data.where(cond, drop=True)
|
|
2660
2665
|
|
|
2661
2666
|
|
pycontrails/core/models.py
CHANGED
|
@@ -706,17 +706,20 @@ class Model(ABC):
|
|
|
706
706
|
# https://github.com/python/cpython/blob/618b7a8260bb40290d6551f24885931077309590/Lib/collections/__init__.py#L231
|
|
707
707
|
__marker = object()
|
|
708
708
|
|
|
709
|
-
def
|
|
710
|
-
|
|
709
|
+
def get_data_param(
|
|
710
|
+
self, other: SourceType, key: str, default: Any = __marker, *, set_attr: bool = True
|
|
711
|
+
) -> Any:
|
|
712
|
+
"""Get data from other source-compatible object with default set by model parameter key.
|
|
711
713
|
|
|
712
714
|
Retrieves data with the following hierarchy:
|
|
713
715
|
|
|
714
|
-
1. :attr:`
|
|
715
|
-
2. :attr:`
|
|
716
|
+
1. :attr:`other.data[key]`. Returns ``np.ndarray | xr.DataArray``.
|
|
717
|
+
2. :attr:`other.attrs[key]`
|
|
716
718
|
3. :attr:`params[key]`
|
|
717
719
|
4. ``default``
|
|
718
720
|
|
|
719
|
-
In case 3., the value of :attr:`params[key]` is attached to :attr:`
|
|
721
|
+
In case 3., the value of :attr:`params[key]` is attached to :attr:`other.attrs[key]`
|
|
722
|
+
unless ``set_attr`` is set to False.
|
|
720
723
|
|
|
721
724
|
Parameters
|
|
722
725
|
----------
|
|
@@ -731,31 +734,33 @@ class Model(ABC):
|
|
|
731
734
|
Returns
|
|
732
735
|
-------
|
|
733
736
|
Any
|
|
734
|
-
Value(s) found for key in
|
|
737
|
+
Value(s) found for key in ``other`` data, ``other`` attrs, or model params
|
|
735
738
|
|
|
736
739
|
Raises
|
|
737
740
|
------
|
|
738
741
|
KeyError
|
|
739
742
|
Raises KeyError if key is not found in any location and ``default`` is not provided.
|
|
740
743
|
|
|
744
|
+
|
|
741
745
|
See Also
|
|
742
746
|
--------
|
|
747
|
+
- get_source_param
|
|
743
748
|
- GeoVectorDataset.get_data_or_attr
|
|
744
749
|
"""
|
|
745
750
|
marker = self.__marker
|
|
746
751
|
|
|
747
|
-
out =
|
|
752
|
+
out = other.data.get(key, marker)
|
|
748
753
|
if out is not marker:
|
|
749
754
|
return out
|
|
750
755
|
|
|
751
|
-
out =
|
|
756
|
+
out = other.attrs.get(key, marker)
|
|
752
757
|
if out is not marker:
|
|
753
758
|
return out
|
|
754
759
|
|
|
755
760
|
out = self.params.get(key, marker)
|
|
756
761
|
if out is not marker:
|
|
757
762
|
if set_attr:
|
|
758
|
-
|
|
763
|
+
other.attrs[key] = out
|
|
759
764
|
|
|
760
765
|
return out
|
|
761
766
|
|
|
@@ -765,6 +770,46 @@ class Model(ABC):
|
|
|
765
770
|
msg = f"Key '{key}' not found in source data, attrs, or model params"
|
|
766
771
|
raise KeyError(msg)
|
|
767
772
|
|
|
773
|
+
def get_source_param(self, key: str, default: Any = __marker, *, set_attr: bool = True) -> Any:
|
|
774
|
+
"""Get source data with default set by parameter key.
|
|
775
|
+
|
|
776
|
+
Retrieves data with the following hierarchy:
|
|
777
|
+
|
|
778
|
+
1. :attr:`source.data[key]`. Returns ``np.ndarray | xr.DataArray``.
|
|
779
|
+
2. :attr:`source.attrs[key]`
|
|
780
|
+
3. :attr:`params[key]`
|
|
781
|
+
4. ``default``
|
|
782
|
+
|
|
783
|
+
In case 3., the value of :attr:`params[key]` is attached to :attr:`source.attrs[key]`
|
|
784
|
+
unless ``set_attr`` is set to False.
|
|
785
|
+
|
|
786
|
+
Parameters
|
|
787
|
+
----------
|
|
788
|
+
key : str
|
|
789
|
+
Key to retrieve
|
|
790
|
+
default : Any, optional
|
|
791
|
+
Default value if key is not found.
|
|
792
|
+
set_attr : bool, optional
|
|
793
|
+
If True (default), set :attr:`source.attrs[key]` to :attr:`params[key]` if found.
|
|
794
|
+
This allows for better post model evaluation tracking.
|
|
795
|
+
|
|
796
|
+
Returns
|
|
797
|
+
-------
|
|
798
|
+
Any
|
|
799
|
+
Value(s) found for key in source data, source attrs, or model params
|
|
800
|
+
|
|
801
|
+
Raises
|
|
802
|
+
------
|
|
803
|
+
KeyError
|
|
804
|
+
Raises KeyError if key is not found in any location and ``default`` is not provided.
|
|
805
|
+
|
|
806
|
+
See Also
|
|
807
|
+
--------
|
|
808
|
+
- get_data_param
|
|
809
|
+
- GeoVectorDataset.get_data_or_attr
|
|
810
|
+
"""
|
|
811
|
+
return self.get_data_param(self.source, key, default, set_attr=set_attr)
|
|
812
|
+
|
|
768
813
|
def _cleanup_indices(self) -> None:
|
|
769
814
|
"""Cleanup indices artifacts if ``params["interpolation_use_indices"]`` is True."""
|
|
770
815
|
if self.params["interpolation_use_indices"] and isinstance(self.source, GeoVectorDataset):
|
|
Binary file
|
pycontrails/core/vector.py
CHANGED
|
@@ -1067,9 +1067,17 @@ class VectorDataset:
|
|
|
1067
1067
|
if out is not marker:
|
|
1068
1068
|
return out
|
|
1069
1069
|
|
|
1070
|
-
|
|
1071
|
-
if
|
|
1072
|
-
|
|
1070
|
+
arr: np.ndarray = self.data.get(key, marker) # type: ignore[arg-type]
|
|
1071
|
+
if arr is not marker:
|
|
1072
|
+
try:
|
|
1073
|
+
vals = np.unique(arr)
|
|
1074
|
+
except TypeError:
|
|
1075
|
+
# A TypeError can occur if the arr has object dtype and contains None
|
|
1076
|
+
# Handle this case by returning None
|
|
1077
|
+
if arr.dtype == object and np.all(arr == None): # noqa: E711
|
|
1078
|
+
return None
|
|
1079
|
+
raise
|
|
1080
|
+
|
|
1073
1081
|
if len(vals) == 1:
|
|
1074
1082
|
return vals[0]
|
|
1075
1083
|
|
pycontrails/datalib/goes.py
CHANGED
|
@@ -16,6 +16,7 @@ from __future__ import annotations
|
|
|
16
16
|
|
|
17
17
|
import datetime
|
|
18
18
|
import enum
|
|
19
|
+
import os
|
|
19
20
|
import tempfile
|
|
20
21
|
from collections.abc import Iterable
|
|
21
22
|
|
|
@@ -566,9 +567,12 @@ class GOES:
|
|
|
566
567
|
|
|
567
568
|
def _load_via_tempfile(data: bytes) -> xr.Dataset:
|
|
568
569
|
"""Load xarray dataset via temporary file."""
|
|
569
|
-
with tempfile.NamedTemporaryFile(
|
|
570
|
+
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
|
570
571
|
tmp.write(data)
|
|
572
|
+
try:
|
|
571
573
|
return xr.load_dataset(tmp.name)
|
|
574
|
+
finally:
|
|
575
|
+
os.remove(tmp.name)
|
|
572
576
|
|
|
573
577
|
|
|
574
578
|
def _concat_c02(ds1: XArrayType, ds2: XArrayType) -> XArrayType:
|
|
@@ -660,6 +664,10 @@ def to_true_color(da: xr.DataArray, gamma: float = 2.2) -> npt.NDArray[np.float3
|
|
|
660
664
|
----------
|
|
661
665
|
- `Unidata's true color recipe <https://unidata.github.io/python-gallery/examples/mapping_GOES16_TrueColor.html>`_
|
|
662
666
|
"""
|
|
667
|
+
if not np.all(np.isin([1, 2, 3], da["band_id"])):
|
|
668
|
+
msg = "DataArray must contain bands 1, 2, and 3 for true color"
|
|
669
|
+
raise ValueError(msg)
|
|
670
|
+
|
|
663
671
|
red = da.sel(band_id=2).values
|
|
664
672
|
green = da.sel(band_id=3).values
|
|
665
673
|
blue = da.sel(band_id=1).values
|
|
@@ -716,6 +724,9 @@ def to_ash(da: xr.DataArray, convention: str = "SEVIRI") -> npt.NDArray[np.float
|
|
|
716
724
|
array([0.0127004 , 0.22793579, 0.3930847 ], dtype=float32)
|
|
717
725
|
"""
|
|
718
726
|
if convention == "standard":
|
|
727
|
+
if not np.all(np.isin([11, 13, 14, 15], da["band_id"])):
|
|
728
|
+
msg = "DataArray must contain bands 11, 13, 14, and 15 for standard ash"
|
|
729
|
+
raise ValueError(msg)
|
|
719
730
|
c11 = da.sel(band_id=11).values # 8.44
|
|
720
731
|
c13 = da.sel(band_id=13).values # 10.33
|
|
721
732
|
c14 = da.sel(band_id=14).values # 11.19
|
|
@@ -725,7 +736,10 @@ def to_ash(da: xr.DataArray, convention: str = "SEVIRI") -> npt.NDArray[np.float
|
|
|
725
736
|
green = c14 - c11
|
|
726
737
|
blue = c13
|
|
727
738
|
|
|
728
|
-
elif convention in
|
|
739
|
+
elif convention in ("SEVIRI", "MIT"): # retain MIT for backwards compatibility
|
|
740
|
+
if not np.all(np.isin([11, 14, 15], da["band_id"])):
|
|
741
|
+
msg = "DataArray must contain bands 11, 14, and 15 for SEVIRI ash"
|
|
742
|
+
raise ValueError(msg)
|
|
729
743
|
c11 = da.sel(band_id=11).values # 8.44
|
|
730
744
|
c14 = da.sel(band_id=14).values # 11.19
|
|
731
745
|
c15 = da.sel(band_id=15).values # 12.27
|
|
@@ -770,3 +784,133 @@ def _clip_and_scale(
|
|
|
770
784
|
Clipped and scaled array.
|
|
771
785
|
"""
|
|
772
786
|
return (arr.clip(low, high) - low) / (high - low)
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def parallax_correct(
|
|
790
|
+
longitude: npt.NDArray[np.floating],
|
|
791
|
+
latitude: npt.NDArray[np.floating],
|
|
792
|
+
altitude: npt.NDArray[np.floating],
|
|
793
|
+
goes_da: xr.DataArray,
|
|
794
|
+
) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]:
|
|
795
|
+
r"""Apply parallax correction to WGS84 geodetic coordinates based on satellite perspective.
|
|
796
|
+
|
|
797
|
+
This function considers the ray from the satellite to the points of interest and finds
|
|
798
|
+
the intersection of this ray with the WGS84 ellipsoid. The intersection point is then
|
|
799
|
+
returned as the corrected longitude and latitude coordinates.
|
|
800
|
+
|
|
801
|
+
::
|
|
802
|
+
|
|
803
|
+
@ satellite
|
|
804
|
+
\
|
|
805
|
+
\
|
|
806
|
+
\
|
|
807
|
+
\
|
|
808
|
+
\
|
|
809
|
+
* aircraft
|
|
810
|
+
\
|
|
811
|
+
\
|
|
812
|
+
x parallax corrected aircraft
|
|
813
|
+
------------------------- surface
|
|
814
|
+
|
|
815
|
+
If the point of interest is not visible from the satellite (ie, on the opposite side of the
|
|
816
|
+
earth), the function returns nan for the corrected coordinates.
|
|
817
|
+
|
|
818
|
+
This function requires the :mod:`pyproj` package to be installed.
|
|
819
|
+
|
|
820
|
+
Parameters
|
|
821
|
+
----------
|
|
822
|
+
longitude : npt.NDArray[np.floating]
|
|
823
|
+
A 1D array of longitudes in degrees.
|
|
824
|
+
latitude : npt.NDArray[np.floating]
|
|
825
|
+
A 1D array of latitudes in degrees.
|
|
826
|
+
altitude : npt.NDArray[np.floating]
|
|
827
|
+
A 1D array of altitudes in meters.
|
|
828
|
+
goes_da : xr.DataArray
|
|
829
|
+
DataArray containing the GOES projection information. Only the ``goes_imager_projection``
|
|
830
|
+
field of the :attr:`xr.DataArray.attrs` is used.
|
|
831
|
+
|
|
832
|
+
Returns
|
|
833
|
+
-------
|
|
834
|
+
tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]
|
|
835
|
+
A tuple containing the corrected longitude and latitude coordinates.
|
|
836
|
+
|
|
837
|
+
"""
|
|
838
|
+
goes_imager_projection = goes_da.attrs["goes_imager_projection"]
|
|
839
|
+
sat_lon = goes_imager_projection["longitude_of_projection_origin"]
|
|
840
|
+
sat_lat = goes_imager_projection["latitude_of_projection_origin"]
|
|
841
|
+
sat_alt = goes_imager_projection["perspective_point_height"]
|
|
842
|
+
|
|
843
|
+
try:
|
|
844
|
+
import pyproj
|
|
845
|
+
except ModuleNotFoundError as exc:
|
|
846
|
+
dependencies.raise_module_not_found_error(
|
|
847
|
+
name="parallax_correct function",
|
|
848
|
+
package_name="pyproj",
|
|
849
|
+
module_not_found_error=exc,
|
|
850
|
+
pycontrails_optional_package="pyproj",
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
# Convert from WGS84 to ECEF coordinates
|
|
854
|
+
ecef_crs = pyproj.CRS("EPSG:4978")
|
|
855
|
+
transformer = pyproj.Transformer.from_crs("WGS84", ecef_crs, always_xy=True)
|
|
856
|
+
|
|
857
|
+
p0 = np.array(transformer.transform([sat_lon], [sat_lat], [sat_alt]))
|
|
858
|
+
p1 = np.array(transformer.transform(longitude, latitude, altitude))
|
|
859
|
+
|
|
860
|
+
# Major and minor axes of the ellipsoid
|
|
861
|
+
a = ecef_crs.ellipsoid.semi_major_metre # type: ignore[union-attr]
|
|
862
|
+
b = ecef_crs.ellipsoid.semi_minor_metre # type: ignore[union-attr]
|
|
863
|
+
intersection = _intersection_with_ellipsoid(p0, p1, a, b)
|
|
864
|
+
|
|
865
|
+
# Convert back to WGS84 coordinates
|
|
866
|
+
inv_transformer = pyproj.Transformer.from_crs(ecef_crs, "WGS84", always_xy=True)
|
|
867
|
+
return inv_transformer.transform(*intersection)[:2] # final coord is (close to) 0
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _intersection_with_ellipsoid(
|
|
871
|
+
p0: npt.NDArray[np.floating],
|
|
872
|
+
p1: npt.NDArray[np.floating],
|
|
873
|
+
a: float,
|
|
874
|
+
b: float,
|
|
875
|
+
) -> npt.NDArray[np.floating]:
|
|
876
|
+
"""Find the intersection of a line with the surface of an ellipsoid."""
|
|
877
|
+
# Calculate the direction vector
|
|
878
|
+
px, py, pz = p0
|
|
879
|
+
v = p1 - p0
|
|
880
|
+
vx, vy, vz = v
|
|
881
|
+
|
|
882
|
+
# The line between p0 and p1 in parametric form is p(t) = p0 + t * v
|
|
883
|
+
# We need to find t such that p(t) lies on the ellipsoid
|
|
884
|
+
# x^2 / a^2 + y^2 / a^2 + z^2 / b^2 = 1
|
|
885
|
+
# (px + t * vx)^2 / a^2 + (py + t * vy)^2 / a^2 + (pz + t * vz)^2 / b^2 = 1
|
|
886
|
+
# Rearranging gives a quadratic in t
|
|
887
|
+
|
|
888
|
+
# Calculate the coefficients of this quadratic equation
|
|
889
|
+
A = vx**2 / a**2 + vy**2 / a**2 + vz**2 / b**2
|
|
890
|
+
B = 2 * (px * vx / a**2 + py * vy / a**2 + pz * vz / b**2)
|
|
891
|
+
C = px**2 / a**2 + py**2 / a**2 + pz**2 / b**2 - 1.0
|
|
892
|
+
|
|
893
|
+
# Calculate the discriminant
|
|
894
|
+
D = B**2 - 4 * A * C
|
|
895
|
+
sqrtD = np.sqrt(D, where=D >= 0, out=np.full_like(D, np.nan))
|
|
896
|
+
|
|
897
|
+
# Calculate the two possible solutions for t
|
|
898
|
+
t0 = (-B + sqrtD) / (2.0 * A)
|
|
899
|
+
t1 = (-B - sqrtD) / (2.0 * A)
|
|
900
|
+
|
|
901
|
+
# Calculate the intersection points
|
|
902
|
+
intersection0 = p0 + t0 * v
|
|
903
|
+
intersection1 = p0 + t1 * v
|
|
904
|
+
|
|
905
|
+
# Pick the intersection point that is closer to the aircraft (p1)
|
|
906
|
+
d0 = np.linalg.norm(intersection0 - p1, axis=0)
|
|
907
|
+
d1 = np.linalg.norm(intersection1 - p1, axis=0)
|
|
908
|
+
out = np.where(d0 < d1, intersection0, intersection1)
|
|
909
|
+
|
|
910
|
+
# Fill the points in which the aircraft is not visible by the satellite with nan
|
|
911
|
+
# This occurs when the earth is between the satellite and the aircraft
|
|
912
|
+
# In other words, we can check for t0 < 1 (or t1 < 1)
|
|
913
|
+
opposite_side = t0 < 1.0
|
|
914
|
+
out[:, opposite_side] = np.nan
|
|
915
|
+
|
|
916
|
+
return out
|
|
@@ -75,7 +75,7 @@ class ValidateTrajectoryHandler:
|
|
|
75
75
|
<LINK HERE TO HOSTED REFERENCE EXAMPLE(S)>.
|
|
76
76
|
"""
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
ROCD_THRESHOLD_FPS = 83.25 # 83.25 ft/sec ~= 5000 ft/min
|
|
79
79
|
CRUISE_LOW_ALTITUDE_THRESHOLD_FT = 15000.0 # lowest expected cruise altitude
|
|
80
80
|
INSTANTANEOUS_HIGH_GROUND_SPEED_THRESHOLD_MPS = 350.0 # 350m/sec ~= 780mph ~= 1260kph
|
|
81
81
|
INSTANTANEOUS_LOW_GROUND_SPEED_THRESHOLD_MPS = 45.0 # 45m/sec ~= 100mph ~= 160kph
|
|
@@ -473,12 +473,12 @@ class ValidateTrajectoryHandler:
|
|
|
473
473
|
Evaluate flight altitude profile.
|
|
474
474
|
|
|
475
475
|
Failure modes include:
|
|
476
|
-
|
|
476
|
+
FlightAltitudeProfileError
|
|
477
477
|
1) flight climbs above alt threshold,
|
|
478
478
|
then descends below that threshold one or more times,
|
|
479
479
|
before making final descent to land.
|
|
480
480
|
|
|
481
|
-
|
|
481
|
+
RocdError
|
|
482
482
|
2) rate of instantaneous (between consecutive waypoint) climb or descent is above threshold,
|
|
483
483
|
while aircraft is above the cruise altitude.
|
|
484
484
|
"""
|
|
@@ -488,15 +488,13 @@ class ValidateTrajectoryHandler:
|
|
|
488
488
|
|
|
489
489
|
violations: list[FlightAltitudeProfileError | ROCDError] = []
|
|
490
490
|
|
|
491
|
-
#
|
|
492
|
-
rocd_above_thres =
|
|
493
|
-
self._df["altitude_baro"] >= self.CRUISE_LOW_ALTITUDE_THRESHOLD_FT
|
|
494
|
-
)
|
|
491
|
+
# evaluate ROCD
|
|
492
|
+
rocd_above_thres = self._df["rocd_fps"].abs() >= self.ROCD_THRESHOLD_FPS
|
|
495
493
|
if rocd_above_thres.any():
|
|
496
494
|
msg = (
|
|
497
495
|
"Flight trajectory has rate of climb/descent values "
|
|
498
496
|
"between consecutive waypoints that exceed threshold "
|
|
499
|
-
f"of {self.
|
|
497
|
+
f"of {self.ROCD_THRESHOLD_FPS:.3f}ft/sec. "
|
|
500
498
|
f"Max value found: {self._df['rocd_fps'].abs().max():.3f}ft/sec"
|
|
501
499
|
)
|
|
502
500
|
violations.append(ROCDError(msg))
|
|
@@ -207,7 +207,7 @@ class SyntheticFlight:
|
|
|
207
207
|
|
|
208
208
|
def _id(self) -> int:
|
|
209
209
|
"""Get random flight ID."""
|
|
210
|
-
return self.rng.integers(100_000, 999_999)
|
|
210
|
+
return self.rng.integers(100_000, 999_999).item()
|
|
211
211
|
|
|
212
212
|
def _define_aircraft(self) -> None:
|
|
213
213
|
"""Define or update instance variables pertaining to flight aircrafts.
|