pycontrails 0.47.3__cp310-cp310-win_amd64.whl → 0.48.1__cp310-cp310-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.

@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import logging
6
6
  import warnings
7
- from typing import Any, NoReturn, Sequence, overload
7
+ from typing import Any, Literal, NoReturn, Sequence, overload
8
8
 
9
9
  import numpy as np
10
10
  import numpy.typing as npt
@@ -93,9 +93,6 @@ class Cocip(Model):
93
93
  * - Ice water content
94
94
  - ``specific_cloud_ice_water_content``
95
95
  - ``ice_water_mixing_ratio``
96
- * - Geopotential
97
- - ``geopotential``
98
- - ``geopotential_height``
99
96
 
100
97
  .. list-table:: Variable keys for single-level radiation data
101
98
  :header-rows: 1
@@ -153,13 +150,13 @@ class Cocip(Model):
153
150
 
154
151
  **Outputs**
155
152
 
156
- NaN values may appear in model output. Specifically, `np.nan` values are used to indicate:
153
+ NaN values may appear in model output. Specifically, ``np.nan`` values are used to indicate:
157
154
 
158
155
  - Flight waypoint or contrail waypoint is not contained with the :attr:`met` domain.
159
156
  - The variable was NOT computed during the model evaluation. For example, at flight waypoints
160
- not producing any persistent contrails, "radiative" variables (`rsr`, `olr`, `rf_sw`,
161
- `rf_lw`, `rf_net`) are not computed. Consequently, the corresponding values in the output
162
- of :meth:`eval` are NaN. One exception to this rule is found on `ef` (energy forcing)
157
+ not producing any persistent contrails, "radiative" variables (``rsr``, ``olr``, ``rf_sw``,
158
+ ``rf_lw``, ``rf_net``) are not computed. Consequently, the corresponding values in the output
159
+ of :meth:`eval` are NaN. One exception to this rule is found on ``ef`` (energy forcing)
163
160
  `contrail_age` predictions. For these two "cumulative" variables, waypoints not producing
164
161
  any persistent contrails are assigned 0 values.
165
162
 
@@ -212,7 +209,6 @@ class Cocip(Model):
212
209
  met_var.NorthwardWind,
213
210
  met_var.VerticalVelocity,
214
211
  (ecmwf.SpecificCloudIceWaterContent, gfs.CloudIceWaterMixingRatio),
215
- (met_var.Geopotential, met_var.GeopotentialHeight),
216
212
  )
217
213
 
218
214
  #: Required single-level top of atmosphere radiation variables.
@@ -223,7 +219,7 @@ class Cocip(Model):
223
219
  )
224
220
 
225
221
  #: Minimal set of met variables needed to run the model after pre-processing.
226
- #: The intention here is that ``geopotential`` and ``ciwc`` are unnecessary after
222
+ #: The intention here is that ``ciwc`` is unnecessary after
227
223
  #: ``tau_cirrus`` has already been calculated.
228
224
  processed_met_variables = (
229
225
  met_var.AirTemperature,
@@ -235,28 +231,33 @@ class Cocip(Model):
235
231
  )
236
232
 
237
233
  #: Additional met variables used to support outputs
238
- optional_met_variables = ((ecmwf.CloudAreaFractionInLayer, gfs.TotalCloudCoverIsobaric),)
234
+ #: .. versionchanged:: 0.48.0
235
+ #: Moved Geopotential from :attr:`required_met_variables` to :attr:`optional_met_variables`
236
+ optional_met_variables = (
237
+ (met_var.Geopotential, met_var.GeopotentialHeight),
238
+ (ecmwf.CloudAreaFractionInLayer, gfs.TotalCloudCoverIsobaric),
239
+ )
239
240
 
240
241
  #: Met data is not optional
241
242
  met: MetDataset
242
243
  met_required = True
243
244
 
244
- #: Radiation data formatted as a MetDataset at a single pressure level [-1]
245
+ #: Radiation data formatted as a :class:`MetDataset` at a single pressure level [-1]
245
246
  rad: MetDataset
246
247
 
247
248
  #: Last Flight modeled in :meth:`eval`
248
249
  source: Flight | Fleet
249
250
 
250
- #: List of GeoVectorDataset contrail objects - one for each timestep
251
+ #: List of :class:`GeoVectorDataset` contrail objects - one for each timestep
251
252
  contrail_list: list[GeoVectorDataset]
252
253
 
253
254
  #: Contrail evolution output from model. Set to None when no contrails are formed.
254
255
  contrail: pd.DataFrame | None
255
256
 
256
- #: xr.Dataset reporesentation of contrail evolution.
257
+ #: :class:`xr.Dataset` representation of contrail evolution.
257
258
  contrail_dataset: xr.Dataset | None
258
259
 
259
- #: Array of np.datetime64 time steps for contrail evolution
260
+ #: Array of :class:`np.datetime64` time steps for contrail evolution
260
261
  timesteps: npt.NDArray[np.datetime64]
261
262
 
262
263
  #: Parallel copy of flight waypoints after SAC filter applied
@@ -278,12 +279,8 @@ class Cocip(Model):
278
279
  # call Model init
279
280
  super().__init__(met, params=params, **params_kwargs)
280
281
 
281
- shift_radiation_time = self.params["shift_radiation_time"]
282
282
  compute_tau_cirrus = self.params["compute_tau_cirrus_in_model_init"]
283
- met, rad = process_met_datasets(met, rad, compute_tau_cirrus, shift_radiation_time)
284
-
285
- self.met = met
286
- self.rad = rad
283
+ self.met, self.rad = process_met_datasets(met, rad, compute_tau_cirrus)
287
284
 
288
285
  # initialize outputs to None
289
286
  self.contrail = None
@@ -406,14 +403,13 @@ class Cocip(Model):
406
403
  self.contrail_list = []
407
404
  self._simulate_contrail_evolution()
408
405
 
409
- self._cleanup_indices()
410
-
411
406
  if not self.contrail_list:
412
407
  logger.debug("No contrails formed by %s", label)
413
408
  return self._fill_empty_flight_results(return_flight_list)
414
409
 
415
410
  logger.debug("Complete contrail simulation for %s", label)
416
411
 
412
+ self._cleanup_indices()
417
413
  self._bundle_results()
418
414
 
419
415
  if return_flight_list:
@@ -915,7 +911,7 @@ class Cocip(Model):
915
911
  self._downwash_contrail = self._create_downwash_contrail()
916
912
  buffers = {
917
913
  f"{coord}_buffer": self.params[f"met_{coord}_buffer"]
918
- for coord in ["longitude", "latitude", "level"]
914
+ for coord in ("longitude", "latitude", "level")
919
915
  }
920
916
  logger.debug("Downselect met for start of Cocip evolution")
921
917
  met = self._downwash_contrail.downselect_met(self.met, **buffers, copy=False)
@@ -1101,12 +1097,20 @@ class Cocip(Model):
1101
1097
 
1102
1098
  @overrides
1103
1099
  def _cleanup_indices(self) -> None:
1104
- if self.params["interpolation_use_indices"]:
1100
+ """Cleanup interpolation artifacts."""
1101
+
1102
+ if not self.params["interpolation_use_indices"]:
1103
+ return
1104
+
1105
+ if hasattr(self, "contrail_list"):
1105
1106
  for contrail in self.contrail_list:
1106
1107
  contrail._invalidate_indices()
1107
- self.source._invalidate_indices()
1108
- self._sac_flight._invalidate_indices()
1108
+
1109
+ self.source._invalidate_indices()
1110
+ self._sac_flight._invalidate_indices()
1111
+ if hasattr(self, "_downwash_flight"):
1109
1112
  self._downwash_flight._invalidate_indices()
1113
+ if hasattr(self, "_downwash_contrail"):
1110
1114
  self._downwash_contrail._invalidate_indices()
1111
1115
 
1112
1116
  def _bundle_results(self) -> None:
@@ -1118,7 +1122,9 @@ class Cocip(Model):
1118
1122
  self.contrail = pd.concat(dfs)
1119
1123
 
1120
1124
  # add age in hours to the contrail waypoint outputs
1121
- self.contrail["age_hours"] = self.contrail["age"] / np.timedelta64(1, "h")
1125
+ age_hours = np.empty_like(self.contrail["ef"])
1126
+ np.divide(self.contrail["age"], np.timedelta64(1, "h"), out=age_hours)
1127
+ self.contrail["age_hours"] = age_hours
1122
1128
 
1123
1129
  if self.params["verbose_outputs"]:
1124
1130
  # Compute dt_integration -- logic is somewhat complicated, but
@@ -1143,10 +1149,9 @@ class Cocip(Model):
1143
1149
 
1144
1150
  self.contrail = seq_index.set_index("index")
1145
1151
 
1146
- # ---
1147
- # Create contrail xr.Dataset (self.contrail_dataset)
1148
- # ---
1149
- if self.params["verbose_outputs"]:
1152
+ # ---
1153
+ # Create contrail xr.Dataset (self.contrail_dataset)
1154
+ # ---
1150
1155
  if isinstance(self.source, Fleet):
1151
1156
  self.contrail_dataset = xr.Dataset.from_dataframe(
1152
1157
  self.contrail.set_index(["flight_id", "timestep", "waypoint"])
@@ -1253,9 +1258,11 @@ class Cocip(Model):
1253
1258
  Flight or list[Flight]
1254
1259
  Flight or list of Flight objects with empty variables.
1255
1260
  """
1261
+ self._cleanup_indices()
1256
1262
 
1257
1263
  intersection = self.source.data.pop("_met_intersection")
1258
- zeros_and_nans = np.where(intersection, 0.0, np.nan)
1264
+ zeros_and_nans = np.zeros(intersection.shape, dtype=np.float32)
1265
+ zeros_and_nans[~intersection] = np.nan
1259
1266
  self.source["ef"] = zeros_and_nans.copy()
1260
1267
  self.source["persistent_1"] = zeros_and_nans.copy()
1261
1268
  self.source["cocip"] = np.sign(zeros_and_nans)
@@ -1275,8 +1282,7 @@ class Cocip(Model):
1275
1282
  def process_met_datasets(
1276
1283
  met: MetDataset,
1277
1284
  rad: MetDataset,
1278
- compute_tau_cirrus: bool | str = "auto",
1279
- shift_radiation_time: np.timedelta64 | None = None,
1285
+ compute_tau_cirrus: bool | Literal["auto"] = "auto",
1280
1286
  ) -> tuple[MetDataset, MetDataset]:
1281
1287
  """Process and verify ERA5 data for :class:`Cocip` and :class:`CocipGrid`.
1282
1288
 
@@ -1289,18 +1295,20 @@ def process_met_datasets(
1289
1295
  of the ``process_met`` parameter. The same approach is also taken
1290
1296
  in :class:`Cocip` in version 0.27.0.
1291
1297
 
1298
+ .. versionchanged:: 0.48.0
1299
+
1300
+ Remove the ``shift_radiation_time`` parameter. This parameter is now
1301
+ inferred from the metadata on the `rad` instance.
1302
+
1292
1303
  Parameters
1293
1304
  ----------
1294
1305
  met : MetDataset
1295
1306
  Met pressure-level data
1296
1307
  rad : MetDataset
1297
1308
  Rad single-level data
1298
- compute_tau_cirrus : bool | str
1309
+ compute_tau_cirrus : bool | Literal["auto"]
1299
1310
  Whether to add ``"tau_cirrus"`` variable to pressure-level met data. If set to
1300
1311
  ``"auto"``, ``"tau_cirrus"`` will be computed iff the met data is dask-backed.
1301
- shift_radiation_time : np.timedelta64 | None
1302
- Shift the time dimension of radiation data to account for accumulated values.
1303
- If not specified, the default value from :class:`CocipGridParams` will be used.
1304
1312
 
1305
1313
  Returns
1306
1314
  -------
@@ -1315,35 +1323,20 @@ def process_met_datasets(
1315
1323
  "Specific humidity enhancement of the raw specific humidity values in "
1316
1324
  "the underlying met data is deprecated."
1317
1325
  )
1318
- if isinstance(compute_tau_cirrus, str) and compute_tau_cirrus != "auto":
1319
- raise ValueError(
1320
- "Parameter ``compute_tau_cirrus`` must be one of"
1321
- f" True, False, or 'auto'. Found {compute_tau_cirrus}."
1322
- )
1326
+
1327
+ if compute_tau_cirrus == "auto":
1328
+ # If met data is dask-backed, compute tau_cirrus
1329
+ compute_tau_cirrus = met.data["air_temperature"].chunks is not None
1330
+
1323
1331
  if "tau_cirrus" not in met.data:
1324
1332
  met.ensure_vars(Cocip.met_variables)
1325
- if (
1326
- compute_tau_cirrus == "auto"
1327
- and met.data["specific_humidity"].chunks is not None
1328
- or isinstance(compute_tau_cirrus, bool)
1329
- and compute_tau_cirrus
1330
- ):
1333
+ if compute_tau_cirrus:
1331
1334
  met = add_tau_cirrus(met)
1332
1335
  else:
1333
1336
  met.ensure_vars(Cocip.processed_met_variables)
1334
1337
 
1335
- # Deal with rad: check shift_radiation_time
1336
1338
  rad.ensure_vars(Cocip.rad_variables)
1337
- if "_pycontrails_modified" in rad["time"].attrs:
1338
- existing_shift = rad["time"].attrs["shift_radiation_time"] # this is a string
1339
- if pd.Timedelta(existing_shift) != shift_radiation_time: # compare timedeltas
1340
- raise ValueError(
1341
- "The time coordinate in MetDataset 'rad' has already been "
1342
- f"scaled by a CoCiP model with 'shift_radiation_time={existing_shift}'. "
1343
- )
1344
- else:
1345
- shift_radiation_time = shift_radiation_time or Cocip.default_params.shift_radiation_time
1346
- rad = _process_rad(rad, shift_radiation_time)
1339
+ rad = _process_rad(rad)
1347
1340
 
1348
1341
  return met, rad
1349
1342
 
@@ -1366,7 +1359,7 @@ def add_tau_cirrus(met: MetDataset) -> MetDataset:
1366
1359
  return met
1367
1360
 
1368
1361
 
1369
- def _process_rad(rad: MetDataset, shift_radiation_time: np.timedelta64) -> MetDataset:
1362
+ def _process_rad(rad: MetDataset) -> MetDataset:
1370
1363
  """Process radiation specific variables for model.
1371
1364
 
1372
1365
  These variables are used to calculate the reflected solar radiation (RSR),
@@ -1379,8 +1372,6 @@ def _process_rad(rad: MetDataset, shift_radiation_time: np.timedelta64) -> MetDa
1379
1372
  ----------
1380
1373
  rad : MetDataset
1381
1374
  Rad single-level data
1382
- shift_radiation_time : np.timedelta64
1383
- Shift the time dimension of radiation data to account for accumulated values.
1384
1375
 
1385
1376
  Returns
1386
1377
  -------
@@ -1392,18 +1383,67 @@ def _process_rad(rad: MetDataset, shift_radiation_time: np.timedelta64) -> MetDa
1392
1383
  - https://www.ecmwf.int/sites/default/files/elibrary/2015/18490-radiation-quantities-ecmwf-model-and-mars.pdf
1393
1384
  - https://confluence.ecmwf.int/pages/viewpage.action?pageId=155337784
1394
1385
  """ # noqa: E501
1395
- logger.debug(f"Shifting radiation time dimension by {shift_radiation_time}")
1396
- if not np.all(np.diff(rad.data["time"]) / 2 == -shift_radiation_time):
1386
+ # If the time coordinate has already been shifted, early return
1387
+ if "shift_radiation_time" in rad["time"].attrs:
1388
+ return rad
1389
+
1390
+ provider = rad.provider_attr
1391
+
1392
+ # Only shift ECMWF data -- exit for anything else
1393
+ # A warning is emitted upstream if the provider is not ECMWF or NCEP
1394
+ if provider != "ECMWF":
1395
+ return rad
1396
+
1397
+ dataset = rad.dataset_attr
1398
+ product = rad.product_attr
1399
+
1400
+ if dataset == "HRES":
1401
+ try:
1402
+ radiation_accumulated = rad.attrs["radiation_accumulated"]
1403
+ except KeyError:
1404
+ msg = (
1405
+ "HRES data must have a boolean 'radiation_accumulated' attribute. "
1406
+ "This attribute is used to determine whether the radiation data "
1407
+ "has been accumulated over the time period. This is the case for "
1408
+ "HRES data taken from a common time of forecast with multiple "
1409
+ "forecast steps. If this is not the case, set the "
1410
+ "'radiation_accumulated' attribute to False."
1411
+ )
1412
+ raise ValueError(msg)
1413
+ if radiation_accumulated:
1414
+ # Keep the original attrs -- we need these later on
1415
+ old_attrs = {k: v.attrs for k, v in rad.data.items()}
1416
+
1417
+ # NOTE: Taking the diff will remove the first time step
1418
+ # This is typically what we want (forecast step 0 is all zeros)
1419
+ # But, if the data has been downselected for a particular Flight / Fleet,
1420
+ # we lose the first time step of the data.
1421
+ rad.data = rad.data.diff("time", label="upper")
1422
+
1423
+ # Add back the original attrs
1424
+ for k, v in rad.data.items():
1425
+ v.attrs = old_attrs[k]
1426
+
1427
+ shift_radiation_time = -np.timedelta64(30, "m")
1428
+
1429
+ elif dataset == "ERA5" and product == "ensemble":
1430
+ shift_radiation_time = -np.timedelta64(90, "m")
1431
+ else:
1432
+ shift_radiation_time = -np.timedelta64(30, "m")
1433
+
1434
+ # Do a final idiot check -- most likely, the time resolution of the data will
1435
+ # agree with the shift_radiation_time. If not, emit a warning. There could be
1436
+ # a false positive here if the data has been downsampled in time.
1437
+ logger.debug("Shifting rad time by %s", shift_radiation_time)
1438
+ rad_time_diff = np.diff(rad.data["time"])
1439
+ if not np.all(rad_time_diff / 2 == -shift_radiation_time):
1397
1440
  warnings.warn(
1398
1441
  f"Shifting radiation time dimension by unexpected interval {shift_radiation_time}. "
1399
- "If working with ERA5 ensemble members, set "
1400
- "`shift_radiation_time=-np.timedelta64(90, 'm')`. Otherwise, the "
1401
- "expected shift is half the time difference consecutive time steps."
1442
+ f"The rad data has metadata indicating it is {product} ECMWF data. "
1443
+ f"This dataset should have time steps of {-2 * shift_radiation_time}."
1402
1444
  )
1403
1445
 
1404
1446
  rad.data = rad.data.assign_coords({"time": rad.data["time"] + shift_radiation_time})
1405
- msg = "Time coordinates adjusted to account for accumulation averaging"
1406
- rad.data["time"].attrs["_pycontrails_modified"] = msg
1407
1447
  rad.data["time"].attrs["shift_radiation_time"] = str(shift_radiation_time)
1408
1448
 
1409
1449
  return rad
@@ -1439,7 +1479,9 @@ def _eval_aircraft_performance(
1439
1479
  if aircraft_performance is None:
1440
1480
  raise ValueError(
1441
1481
  "An AircraftPerformance model parameter is required if the flight "
1442
- f"does not contain the following variables: {aircraft_performance_outputs}"
1482
+ f"does not contain the following variables: {aircraft_performance_outputs}. "
1483
+ "For example, instantiate the Cocip model with "
1484
+ "'Cocip(..., aircraft_performance=PSFlight(...))'."
1443
1485
  )
1444
1486
 
1445
1487
  return aircraft_performance.eval(source=flight, copy_source=False)
@@ -1538,9 +1580,9 @@ def calc_timestep_geometry(contrail: GeoVectorDataset) -> None:
1538
1580
  # Finally, at the next evolution step, the previous waypoint will accrue
1539
1581
  # a nan value after segment_length is recalculated
1540
1582
 
1541
- segment_length[~continuous] = 0
1542
- sin_a[~continuous] = 0
1543
- cos_a[~continuous] = 0
1583
+ segment_length[~continuous] = 0.0
1584
+ sin_a[~continuous] = 0.0
1585
+ cos_a[~continuous] = 0.0
1544
1586
 
1545
1587
  np.nan_to_num(segment_length, copy=False)
1546
1588
  np.nan_to_num(sin_a, copy=False)
@@ -1680,8 +1722,8 @@ def calc_shortwave_radiation(
1680
1722
  Raises
1681
1723
  ------
1682
1724
  ValueError
1683
- If ``rad`` does not contain ``"toa_upward_shortwave_flux"`` or ``"top_net_solar_radiation"``
1684
- variable.
1725
+ If ``rad`` does not contain ``"toa_upward_shortwave_flux"`` or
1726
+ ``"top_net_solar_radiation"`` variable.
1685
1727
 
1686
1728
  Notes
1687
1729
  -----
@@ -1692,30 +1734,36 @@ def calc_shortwave_radiation(
1692
1734
  --------
1693
1735
  :func:`geo.solar_direct_radiation`
1694
1736
  """
1695
-
1696
1737
  if "sdr" in vector and "rsr" in vector:
1697
- return None
1738
+ return
1698
1739
 
1699
- # calculate instantaneous theoretical solar direct radiation based on geo position and time
1700
- longitude = vector["longitude"]
1701
- latitude = vector["latitude"]
1702
- time = vector["time"]
1703
- vector["sdr"] = geo.solar_direct_radiation(longitude, latitude, time, threshold_cos_sza=0.01)
1740
+ try:
1741
+ sdr = vector["sdr"]
1742
+ except KeyError:
1743
+ # calculate instantaneous theoretical solar direct radiation based on geo position and time
1744
+ longitude = vector["longitude"]
1745
+ latitude = vector["latitude"]
1746
+ time = vector["time"]
1747
+ sdr = geo.solar_direct_radiation(longitude, latitude, time, threshold_cos_sza=0.01)
1748
+ vector["sdr"] = sdr
1704
1749
 
1705
1750
  # GFS contains RSR (toa_upward_shortwave_flux) variable directly
1706
- if "toa_upward_shortwave_flux" in rad:
1707
- interpolate_met(rad, vector, "toa_upward_shortwave_flux", "rsr", **interp_kwargs)
1751
+ gfs_key = "toa_upward_shortwave_flux"
1752
+ if gfs_key in rad:
1753
+ interpolate_met(rad, vector, gfs_key, "rsr", **interp_kwargs)
1754
+ return
1755
+
1756
+ ecmwf_key = "top_net_solar_radiation"
1757
+ if ecmwf_key not in rad:
1758
+ msg = f"'rad' data must contain either '{gfs_key}' or '{ecmwf_key}' (ECMWF) variable."
1759
+ raise ValueError(msg)
1708
1760
 
1709
1761
  # ECMWF contains "top_net_solar_radiation" which is SDR - RSR
1710
- elif "top_net_solar_radiation" in rad:
1711
- interpolate_met(rad, vector, "top_net_solar_radiation", **interp_kwargs)
1712
- vector["rsr"] = np.maximum(vector["sdr"] - vector["top_net_solar_radiation"], 0.0)
1762
+ tnsr = interpolate_met(rad, vector, ecmwf_key, **interp_kwargs)
1763
+ tnsr = _rad_accumulation_to_average_instantaneous(rad, ecmwf_key, tnsr)
1764
+ vector.update({ecmwf_key: tnsr})
1713
1765
 
1714
- else:
1715
- raise ValueError(
1716
- "'rad' data must contain either 'toa_upward_shortwave_flux' or "
1717
- "'top_net_solar_radiation' (ECMWF) variable."
1718
- )
1766
+ vector["rsr"] = np.maximum(sdr - tnsr, 0.0)
1719
1767
 
1720
1768
 
1721
1769
  def calc_outgoing_longwave_radiation(
@@ -1747,20 +1795,22 @@ def calc_outgoing_longwave_radiation(
1747
1795
  return None
1748
1796
 
1749
1797
  # GFS contains OLR (toa_upward_longwave_flux) variable directly
1750
- if "toa_upward_longwave_flux" in rad:
1751
- interpolate_met(rad, vector, "toa_upward_longwave_flux", "olr", **interp_kwargs)
1798
+ gfs_key = "toa_upward_longwave_flux"
1799
+ if gfs_key in rad:
1800
+ interpolate_met(rad, vector, gfs_key, "olr", **interp_kwargs)
1752
1801
  return
1753
1802
 
1754
1803
  # ECMWF contains "top_net_thermal_radiation" which is -1 * OLR
1755
- if "top_net_thermal_radiation" in rad:
1756
- interpolate_met(rad, vector, "top_net_thermal_radiation", **interp_kwargs)
1757
- vector["olr"] = np.maximum(-vector["top_net_thermal_radiation"], 0.0)
1758
- return
1804
+ ecmwf_key = "top_net_thermal_radiation"
1805
+ if ecmwf_key not in rad:
1806
+ msg = f"'rad' data must contain either '{gfs_key}' or '{ecmwf_key}' (ECMWF) variable."
1807
+ raise ValueError(msg)
1759
1808
 
1760
- raise ValueError(
1761
- "rad data must contain either 'toa_upward_longwave_flux' "
1762
- "or 'top_net_thermal_radiation' (ECMWF) variable."
1763
- )
1809
+ tntr = interpolate_met(rad, vector, ecmwf_key, **interp_kwargs)
1810
+ tntr = _rad_accumulation_to_average_instantaneous(rad, ecmwf_key, tntr)
1811
+ vector.update({ecmwf_key: tntr})
1812
+
1813
+ vector["olr"] = np.maximum(-tntr, 0.0)
1764
1814
 
1765
1815
 
1766
1816
  def calc_radiative_properties(contrail: GeoVectorDataset, params: dict[str, Any]) -> None:
@@ -1995,8 +2045,7 @@ def calc_contrail_properties(
1995
2045
  contrail.update(dn_dt_agg=dn_dt_agg)
1996
2046
  contrail.update(dn_dt_turb=dn_dt_turb)
1997
2047
  if radiative_heating_effects:
1998
- contrail.update(heat_rate=heat_rate)
1999
- contrail.update(d_heat_rate=d_heat_rate)
2048
+ contrail.update(heat_rate=heat_rate, d_heat_rate=d_heat_rate)
2000
2049
 
2001
2050
 
2002
2051
  def calc_timestep_contrail_evolution(
@@ -2279,6 +2328,57 @@ def calc_timestep_contrail_evolution(
2279
2328
  return final_contrail
2280
2329
 
2281
2330
 
2331
+ def _rad_accumulation_to_average_instantaneous(
2332
+ rad: MetDataset,
2333
+ name: str,
2334
+ arr: npt.NDArray[np.float_],
2335
+ ) -> npt.NDArray[np.float_]:
2336
+ """Convert from radiation accumulation to average instantaneous values.
2337
+
2338
+ .. versionadded:: 0.48.0
2339
+
2340
+ Parameters
2341
+ ----------
2342
+ rad : MetDataset
2343
+ Radiation data
2344
+ name : str
2345
+ Variable name
2346
+ arr : npt.NDArray[np.float_]
2347
+ Array of values already interpolated from ``rad``
2348
+
2349
+ Returns
2350
+ -------
2351
+ npt.NDArray[np.float_]
2352
+ Array of values converted from accumulation to average instantaneous values
2353
+ """
2354
+ mda = rad[name]
2355
+ try:
2356
+ unit = mda.attrs["units"]
2357
+ except KeyError as e:
2358
+ msg = (
2359
+ f"Radiation data contains '{name}' variable "
2360
+ "but units are not specified. Provide units in the "
2361
+ f"rad['{name}'].attrs passed into Cocip."
2362
+ )
2363
+ raise KeyError(msg) from e
2364
+
2365
+ # The unit is already instantaneous
2366
+ if unit == "W m**-2":
2367
+ return arr
2368
+
2369
+ if unit != "J m**-2":
2370
+ msg = f"Unexpected units '{unit}' for '{name}' variable. Expected 'J m**-2' or 'W m**-2'."
2371
+ raise ValueError(msg)
2372
+
2373
+ # Convert from J m**-2 to W m**-2
2374
+ if rad.dataset_attr == "ERA5" and rad.product_attr == "ensemble":
2375
+ n_seconds = 3.0 * 3600.0 # 3 hour interval
2376
+ else:
2377
+ n_seconds = 3600.0 # 1 hour interval
2378
+
2379
+ return arr / n_seconds
2380
+
2381
+
2282
2382
  def _emissions_variables() -> tuple[str, ...]:
2283
2383
  """Return variables required for emissions calculation."""
2284
2384
  return (
@@ -73,10 +73,6 @@ class CocipParams(ModelParams):
73
73
  #: Constant below applies to ECMWF data.
74
74
  effective_vertical_resolution: float = 2000.0
75
75
 
76
- #: Shift the time coordinates of radiation parameters for accumulated values
77
- #: TODO: change this to np.timedelta64(0, "m") when we start using other datasets
78
- shift_radiation_time: np.timedelta64 = -np.timedelta64(30, "m")
79
-
80
76
  #: Smoothing parameters for true airspeed.
81
77
  #: Only used for Flight models.
82
78
  #: Passed directly to :func:`scipy.signal.savgol_filter`.
@@ -54,22 +54,24 @@ def max_downward_displacement(
54
54
  - :cite:`holzapfelProbabilisticTwoPhaseWake2003`
55
55
  - :cite:`schumannContrailCirrusPrediction2012`
56
56
  """
57
- wingspan_arr = np.broadcast_to(wingspan, true_airspeed.shape)
58
- aircraft_mass_arr = np.broadcast_to(aircraft_mass, true_airspeed.shape)
59
-
60
57
  rho_air = thermo.rho_d(air_temperature, air_pressure)
61
58
  n_bv = thermo.brunt_vaisala_frequency(air_pressure, air_temperature, dT_dz)
62
- t_0 = effective_time_scale(wingspan, true_airspeed, aircraft_mass_arr, rho_air)
59
+ t_0 = effective_time_scale(wingspan, true_airspeed, aircraft_mass, rho_air)
63
60
 
64
61
  dz_max_strong = downward_displacement_strongly_stratified(
65
- wingspan, true_airspeed, aircraft_mass_arr, rho_air, n_bv
62
+ wingspan, true_airspeed, aircraft_mass, rho_air, n_bv
66
63
  )
67
64
 
68
65
  is_weakly_stratified = n_bv * t_0 < 0.8
66
+ if isinstance(wingspan, np.ndarray):
67
+ wingspan = wingspan[is_weakly_stratified]
68
+ if isinstance(aircraft_mass, np.ndarray):
69
+ aircraft_mass = aircraft_mass[is_weakly_stratified]
70
+
69
71
  dz_max_weak = downward_displacement_weakly_stratified(
70
- wingspan=wingspan_arr[is_weakly_stratified],
72
+ wingspan=wingspan,
71
73
  true_airspeed=true_airspeed[is_weakly_stratified],
72
- aircraft_mass=aircraft_mass_arr[is_weakly_stratified],
74
+ aircraft_mass=aircraft_mass,
73
75
  rho_air=rho_air[is_weakly_stratified],
74
76
  n_bv=n_bv[is_weakly_stratified],
75
77
  dz_max_strong=dz_max_strong[is_weakly_stratified],
@@ -106,12 +106,8 @@ class CocipGrid(models.Model, cocip_time_handling.CocipTimeHandlingMixin):
106
106
  super().__init__(met, params=params, **params_kwargs)
107
107
  self.validate_time_params()
108
108
 
109
- shift_radiation_time = self.params["shift_radiation_time"]
110
109
  compute_tau_cirrus = self.params["compute_tau_cirrus_in_model_init"]
111
- met, rad = cocip.process_met_datasets(met, rad, compute_tau_cirrus, shift_radiation_time)
112
-
113
- self.met = met
114
- self.rad = rad
110
+ self.met, self.rad = cocip.process_met_datasets(met, rad, compute_tau_cirrus)
115
111
 
116
112
  # Convenience -- only used in `run_interpolators`
117
113
  self.params["_interp_kwargs"] = self.interp_kwargs
@@ -309,12 +305,12 @@ class CocipGrid(models.Model, cocip_time_handling.CocipTimeHandlingMixin):
309
305
  else:
310
306
  dt_integration_str = str(dt_integration.astype("timedelta64[s]"))
311
307
 
308
+ self.transfer_met_source_attrs()
312
309
  attrs: dict[str, Any] = {
313
310
  "description": self.long_name,
314
311
  "max_age": max_age_str,
315
312
  "dt_integration": dt_integration_str,
316
313
  "aircraft_type": self.get_source_param("aircraft_type"),
317
- "met_source": self.met.attrs.get("met_source", "unknown"),
318
314
  "pycontrails_version": pycontrails.__version__,
319
315
  **self.source.attrs, # type: ignore[dict-item]
320
316
  }