Skip to content

Feature/second-order-runge #277

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@
- Remove the optional input parameter `climb_descend_at_end` in `Flight.resample_and_fill`. See the description of the new `_altitude_interpolation` function for the rationale behind this change.
- Remove the `copy` argument from `Fleet.from_seq`. This argument was redundant and not used effectively in the implementation. The `Fleet.from_seq` method always returns a copy of the input sequence.

### Breaking changes

- Remove the `persistent_buffer` parameter from `CocipParams`. Users should instead use the `DryAdvection` model (with the `sedimentation_rate` parameter) to simulate advection past the lifetime of the Cocip evolution routine.

### Features

- Update CoCiP time integration methodology from a first-order Euler approach to a second-order Runge-Kutta scheme. This ensures better numerical stability as the `dt_integration` parameter varies, and brings the pycontrails `Cocip` implementation into better parity with Ulrich Schumann's [initial CoCiP specification](https://py.contrails.org/literature.html#cite-schumannparametricradiativeforcing2012). This new feature can be enabled by passing `second_order_runge=True` to the `Cocip` or `CocipGrid` constructor. The default behavior remains the first-order Euler scheme (this may change in future release).

### Fixes

- Fix the `ERA5` interface when making a pressure-level request with a single pressure level. This change accommodates CDS-Beta server behavior. Previously, a ValueError was raised in this case.
Expand Down Expand Up @@ -452,7 +460,7 @@ The Unterstrasser (2016) parameterization can be used in CoCiP by setting a new
- Add new `MetDataSource.is_single_level` property.
- Add `ecmwf.Divergence` (a subclass of `MetVariable`) for accessing ERA5 divergence data.
- Update the [specific humidity interpolation notebook](https://py.contrails.org/notebooks/specific-humidity-interpolation.html) to use the new `ARCOERA5` interface.
- Adds two parameters to `CoCipParams`, `compute_atr20` and `global_rf_to_atr20_factor`. Setting the former to `True` will add both `global_yearly_mean_rf` and `atr20` to the CoCiP output.
- Add two parameters to `CocipParams`: `compute_atr20` and `global_rf_to_atr20_factor`. Setting the former to `True` will add both `global_yearly_mean_rf` and `atr20` to the CoCiP output.
- Bump minimum pytest version to 8.1 to avoid failures in release workflow.

## v0.49.5
Expand Down
177 changes: 120 additions & 57 deletions pycontrails/models/cocip/cocip.py
Original file line number Diff line number Diff line change
Expand Up @@ -1285,8 +1285,6 @@ def _create_downwash_contrail(self) -> GeoVectorDataset:
contrail["sac"] = self._downwash_flight["sac"]
if not self.params["filter_initially_persistent"]:
contrail["initially_persistent"] = self._downwash_flight["persistent_1"]
if self.params["persistent_buffer"] is not None:
contrail["end_of_life"] = np.full(contrail.size, np.datetime64("NaT", "ns"))

return contrail

Expand Down Expand Up @@ -2176,6 +2174,8 @@ def calc_contrail_properties(
- "tau_contrail"
- "dn_dt_agg"
- "dn_dt_turb"
- "heat_rate" if radiative_heating_effects is True
- "d_heat_rate" if radiative_heating_effects is True

Parameters
----------
Expand Down Expand Up @@ -2316,18 +2316,16 @@ def calc_contrail_properties(
contrail.update(heat_rate=heat_rate, d_heat_rate=d_heat_rate)


def calc_timestep_contrail_evolution(
def time_integration_runge_kutta(
met: MetDataset,
rad: MetDataset,
contrail_1: GeoVectorDataset,
contrail_12: GeoVectorDataset,
time_2: np.datetime64,
params: dict[str, Any],
**interp_kwargs: Any,
) -> GeoVectorDataset:
"""Calculate the contrail evolution across timestep (t1 -> t2).

Note the variable suffix "_1" is used to reference the current time
and the suffix "_2" is used to refer to the time at the next timestep.
"""Integrate contrail properties from t1 to t2 using the second-order Runge-Kutta scheme.

Parameters
----------
Expand All @@ -2337,6 +2335,8 @@ def calc_timestep_contrail_evolution(
Radiation data
contrail_1 : GeoVectorDataset
Contrail waypoints at current timestep (1)
contrail_12 : GeoVectorDataset
Contrail waypoints at next timestep (2) with intermediate solutions, see `Notes`
time_2 : np.datetime64
Time at the end of the evolution step (2)
params : dict[str, Any]
Expand All @@ -2348,62 +2348,80 @@ def calc_timestep_contrail_evolution(
-------
GeoVectorDataset
The contrail evolved to ``time_2``.
"""

# get lat/lon for current timestep (t1)
Notes
-----
- Suffix "_1" is used to reference the current time,
- Suffix "_2" is used to refer to the time at the next timestep, and
- Suffix "_12" is used to refer to the intermediate step in the second-order Runge-Kutta scheme.
- This function becomes a first-order Euler scheme if `contrail_12` is set to `contrail_1`

In a personal communication with Ulrich Schumann (6-August-2024), it was clarified that the
weightings for the advected contrail location (X, Y, p) are set as t1 = 0.5 and t2 = 0.5, while
the evolved contrail properties are now weighted by t1 = 0 and t2 = 1,instead of t1 = 0.5 and
t2 = 0.5, which was stated in Schumann (2012), because these contrail properties are evolving
exponentially.
"""
# Retrieve `contrail_1` location
longitude_1 = contrail_1["longitude"]
latitude_1 = contrail_1["latitude"]
level_1 = contrail_1.level
time_1 = contrail_1["time"]

# get contrail_1 geometry
# Retrieve `contrail_1` geometry
segment_length_1 = contrail_1["segment_length"]
width_1 = contrail_1["width"]
depth_1 = contrail_1["depth"]

# get required met values for evolution calculations
# Retrieve met values at `contrail_1` for evolution calculations
q_sat_1 = contrail_1["q_sat"]
rho_air_1 = contrail_1["rho_air"]
u_wind_1 = contrail_1["u_wind"]
v_wind_1 = contrail_1["v_wind"]

specific_humidity_1 = contrail_1["specific_humidity"]
vertical_velocity_1 = contrail_1["vertical_velocity"]
iwc_1 = contrail_1["iwc"]

# get required contrail_1 properties
# Retrieve absolute `contrail_1` properties
sigma_yz_1 = contrail_1["sigma_yz"]
dsn_dz_1 = contrail_1["dsn_dz"]
terminal_fall_speed_1 = contrail_1["terminal_fall_speed"]
diffuse_h_1 = contrail_1["diffuse_h"]
diffuse_v_1 = contrail_1["diffuse_v"]
plume_mass_per_m_1 = contrail_1["plume_mass_per_m"]
dn_dt_agg_1 = contrail_1["dn_dt_agg"]
dn_dt_turb_1 = contrail_1["dn_dt_turb"]
n_ice_per_m_1 = contrail_1["n_ice_per_m"]

# get contrail_1 radiative properties
rf_net_1 = contrail_1["rf_net"]
# Retrieve met values at `contrail_12` for advection calculations
u_wind_12 = contrail_12["u_wind"]
v_wind_12 = contrail_12["v_wind"]
vertical_velocity_12 = contrail_12["vertical_velocity"]

# Retrieve `contrail_12` properties related to rate of change calculations
dsn_dz_12 = contrail_12["dsn_dz"]
terminal_fall_speed_12 = contrail_12["terminal_fall_speed"]
diffuse_h_12 = contrail_12["diffuse_h"]
diffuse_v_12 = contrail_12["diffuse_v"]
dn_dt_agg_12 = contrail_12["dn_dt_agg"]
dn_dt_turb_12 = contrail_12["dn_dt_turb"]

# initialize new timestep with evolved coordinates
# assume waypoints are the same to start
waypoint_2 = contrail_1["waypoint"]
formation_time_2 = contrail_1["formation_time"]
formation_time = contrail_1["formation_time"]
time_2_array = np.full_like(time_1, time_2)
dt = time_2_array - time_1

# get new contrail location & segment properties after t_step
longitude_2, latitude_2 = geo.advect_horizontal(longitude_1, latitude_1, u_wind_1, v_wind_1, dt)
level_2 = geo.advect_level(level_1, vertical_velocity_1, rho_air_1, terminal_fall_speed_1, dt)
u_wind = 0.5 * (u_wind_1 + u_wind_12)
v_wind = 0.5 * (v_wind_1 + v_wind_12)
vertical_velocity = 0.5 * (vertical_velocity_1 + vertical_velocity_12)
longitude_2, latitude_2 = geo.advect_horizontal(longitude_1, latitude_1, u_wind, v_wind, dt)
level_2 = geo.advect_level(level_1, vertical_velocity, rho_air_1, terminal_fall_speed_12, dt)
altitude_2 = units.pl_to_m(level_2)

contrail_2 = GeoVectorDataset._from_fastpath(
{
"waypoint": waypoint_2,
"flight_id": contrail_1["flight_id"],
"formation_time": formation_time_2,
"formation_time": formation_time,
"time": time_2_array,
"age": time_2_array - formation_time_2,
"age": time_2_array - formation_time,
"longitude": longitude_2,
"latitude": latitude_2,
"altitude": altitude_2,
Expand All @@ -2420,25 +2438,26 @@ def calc_timestep_contrail_evolution(
# Update cumulative radiative heating energy absorbed by the contrail
# This will always be zero if radiative_heating_effects is not activated in cocip_params
if params["radiative_heating_effects"]:
dt_sec = dt / np.timedelta64(1, "s")
heat_rate_1 = contrail_1["heat_rate"]
# Retrieve properties
heat_rate_12 = contrail_12["heat_rate"]
cumul_heat = contrail_1["cumul_heat"]
cumul_heat += heat_rate_1 * dt_sec
d_heat_rate_12 = contrail_12["d_heat_rate"]
cumul_differential_heat = contrail_1["cumul_differential_heat"]

# Calculate cumulative heat
dt_sec = dt / np.timedelta64(1, "s")
cumul_heat = cumul_heat + heat_rate_12 * dt_sec # don't use += to avoid mutating contrail_1
cumul_heat.clip(max=1.5, out=cumul_heat) # Constrain additional heat to 1.5 K as precaution
contrail_2["cumul_heat"] = cumul_heat

d_heat_rate_1 = contrail_1["d_heat_rate"]
cumul_differential_heat = contrail_1["cumul_differential_heat"]
cumul_differential_heat += -d_heat_rate_1 * dt_sec
cumul_differential_heat = cumul_differential_heat - d_heat_rate_12 * dt_sec # don't use -=
contrail_2["cumul_differential_heat"] = cumul_differential_heat

# Attach a few more artifacts for disabled filtering
if not params["filter_sac"]:
contrail_2["sac"] = contrail_1["sac"]
if not params["filter_initially_persistent"]:
contrail_2["initially_persistent"] = contrail_1["initially_persistent"]
if params["persistent_buffer"] is not None:
contrail_2["end_of_life"] = contrail_1["end_of_life"]

# calculate initial contrail properties for the next timestep
calc_continuous(contrail_2)
Expand All @@ -2453,9 +2472,9 @@ def calc_timestep_contrail_evolution(
width_1,
depth_1,
sigma_yz_1,
dsn_dz_1,
diffuse_h_1,
diffuse_v_1,
dsn_dz_12,
diffuse_h_12,
diffuse_v_12,
seg_ratio_2,
dt,
max_depth=params["max_depth"],
Expand Down Expand Up @@ -2508,7 +2527,7 @@ def calc_timestep_contrail_evolution(
plume_mass_per_m_2,
)
n_ice_per_m_2 = contrail_properties.new_ice_particle_number(
n_ice_per_m_1, dn_dt_agg_1, dn_dt_turb_1, seg_ratio_2, dt
n_ice_per_m_1, dn_dt_agg_12, dn_dt_turb_12, seg_ratio_2, dt
)

contrail_2["n_ice_per_m"] = n_ice_per_m_2
Expand All @@ -2528,10 +2547,65 @@ def calc_timestep_contrail_evolution(
params["radiative_heating_effects"],
)
calc_radiative_properties(contrail_2, params)
return contrail_2


def calc_timestep_contrail_evolution(
met: MetDataset,
rad: MetDataset,
contrail_1: GeoVectorDataset,
time_2: np.datetime64,
params: dict[str, Any],
**interp_kwargs: Any,
) -> GeoVectorDataset:
"""Calculate the contrail evolution across timestep (t1 -> t2).

Note the variable suffix "_1" is used to reference the current time
and the suffix "_2" is used to refer to the time at the next timestep.

Parameters
----------
met : MetDataset
Meteorology data
rad : MetDataset
Radiation data
contrail_1 : GeoVectorDataset
Contrail waypoints at current timestep (1)
time_2 : np.datetime64
Time at the end of the evolution step (2)
params : dict[str, Any]
Model parameters
**interp_kwargs : Any
Interpolation keyword arguments

Returns
-------
GeoVectorDataset
The contrail evolved to ``time_2``.

Notes
-----
Subscript ``12`` denotes the intermediate step in the second-order Runge-Kutta scheme.
For further details, see :func:`time_integration_runge_kutta`.
"""
# First-order Euler method
contrail_12 = contrail_1 # Set intermediate values to the initial values
contrail_2 = time_integration_runge_kutta(
met, rad, contrail_1, contrail_12, time_2, params, **interp_kwargs
)

# Second-order Runge-Kutta scheme
if params["second_order_runge"]:
# contrail_2 calculated in first-order Euler method is now used as intermediate values
contrail_12 = contrail_2
contrail_2 = time_integration_runge_kutta(
met, rad, contrail_1, contrail_12, time_2, params, **interp_kwargs
)

# get properties to measure persistence
latitude_2 = contrail_2["latitude"]
altitude_2 = contrail_2.altitude
segment_length_2 = contrail_2["segment_length"]
age_2 = contrail_2["age"]
tau_contrail_2 = contrail_2["tau_contrail"]
n_ice_per_vol_2 = contrail_2["n_ice_per_vol"]
Expand All @@ -2548,7 +2622,15 @@ def calc_timestep_contrail_evolution(
)

# Get energy forcing by looking forward to the next time step radiative forcing
width_1 = contrail_1["width"]
width_2 = contrail_2["width"]
rf_net_1 = contrail_1["rf_net"]
rf_net_2 = contrail_2["rf_net"]

time_1 = contrail_1["time"]
time_2_array = np.full_like(time_1, time_2)
dt = time_2_array - time_1

energy_forcing_2 = contrail_properties.energy_forcing(
rf_net_t1=rf_net_1,
rf_net_t2=rf_net_2,
Expand Down Expand Up @@ -2583,25 +2665,6 @@ def calc_timestep_contrail_evolution(
persistent_2.size,
)

if (buff := params["persistent_buffer"]) is not None:
# Here mask gets waypoints that are just now losing persistence
mask = (~persistent_2) & np.isnat(contrail_2["end_of_life"])
contrail_2["end_of_life"][mask] = time_2

# Keep waypoints that are still persistent, which is determined by filt2
# And waypoints within the persistent buffer, which is determined by filt1
# So we only drop waypoints that are outside of the persistent buffer
filt1 = contrail_2["time"] - contrail_2["end_of_life"] < buff
filt2 = np.isnat(contrail_2["end_of_life"])
filt = filt1 | filt2
logger.debug(
"Fraction of waypoints surviving with buffer %s: %s / %s",
buff,
filt.sum(),
filt.size,
)
return contrail_2.filter(filt)

# filter persistent contrails
final_contrail = contrail_2.filter(persistent_2)

Expand Down
19 changes: 12 additions & 7 deletions pycontrails/models/cocip/cocip_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ class CocipParams(AdvectionBuffers):
# Implementation parameters
# -------------------------

#: Determines whether :meth:`Cocip.process_emissions` runs on model :meth:`Cocip.eval`
#: Determines whether :meth:`Cocip._process_emissions` runs on model :meth:`Cocip.eval`
#: Set to ``False`` when input Flight includes emissions data.
process_emissions: bool = True

Expand Down Expand Up @@ -132,12 +132,6 @@ class CocipParams(AdvectionBuffers):
#: to allow for false negative calibration and model uncertainty studies.
filter_initially_persistent: bool = True

#: Continue evolving contrail waypoints ``persistent_buffer`` beyond
#: end of contrail life.
#: Passing in a non-default value is unusual, but is included
#: to allow for false negative calibration and model uncertainty studies.
persistent_buffer: np.timedelta64 | None = None

# -------
# Outputs
# -------
Expand All @@ -152,16 +146,27 @@ class CocipParams(AdvectionBuffers):
#: These are not standard CoCiP outputs but based on the derivation used
#: in the first supplement to :cite:`yinPredictingClimateImpact2023`. ATR20 is defined
#: as the average temperature response over a 20 year horizon.
#:
#: .. versionadded:: 0.50.0
compute_atr20: bool = False

#: Constant factor used to convert global- and year-mean RF, [:math:`W m^{-2}`],
#: to ATR20, [:math:`K`], given by :cite:`yinPredictingClimateImpact2023`.
#:
#: .. versionadded:: 0.50.0
global_rf_to_atr20_factor: float = 0.0151

# ----------------
# Model parameters
# ----------------

#: Perform CoCiP time integration using the second-order Runge-Kutta scheme.
#: If False, a first-order Euler method is used instead. Note that the first-order Euler
#: method was used exclusively in earlier versions of pycontrails (until v0.54.3).
#:
#: .. versionadded:: 0.54.4
second_order_runge: bool = False

#: Initial wake vortex depth scaling factor.
#: This factor scales max contrail downward displacement after the wake vortex phase
#: to set the initial contrail depth.
Expand Down
Loading