pycontrails 0.52.1__cp312-cp312-win_amd64.whl → 0.52.3__cp312-cp312-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.

pycontrails/_version.py CHANGED
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '0.52.1'
16
- __version_tuple__ = version_tuple = (0, 52, 1)
15
+ __version__ = version = '0.52.3'
16
+ __version_tuple__ = version_tuple = (0, 52, 3)
@@ -9,6 +9,7 @@ from typing import Any, Generic, NoReturn, overload
9
9
 
10
10
  import numpy as np
11
11
  import numpy.typing as npt
12
+ from overrides import overrides
12
13
 
13
14
  from pycontrails.core import flight, fuel
14
15
  from pycontrails.core.flight import Flight
@@ -39,6 +40,19 @@ class AircraftPerformanceParams(ModelParams):
39
40
  #: The default value of 3 is sufficient for most cases.
40
41
  n_iter: int = 3
41
42
 
43
+ #: Experimental. If True, fill waypoints below the lowest altitude met
44
+ #: level with ISA temperature when interpolating "air_temperature" or "t".
45
+ #: If the ``met`` data is not provided, the entire air temperature array
46
+ #: is approximated with the ISA temperature. Enabling this does NOT
47
+ #: remove any NaN values in the ``met`` data itself.
48
+ fill_low_altitude_with_isa_temperature: bool = False
49
+
50
+ #: Experimental. If True, fill waypoints below the lowest altitude met
51
+ #: level with zero wind when computing true airspeed. In other words,
52
+ #: approximate low-altitude true airspeed with the ground speed. Enabling
53
+ #: this does NOT remove any NaN values in the ``met`` data itself.
54
+ fill_low_altitude_with_zero_wind: bool = False
55
+
42
56
 
43
57
  class AircraftPerformance(Model):
44
58
  """
@@ -104,6 +118,23 @@ class AircraftPerformance(Model):
104
118
  Flight trajectory with aircraft performance data.
105
119
  """
106
120
 
121
+ @overrides
122
+ def set_source_met(self, *args: Any, **kwargs: Any) -> None:
123
+ fill_with_isa = self.params["fill_low_altitude_with_isa_temperature"]
124
+ if fill_with_isa and (self.met is None or "air_temperature" not in self.met):
125
+ if "air_temperature" in self.source:
126
+ _fill_low_altitude_with_isa_temperature(self.source, 0.0)
127
+ else:
128
+ self.source["air_temperature"] = self.source.T_isa()
129
+ fill_with_isa = False # we've just filled it
130
+
131
+ super().set_source_met(*args, **kwargs)
132
+ if not fill_with_isa:
133
+ return
134
+
135
+ met_level_0 = self.met.data["level"][-1].item() # type: ignore[union-attr]
136
+ _fill_low_altitude_with_isa_temperature(self.source, met_level_0)
137
+
107
138
  def simulate_fuel_and_performance(
108
139
  self,
109
140
  *,
@@ -426,27 +457,41 @@ class AircraftPerformance(Model):
426
457
  on :attr:`source`, this is returned directly. Otherwise, it is calculated
427
458
  using :meth:`Flight.segment_true_airspeed`.
428
459
  """
460
+ tas = self.source.get("true_airspeed")
461
+ fill_with_groundspeed = self.params["fill_low_altitude_with_zero_wind"]
462
+
463
+ if tas is not None:
464
+ if not fill_with_groundspeed:
465
+ return tas
466
+ cond = np.isnan(tas)
467
+ tas[cond] = self.source.segment_groundspeed()[cond]
468
+ return tas
469
+
470
+ met_incomplete = (
471
+ self.met is None or "eastward_wind" not in self.met or "northward_wind" not in self.met
472
+ )
473
+ if met_incomplete:
474
+ if fill_with_groundspeed:
475
+ tas = self.source.segment_groundspeed()
476
+ self.source["true_airspeed"] = tas
477
+ return tas
478
+ msg = (
479
+ "Cannot compute 'true_airspeed' without 'eastward_wind' and 'northward_wind' "
480
+ "met data. Either include met data in the model constructor, define "
481
+ "'true_airspeed' data on the flight, or set "
482
+ "'fill_low_altitude_with_zero_wind' to True."
483
+ )
484
+ raise ValueError(msg)
429
485
 
430
- try:
431
- return self.source["true_airspeed"]
432
- except KeyError:
433
- pass
434
-
435
- if not isinstance(self.source, Flight):
436
- raise TypeError("Model source must be a Flight to calculate true airspeed.")
437
-
438
- # Two step fallback: try to find u_wind and v_wind.
439
- try:
440
- u = interpolate_met(self.met, self.source, "eastward_wind", **self.interp_kwargs)
441
- v = interpolate_met(self.met, self.source, "northward_wind", **self.interp_kwargs)
486
+ u = interpolate_met(self.met, self.source, "eastward_wind", **self.interp_kwargs)
487
+ v = interpolate_met(self.met, self.source, "northward_wind", **self.interp_kwargs)
442
488
 
443
- except (ValueError, KeyError) as exc:
444
- raise ValueError(
445
- "Variable 'true_airspeed' not found. Include 'eastward_wind' and"
446
- " 'northward_wind' variables on 'met' in model constructor, or define"
447
- " 'true_airspeed' data on flight. This can be achieved by calling the"
448
- " 'Flight.segment_true_airspeed' method."
449
- ) from exc
489
+ if fill_with_groundspeed:
490
+ met_level_max = self.met.data["level"][-1].item() # type: ignore[union-attr]
491
+ cond = self.source.level > met_level_max
492
+ # We DON'T overwrite the original u and v arrays already attached to the source
493
+ u = np.where(cond, 0.0, u)
494
+ v = np.where(cond, 0.0, v)
450
495
 
451
496
  out = self.source.segment_true_airspeed(u, v)
452
497
  self.source["true_airspeed"] = out
@@ -543,3 +588,54 @@ class AircraftPerformanceGridData(Generic[ArrayOrFloat]):
543
588
 
544
589
  #: Engine efficiency, [:math:`0-1`]
545
590
  engine_efficiency: ArrayOrFloat
591
+
592
+
593
+ def _fill_low_altitude_with_isa_temperature(vector: GeoVectorDataset, met_level_max: float) -> None:
594
+ """Fill low-altitude NaN values in ``air_temperature`` with ISA values.
595
+
596
+ The ``air_temperature`` param is assumed to have been computed by
597
+ interpolating against a gridded air temperature field that did not
598
+ necessarily extend to the surface. This function fills points below the
599
+ lowest altitude in the gridded data with ISA temperature values.
600
+
601
+ This function operates in-place and modifies the ``air_temperature`` field.
602
+
603
+ Parameters
604
+ ----------
605
+ vector : GeoVectorDataset
606
+ GeoVectorDataset instance associated with the ``air_temperature`` data.
607
+ met_level_max : float
608
+ The maximum level in the met data, [:math:`hPa`].
609
+ """
610
+ air_temperature = vector["air_temperature"]
611
+ is_nan = np.isnan(air_temperature)
612
+ low_alt = vector.level > met_level_max
613
+ cond = is_nan & low_alt
614
+
615
+ t_isa = vector.T_isa()
616
+ air_temperature[cond] = t_isa[cond]
617
+
618
+
619
+ def _fill_low_altitude_tas_with_true_groundspeed(fl: Flight, met_level_max: float) -> None:
620
+ """Fill low-altitude NaN values in ``true_airspeed`` with ground speed.
621
+
622
+ The ``true_airspeed`` param is assumed to have been computed by
623
+ interpolating against a gridded wind field that did not necessarily
624
+ extend to the surface. This function fills points below the lowest
625
+ altitude in the gridded data with ground speed values.
626
+
627
+ This function operates in-place and modifies the ``true_airspeed`` field.
628
+
629
+ Parameters
630
+ ----------
631
+ fl : Flight
632
+ Flight instance associated with the ``true_airspeed`` data.
633
+ met_level_max : float
634
+ The maximum level in the met data, [:math:`hPa`].
635
+ """
636
+ tas = fl["true_airspeed"]
637
+ is_nan = np.isnan(tas)
638
+ low_alt = fl.level > met_level_max
639
+ cond = is_nan & low_alt
640
+
641
+ tas[cond] = fl.segment_groundspeed()[cond]
pycontrails/core/fleet.py CHANGED
@@ -226,28 +226,29 @@ class Fleet(Flight):
226
226
  return len(self.fl_attrs)
227
227
 
228
228
  def to_flight_list(self, copy: bool = True) -> list[Flight]:
229
- """De-concatenate merged waypoints into a list of Flight instances.
229
+ """De-concatenate merged waypoints into a list of :class:`Flight` instances.
230
230
 
231
231
  Any global :attr:`attrs` are lost.
232
232
 
233
233
  Parameters
234
234
  ----------
235
235
  copy : bool, optional
236
- If True, make copy of each flight instance in `seq`.
236
+ If True, make copy of each :class:`Flight` instance.
237
237
 
238
238
  Returns
239
239
  -------
240
240
  list[Flight]
241
- List of Flights in the same order as was passed into the `Fleet` instance.
241
+ List of Flights in the same order as was passed into the ``Fleet`` instance.
242
242
  """
243
-
244
- # Avoid self.dataframe to purposely drop global attrs
245
- tmp = pd.DataFrame(self.data, copy=copy)
246
- grouped = tmp.groupby("flight_id", sort=False)
247
-
243
+ indices = self.dataframe.groupby("flight_id", sort=False).indices
248
244
  return [
249
- Flight(df, attrs=self.fl_attrs[flight_id], fuel=self.fuel, copy=copy)
250
- for flight_id, df in grouped
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()
251
252
  ]
252
253
 
253
254
  ###################################
@@ -954,28 +954,13 @@ class Flight(GeoVectorDataset):
954
954
  # STEP 3: Set the time index, and sort it
955
955
  df = df.set_index("time", verify_integrity=True).sort_index()
956
956
 
957
- # STEP 4: Some adhoc code for dealing with antimeridian.
958
- # Idea: A flight likely crosses the antimeridian if
959
- # `min_pos > 90` and `max_neg < -90`
960
- # This is not foolproof: it assumes the full trajectory will not
961
- # span more than 180 longitude degrees. There could be flights that
962
- # violate this near the poles (but this would be very rare -- flights
963
- # would instead wrap the other way). For this flights spanning the
964
- # antimeridian, we translate them to a common "chart" away from the
965
- # antimeridian (see variable `shift`), then apply the interpolation,
966
- # then shift back to their original position.
967
- lon = df["longitude"].to_numpy()
968
- sign_ = np.sign(lon)
969
- min_pos = np.min(lon[sign_ == 1.0], initial=np.inf)
970
- max_neg = np.max(lon[sign_ == -1.0], initial=-np.inf)
971
-
972
- if (180.0 - min_pos) + (180.0 + max_neg) < 180.0 and min_pos < np.inf and max_neg > -np.inf:
973
- # In this case, we believe the flight crosses the antimeridian
974
- shift = min_pos
975
- # So we shift the longitude "chart"
957
+ # STEP 4: handle antimeridian crossings
958
+ # For flights spanning the antimeridian, we translate them to a
959
+ # common "chart" away from the antimeridian (see variable `shift`),
960
+ # then apply the interpolation, then shift back to their original position.
961
+ shift = self._antimeridian_shift()
962
+ if shift is not None:
976
963
  df["longitude"] = (df["longitude"] - shift) % 360.0
977
- else:
978
- shift = None
979
964
 
980
965
  # STEP 5: Resample flight to freq
981
966
  # Save altitudes to copy over - these just get rounded down in time.
@@ -1189,19 +1174,12 @@ class Flight(GeoVectorDataset):
1189
1174
  """
1190
1175
 
1191
1176
  # Check if flight crosses antimeridian line
1177
+ # If it does, shift longitude chart to remove jump
1192
1178
  lon_ = self["longitude"]
1193
1179
  lat_ = self["latitude"]
1194
- sign_ = np.sign(lon_)
1195
- min_pos = np.min(lon_[sign_ == 1.0], initial=np.inf)
1196
- max_neg = np.max(lon_[sign_ == -1.0], initial=-np.inf)
1197
-
1198
- if (180.0 - min_pos) + (180.0 + max_neg) < 180.0 and min_pos < np.inf and max_neg > -np.inf:
1199
- # In this case, we believe the flight crosses the antimeridian
1200
- shift = min_pos
1201
- # So we shift the longitude "chart"
1180
+ shift = self._antimeridian_shift()
1181
+ if shift is not None:
1202
1182
  lon_ = (lon_ - shift) % 360.0
1203
- else:
1204
- shift = None
1205
1183
 
1206
1184
  # Make a fake flight that flies at constant height so distance is just
1207
1185
  # distance traveled across groud
@@ -1262,6 +1240,55 @@ class Flight(GeoVectorDataset):
1262
1240
 
1263
1241
  return lat, lon, seg_idx
1264
1242
 
1243
+ def _antimeridian_shift(self) -> float | None:
1244
+ """Determine shift required for resampling trajectories that cross antimeridian.
1245
+
1246
+ Because flights sometimes span more than 180 degree longitude (for example,
1247
+ when flight-level winds favor travel in a specific direction, typically eastward),
1248
+ antimeridian crossings cannot reliably be detected by looking only at minimum
1249
+ and maximum longitudes.
1250
+
1251
+ Instead, this function checks each flight segment for an antimeridian crossing,
1252
+ and if it finds one returns the coordinate of a meridian that is not crossed by
1253
+ the flight.
1254
+
1255
+ Returns
1256
+ -------
1257
+ float | None
1258
+ Longitude shift for handling antimeridian crossings, or None if the
1259
+ flight does not cross the antimeridian.
1260
+ """
1261
+
1262
+ # logic for detecting crossings is consistent with _antimeridian_crossing,
1263
+ # but implementation is separate to keep performance costs as low as possible
1264
+ lon = self["longitude"]
1265
+ if np.any(np.isnan(lon)):
1266
+ warnings.warn("Anti-meridian crossings can't be reliably detected with nan longitudes")
1267
+
1268
+ s1 = (lon >= -180) & (lon <= -90)
1269
+ s2 = (lon <= 180) & (lon >= 90)
1270
+ jump12 = s1[:-1] & s2[1:] # westward
1271
+ jump21 = s2[:-1] & s1[1:] # eastward
1272
+ if not np.any(jump12 | jump21):
1273
+ return None
1274
+
1275
+ # separate flight into segments that are east and west of crossings
1276
+ net_westward = np.insert(np.cumsum(jump12.astype(int) - jump21.astype(int)), 0, 0)
1277
+ max_westward = net_westward.max()
1278
+ if max_westward - net_westward.min() > 1:
1279
+ msg = "Cannot handle consecutive antimeridian crossings in the same direction"
1280
+ raise ValueError(msg)
1281
+ east = (net_westward == 0) if max_westward == 1 else (net_westward == -1)
1282
+
1283
+ # shift must be between maximum longitude east of crossings
1284
+ # and minimum longitude west of crossings
1285
+ shift_min = np.nanmax(lon[east])
1286
+ shift_max = np.nanmin(lon[~east])
1287
+ if shift_min >= shift_max:
1288
+ msg = "Cannot handle flight that spans more than 360 degrees longitude"
1289
+ raise ValueError(msg)
1290
+ return (shift_min + shift_max) / 2
1291
+
1265
1292
  def _geodesic_interpolation(self, geodesic_threshold: float) -> pd.DataFrame | None:
1266
1293
  """Geodesic interpolate between large gaps between waypoints.
1267
1294
 
@@ -1506,25 +1533,25 @@ class Flight(GeoVectorDataset):
1506
1533
 
1507
1534
  >>> # Build flight
1508
1535
  >>> df = pd.DataFrame()
1509
- >>> df['time'] = pd.date_range('2022-03-01T00', '2022-03-01T03', periods=11)
1510
- >>> df['longitude'] = np.linspace(-20, 20, 11)
1511
- >>> df['latitude'] = np.linspace(-20, 20, 11)
1512
- >>> df['altitude'] = np.linspace(9500, 10000, 11)
1513
- >>> fl = Flight(df).resample_and_fill('10s')
1536
+ >>> df["time"] = pd.date_range("2022-03-01T00", "2022-03-01T03", periods=11)
1537
+ >>> df["longitude"] = np.linspace(-20, 20, 11)
1538
+ >>> df["latitude"] = np.linspace(-20, 20, 11)
1539
+ >>> df["altitude"] = np.linspace(9500, 10000, 11)
1540
+ >>> fl = Flight(df).resample_and_fill("10s")
1514
1541
 
1515
1542
  >>> # Intersect and attach
1516
- >>> fl["air_temperature"] = fl.intersect_met(met['air_temperature'])
1543
+ >>> fl["air_temperature"] = fl.intersect_met(met["air_temperature"])
1517
1544
  >>> fl["air_temperature"]
1518
- array([235.94657007, 235.95766965, 235.96873412, ..., 234.59917962,
1545
+ array([235.94657007, 235.55745645, 235.56709768, ..., 234.59917962,
1519
1546
  234.60387402, 234.60845312])
1520
1547
 
1521
1548
  >>> # Length (in meters) of waypoints whose temperature exceeds 236K
1522
1549
  >>> fl.length_met("air_temperature", threshold=236)
1523
- np.float64(4132178.159...)
1550
+ np.float64(3589705.998...)
1524
1551
 
1525
1552
  >>> # Proportion (with respect to distance) of waypoints whose temperature exceeds 236K
1526
1553
  >>> fl.proportion_met("air_temperature", threshold=236)
1527
- np.float64(0.663552...)
1554
+ np.float64(0.576...)
1528
1555
  """
1529
1556
  if key not in self.data:
1530
1557
  raise KeyError(f"Column {key} does not exist in data.")
@@ -1591,10 +1618,30 @@ class Flight(GeoVectorDataset):
1591
1618
  :class:`matplotlib.axes.Axes`
1592
1619
  Plot
1593
1620
  """
1594
- ax = self.dataframe.plot(x="longitude", y="latitude", legend=False, **kwargs)
1621
+ kwargs.setdefault("legend", False)
1622
+ ax = self.dataframe.plot(x="longitude", y="latitude", **kwargs)
1595
1623
  ax.set(xlabel="longitude", ylabel="latitude")
1596
1624
  return ax
1597
1625
 
1626
+ def plot_profile(self, **kwargs: Any) -> matplotlib.axes.Axes:
1627
+ """Plot flight trajectory time-altitude values.
1628
+
1629
+ Parameters
1630
+ ----------
1631
+ **kwargs : Any
1632
+ Additional plot properties to passed to `pd.DataFrame.plot`
1633
+
1634
+ Returns
1635
+ -------
1636
+ :class:`matplotlib.axes.Axes`
1637
+ Plot
1638
+ """
1639
+ kwargs.setdefault("legend", False)
1640
+ df = self.dataframe.assign(altitude_ft=self.altitude_ft)
1641
+ ax = df.plot(x="time", y="altitude_ft", **kwargs)
1642
+ ax.set(xlabel="time", ylabel="altitude_ft")
1643
+ return ax
1644
+
1598
1645
 
1599
1646
  def _return_linestring(data: dict[str, npt.NDArray[np.float64]]) -> list[list[float]]:
1600
1647
  """Return list of coordinates for geojson constructions.
@@ -1631,18 +1678,14 @@ def _antimeridian_index(longitude: pd.Series, crs: str = "EPSG:4326") -> list[in
1631
1678
 
1632
1679
  Returns
1633
1680
  -------
1634
- int
1635
- Index after jump or -1
1681
+ list[int]
1682
+ Indices after jump, or empty list of flight does not cross antimeridian.
1636
1683
 
1637
1684
  Raises
1638
1685
  ------
1639
1686
  ValueError
1640
1687
  CRS is not supported.
1641
- Flight crosses antimeridian several times.
1642
1688
  """
1643
- # FIXME: This logic here is somewhat outdated - the _interpolate_altitude
1644
- # method handles this somewhat more reliably
1645
- # This function should get updated to follow the logic there.
1646
1689
  # WGS84
1647
1690
  if crs in ["EPSG:4326"]:
1648
1691
  l1 = (-180.0, -90.0)
@@ -1878,7 +1921,7 @@ def _altitude_interpolation_climb_descend_middle(
1878
1921
  s = pd.Series(altitude)
1879
1922
 
1880
1923
  # Check to see if we have gaps greater than two hours
1881
- step_threshold = 120.0 * freq / np.timedelta64(1, "m")
1924
+ step_threshold = np.timedelta64(2, "h") / freq
1882
1925
  step_groups = na_group_size > step_threshold
1883
1926
  if np.any(step_groups):
1884
1927
  # If there are gaps greater than two hours, step through one by one
@@ -2214,16 +2257,14 @@ def segment_rocd(
2214
2257
  if air_temperature is None:
2215
2258
  return out
2216
2259
 
2217
- else:
2218
- altitude_m = units.ft_to_m(altitude_ft)
2219
- T_isa = units.m_to_T_isa(altitude_m)
2260
+ altitude_m = units.ft_to_m(altitude_ft)
2261
+ T_isa = units.m_to_T_isa(altitude_m)
2220
2262
 
2221
- T_correction = np.empty_like(altitude_ft)
2222
- T_correction[:-1] = (0.5 * (air_temperature[:-1] + air_temperature[1:])) / (
2223
- 0.5 * (T_isa[:-1] + T_isa[1:])
2224
- )
2225
- T_correction[-1] = np.nan
2226
- return T_correction * out
2263
+ T_correction = np.empty_like(altitude_ft)
2264
+ T_correction[:-1] = (air_temperature[:-1] + air_temperature[1:]) / (T_isa[:-1] + T_isa[1:])
2265
+ T_correction[-1] = np.nan
2266
+
2267
+ return T_correction * out
2227
2268
 
2228
2269
 
2229
2270
  def _resample_to_freq(df: pd.DataFrame, freq: str) -> tuple[pd.DataFrame, pd.DatetimeIndex]:
@@ -215,7 +215,8 @@ class PycontrailsRegularGridInterpolator(scipy.interpolate.RegularGridInterpolat
215
215
 
216
216
  if ndim == 1:
217
217
  # np.interp could be better ... although that may also promote the dtype
218
- return rgi_cython.evaluate_linear_1d(values, indices, norm_distances, out)
218
+ # 1-d view is required for evaluate_linear_1d
219
+ return rgi_cython.evaluate_linear_1d(values, indices[0, :], norm_distances[0, :], out)
219
220
 
220
221
  msg = f"Invalid number of dimensions: {ndim}"
221
222
  raise ValueError(msg)