diff --git a/CHANGELOG.md b/CHANGELOG.md index fc296b39b..4fb30be67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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 diff --git a/pycontrails/models/cocip/cocip.py b/pycontrails/models/cocip/cocip.py index 6bf880f89..6a1b16c61 100644 --- a/pycontrails/models/cocip/cocip.py +++ b/pycontrails/models/cocip/cocip.py @@ -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 @@ -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 ---------- @@ -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 ---------- @@ -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] @@ -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, @@ -2420,16 +2438,19 @@ 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 @@ -2437,8 +2458,6 @@ def calc_timestep_contrail_evolution( 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) @@ -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"], @@ -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 @@ -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"] @@ -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, @@ -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) diff --git a/pycontrails/models/cocip/cocip_params.py b/pycontrails/models/cocip/cocip_params.py index a7ed98fc3..11ae91afe 100644 --- a/pycontrails/models/cocip/cocip_params.py +++ b/pycontrails/models/cocip/cocip_params.py @@ -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 @@ -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 # ------- @@ -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. diff --git a/pycontrails/models/cocipgrid/cocip_grid.py b/pycontrails/models/cocipgrid/cocip_grid.py index 86826ab7a..ec734efb3 100644 --- a/pycontrails/models/cocipgrid/cocip_grid.py +++ b/pycontrails/models/cocipgrid/cocip_grid.py @@ -1093,21 +1093,38 @@ def _evolve_vector( dt_head = dt - half_head_tail_dt # type: ignore[operator] dt_tail = dt + half_head_tail_dt # type: ignore[operator] - # After advection, out has time t - out = advect(vector, dt, dt_head, dt_tail) # type: ignore[arg-type] + vector_1 = vector + vector_12 = vector + vector_2 = advect(vector_1, vector_12, dt, dt_head, dt_tail) # type: ignore[arg-type] - out = run_interpolators( - out, + vector_2 = run_interpolators( + vector_2, met, rad, dz_m=params["dz_m"], humidity_scaling=params["humidity_scaling"], **params["_interp_kwargs"], ) - out = calc_evolve_one_step(vector, out, params) - ef_summary = out.select(("index", "age", "ef"), copy=False) - return out, ef_summary + vector_2, persistent = calc_evolve_one_step(vector, vector_12, vector_2, params) + + if params["second_order_runge"]: + vector_12 = vector_2 + vector_2 = advect(vector_1, vector_12, dt, dt_head, dt_tail) # type: ignore[arg-type] + + vector_2 = run_interpolators( + vector_2, + met, + rad, + dz_m=params["dz_m"], + humidity_scaling=params["humidity_scaling"], + **params["_interp_kwargs"], + ) + vector_2, persistent = calc_evolve_one_step(vector, vector_12, vector_2, params) + + vector_2 = vector_2.filter(persistent) + ef_summary = vector_2.select(("index", "age", "ef"), copy=False) + return vector_2, ef_summary def _run_downwash( @@ -1557,144 +1574,152 @@ def find_initial_persistent_contrails( def calc_evolve_one_step( - curr_contrail: GeoVectorDataset, - next_contrail: GeoVectorDataset, + contrail_1: GeoVectorDataset, + contrail_12: GeoVectorDataset, + contrail_2: GeoVectorDataset, params: dict[str, Any], -) -> GeoVectorDataset: - """Calculate contrail properties of ``next_contrail``. +) -> tuple[GeoVectorDataset, npt.NDArray[np.bool_]]: + """Calculate contrail properties of ``contrail_2``. - This function attaches additional variables to ``next_contrail``, then - filters by :func:`contrail_properties.contrail_persistent`. + This function attaches additional variables to ``contrail_2`` (it is + freely modified in place). The function also returns a boolean array of + filters produced by :func:`contrail_properties.contrail_persistent`. + + This implementation parallels + :func:pycontrails.models.cocip.cocip.time_integration_runge_kutta`. In particular, + the same conventions and variable names are used when possible. Parameters ---------- - curr_contrail : GeoVectorDataset + contrail_1 : GeoVectorDataset Existing contrail - next_contrail : GeoVectorDataset + contrail_12 : GeoVectorDataset + Intermediate contrail used for the Runge-Kutta method + contrail_2 : GeoVectorDataset Result of advecting existing contrail already interpolated against CoCiP met data params : dict[str, Any] CoCiP model parameters. See :class:`CocipGrid`. Returns ------- - GeoVectorDataset - Parameter ``next_contrail`` filtered by persistence. + tuple[GeoVectorDataset, npt.NDArray[np.bool_]] + A tuple with ``contrail_2`` and a boolean array indicating which + points persist after the evolution step. """ calc_wind_shear( - next_contrail, + contrail_2, is_downwash=False, dz_m=params["dz_m"], dsn_dz_factor=params["dsn_dz_factor"], ) - calc_thermal_properties(next_contrail) - - iwc_t1 = curr_contrail["iwc"] - specific_humidity_t1 = curr_contrail["specific_humidity"] - specific_humidity_t2 = next_contrail["specific_humidity"] - q_sat_t1 = curr_contrail["q_sat"] - q_sat_t2 = next_contrail["q_sat"] - plume_mass_per_m_t1 = curr_contrail["plume_mass_per_m"] - width_t1 = curr_contrail["width"] - depth_t1 = curr_contrail["depth"] - sigma_yz_t1 = curr_contrail["sigma_yz"] - dsn_dz_t1 = curr_contrail["dsn_dz"] - diffuse_h_t1 = curr_contrail["diffuse_h"] - diffuse_v_t1 = curr_contrail["diffuse_v"] + calc_thermal_properties(contrail_2) + + iwc_1 = contrail_1["iwc"] + specific_humidity_1 = contrail_1["specific_humidity"] + specific_humidity_2 = contrail_2["specific_humidity"] + q_sat_1 = contrail_1["q_sat"] + q_sat_2 = contrail_2["q_sat"] + plume_mass_per_m_1 = contrail_1["plume_mass_per_m"] + width_1 = contrail_1["width"] + depth_1 = contrail_1["depth"] + sigma_yz_1 = contrail_1["sigma_yz"] + + dsn_dz_12 = contrail_12["dsn_dz"] + diffuse_h_12 = contrail_12["diffuse_h"] + diffuse_v_12 = contrail_12["diffuse_v"] # Segment-free mode logic - segment_length_t2: np.ndarray | float - seg_ratio_t12: np.ndarray | float - if _is_segment_free_mode(curr_contrail): - segment_length_t2 = 1.0 - seg_ratio_t12 = 1.0 + segment_length_2: np.ndarray | float + seg_ratio_12: np.ndarray | float + if _is_segment_free_mode(contrail_1): + segment_length_2 = 1.0 + seg_ratio_12 = 1.0 else: - segment_length_t1 = curr_contrail["segment_length"] - segment_length_t2 = next_contrail["segment_length"] - seg_ratio_t12 = contrail_properties.segment_length_ratio( - segment_length_t1, segment_length_t2 - ) - - dt = next_contrail["time"] - curr_contrail["time"] - - sigma_yy_t2, sigma_zz_t2, sigma_yz_t2 = contrail_properties.plume_temporal_evolution( - width_t1=width_t1, - depth_t1=depth_t1, - sigma_yz_t1=sigma_yz_t1, - dsn_dz_t1=dsn_dz_t1, - diffuse_h_t1=diffuse_h_t1, - diffuse_v_t1=diffuse_v_t1, - seg_ratio=seg_ratio_t12, + segment_length_1 = contrail_1["segment_length"] + segment_length_2 = contrail_2["segment_length"] + seg_ratio_12 = contrail_properties.segment_length_ratio(segment_length_1, segment_length_2) + + dt = contrail_2["time"] - contrail_1["time"] + + sigma_yy_2, sigma_zz_2, sigma_yz_2 = contrail_properties.plume_temporal_evolution( + width_t1=width_1, + depth_t1=depth_1, + sigma_yz_t1=sigma_yz_1, + dsn_dz_t1=dsn_dz_12, + diffuse_h_t1=diffuse_h_12, + diffuse_v_t1=diffuse_v_12, + seg_ratio=seg_ratio_12, dt=dt, max_depth=params["max_depth"], ) - width_t2, depth_t2 = contrail_properties.new_contrail_dimensions(sigma_yy_t2, sigma_zz_t2) - next_contrail["sigma_yz"] = sigma_yz_t2 - next_contrail["width"] = width_t2 - next_contrail["depth"] = depth_t2 + width_2, depth_2 = contrail_properties.new_contrail_dimensions(sigma_yy_2, sigma_zz_2) + contrail_2["sigma_yz"] = sigma_yz_2 + contrail_2["width"] = width_2 + contrail_2["depth"] = depth_2 - area_eff_t2 = contrail_properties.new_effective_area_from_sigma( - sigma_yy=sigma_yy_t2, - sigma_zz=sigma_zz_t2, - sigma_yz=sigma_yz_t2, + area_eff_2 = contrail_properties.new_effective_area_from_sigma( + sigma_yy=sigma_yy_2, + sigma_zz=sigma_zz_2, + sigma_yz=sigma_yz_2, ) - rho_air_t2 = next_contrail["rho_air"] - plume_mass_per_m_t2 = contrail_properties.plume_mass_per_distance(area_eff_t2, rho_air_t2) - iwc_t2 = contrail_properties.new_ice_water_content( - iwc_t1=iwc_t1, - q_t1=specific_humidity_t1, - q_t2=specific_humidity_t2, - q_sat_t1=q_sat_t1, - q_sat_t2=q_sat_t2, - mass_plume_t1=plume_mass_per_m_t1, - mass_plume_t2=plume_mass_per_m_t2, + rho_air_2 = contrail_2["rho_air"] + plume_mass_per_m_2 = contrail_properties.plume_mass_per_distance(area_eff_2, rho_air_2) + iwc_2 = contrail_properties.new_ice_water_content( + iwc_t1=iwc_1, + q_t1=specific_humidity_1, + q_t2=specific_humidity_2, + q_sat_t1=q_sat_1, + q_sat_t2=q_sat_2, + mass_plume_t1=plume_mass_per_m_1, + mass_plume_t2=plume_mass_per_m_2, ) - next_contrail["iwc"] = iwc_t2 + contrail_2["iwc"] = iwc_2 - n_ice_per_m_t1 = curr_contrail["n_ice_per_m"] - dn_dt_agg = curr_contrail["dn_dt_agg"] - dn_dt_turb = curr_contrail["dn_dt_turb"] + n_ice_per_m_t1 = contrail_1["n_ice_per_m"] + dn_dt_agg_12 = contrail_12["dn_dt_agg"] + dn_dt_turb_12 = contrail_12["dn_dt_turb"] n_ice_per_m_t2 = contrail_properties.new_ice_particle_number( n_ice_per_m_t1=n_ice_per_m_t1, - dn_dt_agg=dn_dt_agg, - dn_dt_turb=dn_dt_turb, - seg_ratio=seg_ratio_t12, + dn_dt_agg=dn_dt_agg_12, + dn_dt_turb=dn_dt_turb_12, + seg_ratio=seg_ratio_12, dt=dt, ) - next_contrail["n_ice_per_m"] = n_ice_per_m_t2 + contrail_2["n_ice_per_m"] = n_ice_per_m_t2 cocip.calc_contrail_properties( - next_contrail, + contrail_2, params["effective_vertical_resolution"], params["wind_shear_enhancement_exponent"], params["sedimentation_impact_factor"], radiative_heating_effects=False, # Not yet supported in CocipGrid ) - cocip.calc_radiative_properties(next_contrail, params) + cocip.calc_radiative_properties(contrail_2, params) - rf_net_t1 = curr_contrail["rf_net"] - rf_net_t2 = next_contrail["rf_net"] + rf_net_t1 = contrail_1["rf_net"] + rf_net_t2 = contrail_2["rf_net"] ef = contrail_properties.energy_forcing( rf_net_t1=rf_net_t1, rf_net_t2=rf_net_t2, - width_t1=width_t1, - width_t2=width_t2, - seg_length_t2=segment_length_t2, + width_t1=width_1, + width_t2=width_2, + seg_length_t2=segment_length_2, dt=dt, ) # NOTE: This will get masked below if `persistent` is False # That is, we are taking a right Riemann sum of a decreasing function, so we are # underestimating the truth. With dt small enough, this is fine. - next_contrail["ef"] = ef + contrail_2["ef"] = ef - # NOTE: Only dealing with `next_contrail` here - latitude = next_contrail["latitude"] - altitude = next_contrail["altitude"] - tau_contrail = next_contrail["tau_contrail"] - n_ice_per_vol = next_contrail["n_ice_per_vol"] - age = next_contrail["age"] + # NOTE: Only dealing with `contrail_2` here + latitude = contrail_2["latitude"] + altitude = contrail_2["altitude"] + tau_contrail = contrail_2["tau_contrail"] + n_ice_per_vol = contrail_2["n_ice_per_vol"] + age = contrail_2["age"] # Both tau_contrail and n_ice_per_vol could have nan values # These are mostly due to out of bounds interpolation @@ -1706,23 +1731,14 @@ def calc_evolve_one_step( persistent = contrail_properties.contrail_persistent( latitude=latitude, altitude=altitude, - segment_length=segment_length_t2, # type: ignore[arg-type] + segment_length=segment_length_2, # type: ignore[arg-type] age=age, tau_contrail=tau_contrail, n_ice_per_m3=n_ice_per_vol, params=params, ) - # Filter by persistent - logger.debug( - "Fraction of grid points surviving: %s / %s", - np.sum(persistent), - next_contrail.size, - ) - if params["persistent_buffer"] is not None: - # See Cocip implementation if we want to support this - raise NotImplementedError - return next_contrail.filter(persistent) + return contrail_2, persistent def calc_emissions(vector: GeoVectorDataset, params: dict[str, Any]) -> None: @@ -1934,25 +1950,32 @@ def calc_thermal_properties(contrail: GeoVectorDataset) -> None: def advect( - contrail: GeoVectorDataset, + contrail_1: GeoVectorDataset, + contrail_12: GeoVectorDataset, dt: np.timedelta64 | npt.NDArray[np.timedelta64], dt_head: np.timedelta64 | None, dt_tail: np.timedelta64 | None, ) -> GeoVectorDataset: """Form new contrail by advecting existing contrail. - Parameter ``contrail`` is not modified. - .. versionchanged:: 0.25.0 - The ``dt_head`` and ``dt_tail`` parameters are no longer optional. + The ``dt_head`` and ``dt_tail`` parameters are required. Set these to ``dt`` to evolve the contrail uniformly over a constant time. Set to None for segment-free mode. + .. versionchanged:: 0.54.4 + + Rename ``contrail`` parameter to ``contrail_1``. + Add ``contrail_12`` parameter. + Parameters ---------- - contrail : GeoVectorDataset + contrail_1 : GeoVectorDataset Grid points already interpolated against wind data + contrail_12 : GeoVectorDataset + Grid points with intermediate wind data. + See :func:`pycontrails.models.cocip.cocip.time_integration_runge_kutta`. dt : np.timedelta64 | npt.NDArray[np.timedelta64] Time step for advection dt_head : np.timedelta64 | None @@ -1980,60 +2003,82 @@ def advect( - "segment_length" (only if `is_segment_free=False`) - "head_tail_dt" (only if `is_segment_free=False`) """ - longitude = contrail["longitude"] - latitude = contrail["latitude"] - level = contrail["level"] - time = contrail["time"] - formation_time = contrail["formation_time"] - age = contrail["age"] - u_wind = contrail["eastward_wind"] - v_wind = contrail["northward_wind"] - vertical_velocity = contrail["lagrangian_tendency_of_air_pressure"] - rho_air = contrail["rho_air"] - terminal_fall_speed = contrail["terminal_fall_speed"] - - # Using the _t2 convention for post-advection data - index_t2 = contrail["index"] - time_t2 = time + dt - age_t2 = age + dt - - longitude_t2, latitude_t2 = geo.advect_horizontal( - longitude=longitude, - latitude=latitude, + longitude_1 = contrail_1["longitude"] + latitude_1 = contrail_1["latitude"] + level_1 = contrail_1["level"] + time_1 = contrail_1["time"] + age_1 = contrail_1["age"] + u_wind_1 = contrail_1["eastward_wind"] + v_wind_1 = contrail_1["northward_wind"] + vertical_velocity_1 = contrail_1["lagrangian_tendency_of_air_pressure"] + rho_air_1 = contrail_1["rho_air"] + + u_wind_12 = contrail_12["eastward_wind"] + v_wind_12 = contrail_12["northward_wind"] + vertical_velocity_12 = contrail_12["lagrangian_tendency_of_air_pressure"] + terminal_fall_speed_12 = contrail_12["terminal_fall_speed"] + + 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=longitude_1, + latitude=latitude_1, u_wind=u_wind, v_wind=v_wind, dt=dt, ) - level_t2 = geo.advect_level(level, vertical_velocity, rho_air, terminal_fall_speed, dt) - altitude_t2 = units.pl_to_m(level_t2) + level_2 = geo.advect_level( + level=level_1, + vertical_velocity=vertical_velocity, + rho_air=rho_air_1, + terminal_fall_speed=terminal_fall_speed_12, + dt=dt, + ) + altitude_2 = units.pl_to_m(level_2) + + # Using the _2 convention for post-advection data + index = contrail_1["index"] + formation_time = contrail_1["formation_time"] + time_2 = time_1 + dt + age_2 = age_1 + dt data = { - "index": index_t2, - "longitude": longitude_t2, - "latitude": latitude_t2, - "level": level_t2, - "air_pressure": 100.0 * level_t2, - "altitude": altitude_t2, - "time": time_t2, + "index": index, + "longitude": longitude_2, + "latitude": latitude_2, + "level": level_2, + "air_pressure": 100.0 * level_2, + "altitude": altitude_2, + "time": time_2, "formation_time": formation_time, - "age": age_t2, - **_get_uncertainty_params(contrail), + "age": age_2, + **_get_uncertainty_params(contrail_1), } if dt_tail is None or dt_head is None: - assert _is_segment_free_mode(contrail) + assert _is_segment_free_mode(contrail_1) assert dt_tail is None assert dt_head is None - return GeoVectorDataset._from_fastpath(data, attrs=contrail.attrs).copy() - - longitude_head = contrail["longitude_head"] - latitude_head = contrail["latitude_head"] - longitude_tail = contrail["longitude_tail"] - latitude_tail = contrail["latitude_tail"] - u_wind_head = contrail["eastward_wind_head"] - v_wind_head = contrail["northward_wind_head"] - u_wind_tail = contrail["eastward_wind_tail"] - v_wind_tail = contrail["northward_wind_tail"] + return GeoVectorDataset._from_fastpath(data, attrs=contrail_1.attrs).copy() + + longitude_head = contrail_1["longitude_head"] + latitude_head = contrail_1["latitude_head"] + longitude_tail = contrail_1["longitude_tail"] + latitude_tail = contrail_1["latitude_tail"] + u_wind_head_1 = contrail_1["eastward_wind_head"] + v_wind_head_1 = contrail_1["northward_wind_head"] + u_wind_tail_1 = contrail_1["eastward_wind_tail"] + v_wind_tail_1 = contrail_1["northward_wind_tail"] + u_wind_head_12 = contrail_12["eastward_wind_head"] + v_wind_head_12 = contrail_12["northward_wind_head"] + u_wind_tail_12 = contrail_12["eastward_wind_tail"] + v_wind_tail_12 = contrail_12["northward_wind_tail"] + + u_wind_head = 0.5 * (u_wind_head_1 + u_wind_head_12) + v_wind_head = 0.5 * (v_wind_head_1 + v_wind_head_12) + u_wind_tail = 0.5 * (u_wind_tail_1 + u_wind_tail_12) + v_wind_tail = 0.5 * (v_wind_tail_1 + v_wind_tail_12) longitude_head_t2, latitude_head_t2 = geo.advect_horizontal( longitude=longitude_head, @@ -2057,7 +2102,7 @@ def advect( lats1=latitude_tail_t2, ) - head_tail_dt_t2 = np.full(contrail.size, np.timedelta64(0, "ns")) # trivial + head_tail_dt_t2 = np.full(contrail_1.size, np.timedelta64(0, "ns")) # trivial data["longitude_head"] = longitude_head_t2 data["latitude_head"] = latitude_head_t2 @@ -2066,7 +2111,7 @@ def advect( data["segment_length"] = segment_length_t2 data["head_tail_dt"] = head_tail_dt_t2 - return GeoVectorDataset._from_fastpath(data, attrs=contrail.attrs).copy() + return GeoVectorDataset._from_fastpath(data, attrs=contrail_1.attrs).copy() def _aggregate_ef_summary(vector_list: list[VectorDataset]) -> VectorDataset | None: diff --git a/tests/unit/test_cocip.py b/tests/unit/test_cocip.py index f9eb9e487..f774e0f77 100644 --- a/tests/unit/test_cocip.py +++ b/tests/unit/test_cocip.py @@ -1558,7 +1558,6 @@ def test_cocip_filtering(fl: Flight, met: MetDataset, rad: MetDataset): Check model parameters: - filter_sac - filter_initially_persistent - - persistent_buffer """ # Boost air temperature for more interesting SAC dynamics met.data["air_temperature"] += 10 @@ -1585,26 +1584,6 @@ def test_cocip_filtering(fl: Flight, met: MetDataset, rad: MetDataset): assert cocip._downwash_flight.size == 18 assert len(cocip.contrail) == 0 - cocip = Cocip( - **params, - filter_initially_persistent=False, - persistent_buffer=np.timedelta64(1, "h"), - ) - with pytest.warns(UserWarning, match="Manually overriding initially persistent filter"): - cocip.eval(fl) - assert len(cocip.contrail) == 18 * 2 - assert "end_of_life" in cocip.contrail - - cocip = Cocip( - **params, - filter_initially_persistent=False, - persistent_buffer=np.timedelta64(2, "h"), - ) - with pytest.warns(UserWarning, match="Manually overriding initially persistent filter"): - cocip.eval(fl) - assert len(cocip.contrail) == 18 * 4 - assert "end_of_life" in cocip.contrail - def test_cocip_no_persistence_ef_fill_value(fl: Flight, met: MetDataset, rad: MetDataset): """Confirm that EF is filled with 0 for in-domain and nan for out-of-domain waypoints.""" diff --git a/tests/unit/test_cocip_grid_parity.py b/tests/unit/test_cocip_grid_parity.py index 1b91f841e..9a98fff48 100644 --- a/tests/unit/test_cocip_grid_parity.py +++ b/tests/unit/test_cocip_grid_parity.py @@ -75,11 +75,13 @@ def fl(met: MetDataset, request) -> Flight: return syn() +@pytest.mark.parametrize("second_order_runge", [True, False]) def test_parity( fl: Flight, met: MetDataset, rad: MetDataset, bada_model: AircraftPerformance, + second_order_runge: bool, ) -> None: """Ensure substantial parity between `Cocip` and `CocipGrid`.""" @@ -96,6 +98,7 @@ def test_parity( "max_age": np.timedelta64(1, "h"), "interpolation_bounds_error": True, "humidity_scaling": ExponentialBoostHumidityScaling(), + "second_order_runge": second_order_runge, } cocip = Cocip(met=met, rad=rad, **model_params, aircraft_performance=bada_model) out1 = cocip.eval(fl)