diff --git a/.gitignore b/.gitignore index 1441e71..9964502 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ example/repository/*.nc !example/repository/ch4_bg.nc !example/repository/n2o_bg.nc !example/repository/resp_RF.nc +!example/repository/resp_cont.nc tests/repository/*.* tests/repository/cache # exceptions diff --git a/README.md b/README.md index 6439f6a..97f67f8 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,9 @@ Major planned software releases and milestones for the project planning are: ## References - Grewe, V., & Stenke, A. (2008). AirClim: an efficient tool for climate evaluation of aircraft technology. Atmospheric Chemistry and Physics, 8(16), 4621-4639. - Dahlmann, K. (2011). A method for the efficient evaluation of climate optimisation measures for air transport [Eine Methode zur effizienten Bewertung von Maßnahmen zur Klimaoptimierung des Luftverkehrs] (Doctoral dissertation, Ph. D. Thesis, Ludwig-Maximilians-Universität München, Munich). +- Hüttenhofer, L. (2013). Parametrisierung von Kondensstreifenzirren für AirClim 2.0 (Bachelor Thesis, Ludwig-Maximilians-Universität München, Munich). - Dahlmann, K., Grewe, V., Frömming, C., & Burkhardt, U. (2016). Can we reliably assess climate mitigation options for air traffic scenarios despite large uncertainties in atmospheric processes?. Transportation Research Part D: Transport and Environment, 46, 40-55. +- Grewe, V., Bock, L., Burkhardt, U., et al. (2017). Assessing the climate impact of the AHEAD multi-fuel blended wing body. Meteorologische Zeitschrift, 26(6), 711-725. - Leipold, A. et al. (2021) DEPA 2050 – Development Pathways for Aviation up to 2050 (Final Report). https://elib.dlr.de/142185/ diff --git a/example/example.toml b/example/example.toml index c732d32..c8d9d13 100644 --- a/example/example.toml +++ b/example/example.toml @@ -3,23 +3,23 @@ # Species considered [species] # Species defined in emission inventories -# possible values: "CO2", "H2O", "NOx" -inv = ["CO2", "H2O", "NOx"] +# possible values: "CO2", "H2O", "NOx", "distance" +inv = ["CO2", "H2O", "NOx", "distance"] # Assumed NOx species in emission inventory # possible values: "NO", "NO2" nox = "NO" # Output / response species # possible values: "CO2", "H2O" -out = ["CO2", "H2O", "CH4"] +out = ["CO2", "H2O", "cont"] # Emission inventories [inventories] dir = "repository/" files = [ - "rnd_inv_2020.nc", - "rnd_inv_2030.nc", - "rnd_inv_2040.nc", - "rnd_inv_2050.nc", + "emi_inv_2020.nc", +# "emi_inv_2030.nc", +# "emi_inv_2040.nc", + "emi_inv_2050.nc", # "rnd_inv_2060.nc", # "rnd_inv_2070.nc", # "rnd_inv_2080.nc", @@ -77,6 +77,12 @@ CH4.response_grid = "2D" CH4.tau.file = "repository/resp_ch4.nc" CH4.rf.method = "Etminan_2016" +cont.response_grid = "cont" +cont.resp.file = "repository/resp_cont.nc" +cont.G_comp = 0.04 # conventional: 0.04; hydrogen: 0.12 +cont.eff_fac = 1.0 # efficiency factor compared to kerosene (1.0) +cont.PMrel = 1.0 # relative PM emissions compared to kerosene (1.0) + # Temperature options [temperature] # valid methods: "Boucher&Reddy" @@ -88,6 +94,7 @@ CO2.lambda = 0.73 H2O.efficacy = 1.14 O3.efficacy = 1.37 CH4.efficacy = 1.14 +cont.efficacy = 0.59 # Climate metrics options [metrics] diff --git a/example/repository/resp_cont.nc b/example/repository/resp_cont.nc new file mode 100644 index 0000000..5e4617a Binary files /dev/null and b/example/repository/resp_cont.nc differ diff --git a/openairclim/__init__.py b/openairclim/__init__.py index adb1d30..2618b7c 100644 --- a/openairclim/__init__.py +++ b/openairclim/__init__.py @@ -14,6 +14,7 @@ from openairclim.calc_response import * # noqa: F401, F403 from openairclim.calc_co2 import * # noqa: F401, F403 from openairclim.calc_ch4 import * # noqa: F401, F403 +from openairclim.calc_cont import * # noqa: F401, F403 from openairclim.calc_dt import * # noqa: F401, F403 from openairclim.calc_metric import * # noqa: F401, F403 from openairclim.uncertainties import * # noqa: F401, F403 diff --git a/openairclim/calc_cont.py b/openairclim/calc_cont.py new file mode 100644 index 0000000..08989e2 --- /dev/null +++ b/openairclim/calc_cont.py @@ -0,0 +1,343 @@ +""" +Calculates the contrail response. +Currently implemented: AirClim 2.1 contrail module. +""" + +__author__ = "Liam Megill" +__email__ = "liam.megill@dlr.de" +__license__ = "Apache License 2.0" + + +import numpy as np +import xarray as xr +from openairclim.interpolate_time import apply_evolution + +# CONSTANTS +R_EARTH = 6371. # [km] radius of Earth +KAPPA = 287. / 1003.5 + +# DEFINITION OF CONTRAIL GRID (from AirClim 2.1) +cc_lon_vals = np.arange(0, 360, 3.75) +cc_lat_vals = np.array([ + 87.1591, 83.47892, 79.77705, 76.07024, 72.36156, 68.65202, + 64.94195, 61.23157, 57.52099, 53.81027, 50.09945, 46.38856, 42.6776, + 38.96661, 35.25558, 31.54452, 27.83344, 24.12235, 20.41124, 16.70012, + 12.98899, 9.277853, 5.566714, 1.855572, -1.855572, -5.566714, -9.277853, + -12.98899, -16.70012, -20.41124, -24.12235, -27.83344, -31.54452, + -35.25558, -38.96661, -42.6776, -46.38856, -50.09945, -53.81027, + -57.52099, -61.23157, -64.94195, -68.65202, -72.36156, -76.07024, + -79.77705, -83.47892, -87.1591 +]) +cc_plev_vals = np.array([ + 1014., 996., 968., 921., 865., 809., 755., 704., + 657., 613., 573., 535., 499., 466., 434., 405., 377., 350., 325., 301., + 278., 256., 236., 216., 198., 180., 163., 147., 131., 117., 103.0, 89., + 76.0, 64.0, 52.0, 41.0, 30.0, 20.0, 10.0 +]) + + +def calc_cont_grid_areas(lat: np.ndarray, lon: np.ndarray) -> np.ndarray: + """Calculate the cell area of the contrail grid using a simplified method. + + Args: + lat (np.ndarray): Latitudes of the grid cells [deg]. + lon (np.ndarray): Longitudes of the grid cells [deg]. + + Returns: + np.ndarray : Contrail grid cell areas as a function of latitude [km^2]. + """ + + # pre-conditions + assert len(lat) > 0, "Latitudes cannot be empty." + assert len(lon) > 0, "Longitudes cannot be empty." + assert len(lat) == len(np.unique(lat)), "Duplicate latitude values." + assert len(lon) == len(np.unique(lon)), "Duplicate longitude values." + assert np.all((lat > -90.) & (lat < 90.)), "Latitudes values must be "\ + "between, but not equal to, -90 and +90 degrees." + assert np.all((lon >= 0.) & (lon <= 360.)), "Longitude values must vary " \ + "between 0 and 360 degrees." + assert (0. in lon) != (360. in lon), "Longitude values must not include " \ + "both 0 and 360 deg values." + + # ensure that lat values descend and lon values ascend + lat = np.sort(lat)[::-1] + lon = np.sort(lon) + + # calculate dlon + lon_padded = np.concatenate(([lon[-1] - 360.], lon, [lon[0] + 360.])) + lon_midpoints = 0.5 * (lon_padded[1:] + lon_padded[:-1]) + dlon_deg = np.diff(lon_midpoints) + dlon = np.deg2rad(dlon_deg) * R_EARTH + + # calculate dlat + lat_padded = np.concatenate(([90], lat, [-90])) # add +/-90 deg + lat_midpoints = 0.5 * (lat_padded[1:] + lat_padded[:-1]) + dlat = R_EARTH * np.abs(np.sin(np.deg2rad(lat_midpoints[:-1])) - + np.sin(np.deg2rad(lat_midpoints[1:]))) + + # calculate areas + areas = np.outer(dlat, dlon) + + # post-conditions + assert np.all(areas) > 0., "Not all calculated areas are positive." + sphere_area = 4 * np.pi * R_EARTH ** 2 + assert abs(areas.sum() - sphere_area) / sphere_area < 1e-3, "Total area " \ + "calculation is insufficiently accurate." + + return areas + + +def calc_cont_weighting(config: dict, val: str) -> np.ndarray: + """Calculate weighting functions for the contrail grid developed by + Ludwig Hüttenhofer (Bachelorarbeit LMU, 2013). This assumes the + contrail grid developed for AirClim 2.1 (Dahlmann et al., 2016). + + Args: + config (dict): Configuration dictionary from config file. + val (str): Weighting value to calculate. Choice of "w1", "w2" or "w3" + + Raises: + ValueError: if invalid value is passed for "val". + + Returns: + np.ndarray: Array of size (nlat) with weighting values for each + latitude value + """ + + # Eq. 3.3.4 of Hüttenhofer (2013); "rel" in AirClim 2.1 + if val == "w1": + idxs = (cc_lat_vals > 68.) | (cc_lat_vals < -53.) # as in AirClim 2.1 + res = np.where(idxs, 1., 0.863 * np.cos(np.pi * cc_lat_vals / 50.) + 1.615) + + # "fkt_g" in AirClim 2.1 + elif val == "w2": + # pre-conditions + assert "responses" in config, "Missing 'responses' key in config." + assert "cont" in config["responses"], "Missing 'cont' key in" \ + "config['responses']." + assert "eff_fac" in config["responses"]["cont"], "Missing eff_fac " \ + "key in config['responses']['cont']." + + eff_fac = config["responses"]["cont"]["eff_fac"] + res = 1. + 15. * np.abs(0.045 * np.cos(cc_lat_vals * 0.045) + 0.045) * (eff_fac - 1.) + + # Eq. 3.3.10 of Hüttenhofer (2013); RF weighting in AirClim 2.1 + elif val == "w3": + res = 1. + 0.24 * np.cos(cc_lat_vals * np.pi / 23.) + + # raise error in case val invalid + else: + raise ValueError(f"Contrail weighting parameter {val} is invalid.") + + return res + + +def calc_cfdd(config: dict, inv_dict: dict, ds_cont: xr.Dataset) -> dict: + """Calculate the Contrail Flight Distance Density (CFDD) for each year in + inv_dict. This function uses the p_sac data calculated during the + development of AirClim 2.1 (Dahlmann et al., 2016). + + Args: + config (dict): Configuration dictionary from config file. + inv_dict (dict): Dictionary of emission inventory xarrays, + keys are inventory years. + ds_cont (xr.Dataset): Dataset of precalculated contrail data. + + Returns: + dict: Dictionary with CFDD values [km/km2], keys are inventory years + """ + + # pre-conditions + assert "responses" in config, "Missing 'responses' key in config." + assert "cont" in config["responses"], "Missing 'cont' key in" \ + "config['responses']." + assert "G_comp" in config["responses"]["cont"], "Missing G_comp key in " \ + "config['responses']['cont']." + + # calculate p_sac for aircraft G + g_comp = config["responses"]["cont"]["G_comp"] + g_comp_con = 0.04 # EIH2O 1.25, Q 43.6e6, eta 0.3 + g_comp_lh2 = 0.12 # EIH2O 8.94, Q 120.9e6, eta 0.4 + assert ((g_comp >= g_comp_con) & (g_comp <= g_comp_lh2)), "Invalid " \ + "G_comp value. Expected range: [0.04, 0.12]." + + x = (g_comp - g_comp_con) / ( g_comp_lh2 - g_comp) + p_sac = (1. - x) * ds_cont.SAC_CON + x * ds_cont.SAC_LH2 + + # calculate contrail grid areas + areas = calc_cont_grid_areas(cc_lat_vals, cc_lon_vals) + + # calculate CFDD + # p_sac is interpolated using a power law over pressure level and using + # a nearest neighbour for latitude and longitude. + cfdd_dict = {} + for year, inv in inv_dict.items(): + + # initialise arrays for storage + sum_km = np.zeros((len(cc_lat_vals), len(cc_lon_vals))) + + # find indices + lat_idxs = np.abs(cc_lat_vals[:, np.newaxis] - inv.lat.data).argmin(axis=0) + lon_idxs = np.abs(cc_lon_vals[:, np.newaxis] - inv.lon.data).argmin(axis=0) + plev_idxs = len(cc_plev_vals) - np.searchsorted(cc_plev_vals[::-1], + inv.plev.data, side="right") + + # interpolate over plev using power law between upper and lower bounds + plev_ub = cc_plev_vals[plev_idxs] + plev_lb = cc_plev_vals[plev_idxs-1] + sigma_plev = 1 - ((inv.plev.data ** KAPPA - plev_lb ** KAPPA) / + (plev_ub ** KAPPA - plev_lb ** KAPPA)) + + # calculate p_sac + p_sac_ub = p_sac.values[lat_idxs, lon_idxs, plev_idxs] + p_sac_lb = p_sac.values[lat_idxs, lon_idxs, plev_idxs-1] + p_sac_intrp = sigma_plev * p_sac_lb + (1 - sigma_plev) * p_sac_ub + + # calculate and store CFDD + # 1800s since ISS & p_sac were developed in 30min intervals + # 3153600s in one year + sum_contrib = inv.distance.data * p_sac_intrp * 1800.0 / 31536000.0 + np.add.at(sum_km, (lat_idxs, lon_idxs), sum_contrib) + cfdd = sum_km / areas + cfdd_dict[year] = cfdd + + # post-conditions + for year, cfdd in cfdd_dict.items(): + assert cfdd.shape == (len(cc_lat_vals), len(cc_lon_vals)), "Shape " \ + f"of CFDD array for year {year} is not correct." + + return cfdd_dict + + +def calc_cccov(config: dict, cfdd_dict: dict, ds_cont: xr.Dataset) -> dict: + """Calculate contrail cirrus coverage using the relationship developed for + AirClim 2.1 (Dahlmann et al., 2016). + + Args: + config (dict): Configuration dictionary from config file. + cfdd_dict (dict): Dictionary with CFDD values [km/km2], keys are + inventory years. + ds_cont (xr.Dataset): Dataset of precalculated contrail data. + + Returns: + dict: Dictionary with cccov values, keys are inventory years + """ + + # pre-conditions + assert "responses" in config, "Missing 'responses' key in config." + assert "cont" in config["responses"], "Missing 'cont' key in" \ + "config['responses']." + assert "eff_fac" in config["responses"]["cont"], "Missing eff_fac key " \ + "in config['responses']['cont']." + for year, cfdd in cfdd_dict.items(): + assert cfdd.shape == (len(cc_lat_vals), len(cc_lon_vals)), "Shape " \ + f"of CFDD array for year {year} is not correct." + + # load weighting function + eff_fac = config["responses"]["cont"]["eff_fac"] + w1 = calc_cont_weighting(config, "w1") + + # calculate cccov + cccov_dict = {} + for year, cfdd in cfdd_dict.items(): + cccov = 0.128 * ds_cont.ISS.data * np.arctan(97.7 * cfdd / + ds_cont.ISS.data) + cccov = cccov * eff_fac * w1[:, np.newaxis] # add corrections + cccov_dict[year] = cccov + + # post-conditions + for year, cccov in cccov_dict.items(): + assert cccov.shape == (len(cc_lat_vals), len(cc_lon_vals)), "Shape " \ + f"of cccov array for year {year} is not correct." + + return cccov_dict + + +def calc_cccov_tot(config, cccov_dict): + """Calculate total, area-weighted contrail cirrus coverage using the + relationship developed for AirClim 2.1 (Dahlmann et al., 2016). + + Args: + config (dict): Configuration dictionary from config file. + cccov_dict (dict): Dictionary with cccov values, keys are inventory + years. + + Returns: + dict: Dictionary with total, area-weighted contrail cirrus coverage, + keys are inventory years. + """ + + for year, cccov in cccov_dict.items(): + assert cccov.shape == (len(cc_lat_vals), len(cc_lon_vals)), "Shape " \ + f"of cccov array for year {year} is not correct." + + # calculate contril grid cell areas + areas = calc_cont_grid_areas(cc_lat_vals, cc_lon_vals) + w2 = calc_cont_weighting(config, "w2") + w3 = calc_cont_weighting(config, "w3") + + # calculate total (area-weighted) cccov + cccov_tot_dict = {} + for year, cccov in cccov_dict.items(): + cccov_tot = (cccov * areas).sum(axis=1) * w2 * w3 / areas.sum() + cccov_tot_dict[year] = cccov_tot + + for year, cccov_tot in cccov_tot_dict.items(): + assert cccov_tot.shape == (len(cc_lat_vals),), "Shape of cccov_tot " \ + f"array for year {year} is not correct." + + return cccov_tot_dict + + +def calc_cont_rf(config, cccov_tot_dict, inv_dict): + """Calculate contrail Radiative Forcing (RF) using the relationship + developed for AirClim 2.1 (Dahlmann et al., 2016). + + Args: + config (dict): Configuration dictionary from config file. + cccov_tot_dict (dict): Dictionary with total, area-weighted contrail + cirrus coverage, keys are inventory years + inv_dict (dict): Dictionary of emission inventory xarrays, + keys are inventory years. + + Returns: + dict: Dictionary with contrail RF values interpolated for all years + between the simulation start and end years. + """ + + # pre-conditions: check config + assert "responses" in config, "Missing 'responses' key in config." + assert "cont" in config["responses"], "Missing 'cont' key in" \ + "config['responses']." + assert "PMrel" in config["responses"]["cont"], "Missing 'PMrel' key in " \ + "config['responses']['cont']." + assert "time" in config, "Missing 'time' key in config." + assert "range" in config["time"], "Missing 'range' key in config['time']." + # pre-conditions: check input dicts + assert len(inv_dict) > 0, "inv_dict cannot be empty." + assert len(cccov_tot_dict) > 0, "cccov_tot_dict cannot be empty." + assert np.all(cccov_tot_dict.keys() == inv_dict.keys()), "Keys of " \ + "cccov_dict do not match those of inv_dict." + for year, cccov_tot in cccov_tot_dict.items(): + assert cccov_tot.shape == (len(cc_lat_vals),), f"Shape of cccov_tot " \ + f"array for year {year} is not correct." + + # calculate RF factor due to PM reduction, from AirClim 2.1 + pm_rel = config["responses"]["cont"]["PMrel"] + if pm_rel >= 0.033: + pm_factor = 0.92 * np.arctan(1.902 * pm_rel ** 0.74) + else: + pm_factor = 0.92 * np.arctan(1.902 * 0.033 ** 0.74) + + # calculate contrail RF + cont_rf_at_inv = [] # RF at inventory years + for year, cccov_tot in cccov_tot_dict.items(): + cont_rf = 14.9 * np.sum(cccov_tot) * pm_factor + cont_rf_at_inv.append(cont_rf) + + # interpolate RF to all simulation years + _, rf_cont_dict = apply_evolution(config, + {"cont": np.array(cont_rf_at_inv)}, + inv_dict) + + return rf_cont_dict diff --git a/openairclim/construct_conc.py b/openairclim/construct_conc.py index 97d8479..885e8db 100644 --- a/openairclim/construct_conc.py +++ b/openairclim/construct_conc.py @@ -33,8 +33,9 @@ def get_emissions(inv_dict, species): emis_dict = {} for spec in species: emis = calc_inv_sums(spec, inv_dict) - # Convert kg to Tg - emis = kg_to_tg(emis) + if spec != "distance": # distance remains in km + # Convert kg to Tg + emis = kg_to_tg(emis) emis_dict[spec] = emis return emis_dict diff --git a/openairclim/main.py b/openairclim/main.py index 057a375..f60e93e 100644 --- a/openairclim/main.py +++ b/openairclim/main.py @@ -36,7 +36,7 @@ def run(file_name): if full_run: inv_species = config["species"]["inv"] # out_species = config["species"]["out"] - species_0d, species_2d = oac.classify_species(config) + species_0d, species_2d, species_cont = oac.classify_species(config) inv_dict = oac.open_inventories(config) # Emissions in Tg, each species @@ -183,7 +183,36 @@ def run(file_name): logging.warning( "No species defined in config with 2D response_grid." ) - + + if species_cont: + # Calculate Contrail Flight Distance Density (CFDD) + cfdd_dict = oac.calc_cfdd(config, inv_dict) + + # Calculate contrail cirrus coverage (cccov) + cccov_dict = oac.calc_cccov(config, cfdd_dict) + + # Calculate global, area-weighted cccov + cccov_tot_dict = oac.calc_cccov_tot(config, cccov_dict) + + # Calculate contrail RF + rf_cont_dict = oac.calc_cont_RF(config, cccov_tot_dict, inv_dict) + oac.write_to_netcdf( + config, rf_cont_dict, result_type="RF", mode="a" + ) + + # Calculate contrail temperature change + dtemp_cont_dict = oac.calc_dtemp(config, "cont", rf_cont_dict) + oac.write_to_netcdf( + config, dtemp_cont_dict, result_type="dT", mode="a" + ) + logging.warning( + "Contrail values use the AirClim 2.1 method." + ) + else: + logging.warning( + "No contrails defined in config." + ) + # Calculate climate metrics metrics_dict = oac.calc_climate_metrics(config) oac.write_climate_metrics(config, metrics_dict) diff --git a/openairclim/read_config.py b/openairclim/read_config.py index 42aed68..fd022a2 100644 --- a/openairclim/read_config.py +++ b/openairclim/read_config.py @@ -87,7 +87,7 @@ def check_config(config): flag = check_config_types(config, CONFIG_TYPES) if flag: # Check response section - _species_0d, species_2d = classify_species(config) + _species_0d, species_2d, species_cont = classify_species(config) response_files = [] for spec in species_2d: resp_flag = False @@ -231,6 +231,7 @@ def classify_species(config): responses = config["responses"] species_0d = [] species_2d = [] + species_cont = [] for spec in species: exists = False for key, item in responses.items(): @@ -240,6 +241,8 @@ def classify_species(config): species_0d.append(spec) elif item["response_grid"] == "2D": species_2d.append(spec) + elif item["response_grid"] == "cont": + species_cont.append(spec) else: raise KeyError( "No valid response_grid in config for", spec @@ -248,7 +251,7 @@ def classify_species(config): pass if exists is False: raise KeyError("Responses not defined in config for", spec) - return species_0d, species_2d + return species_0d, species_2d, species_cont def classify_response_types(config, species_arr): diff --git a/openairclim/read_netcdf.py b/openairclim/read_netcdf.py index ccb91ca..647f9bd 100644 --- a/openairclim/read_netcdf.py +++ b/openairclim/read_netcdf.py @@ -8,7 +8,7 @@ import xarray as xr # CONSTANTS -INV_SPEC_UNITS = ["kg"] +INV_SPEC_UNITS = ["kg", "km"] def open_netcdf(netcdf): diff --git a/openairclim/write_output.py b/openairclim/write_output.py index 46857b6..a8855af 100644 --- a/openairclim/write_output.py +++ b/openairclim/write_output.py @@ -13,7 +13,7 @@ RESULT_TYPE_DICT = { "emis": { "long_name": "Emission", - "units": {"CO2": "Tg", "H2O": "Tg", "NOx": "Tg"}, + "units": {"CO2": "Tg", "H2O": "Tg", "NOx": "Tg", "distance": "km"}, }, "conc": { "long_name": "Concentration", @@ -26,11 +26,12 @@ "H2O": "W/m²", "O3": "W/m²", "CH4": "W/m²", + "cont": "W/m²" }, }, "dT": { "long_name": "Temperature change", - "units": {"CO2": "K", "H2O": "K", "O3": "K", "CH4": "K"}, + "units": {"CO2": "K", "H2O": "K", "O3": "K", "CH4": "K", "cont": "K"}, }, "ATR": {"long_name": "Average Temperature Response", "units": "K"}, "AGWP": { @@ -64,6 +65,8 @@ def write_to_netcdf(config, val_arr_dict, result_type, mode="w"): Returns: xarray: xarray Dateset of results time series """ + # TODO "distance" is not really an emission, so being saved as "distance emission" doesn't really make sense + output_dir = config["output"]["dir"] output_name = config["output"]["name"] output_filename = output_dir + output_name + ".nc" diff --git a/tests/calc_cont_test.py b/tests/calc_cont_test.py new file mode 100644 index 0000000..3e4672a --- /dev/null +++ b/tests/calc_cont_test.py @@ -0,0 +1,317 @@ +""" +Provides tests for module calc_cont +""" +import numpy as np +import pytest +import openairclim as oac +from utils.create_test_data import create_test_inv, create_test_resp_cont + + +class TestInputContrailGrid: + """Tests the input contrail grid, defined by `cc_lat_vals`, `cc_lon_vals` + and `cc_plev_vals`.""" + + def test_lon_vals(self): + """Tests longitude values.""" + lon = oac.cc_lon_vals + assert len(lon) > 0, "Contrail grid longitudes cannot be empty." + assert len(lon) == len(np.unique(lon)), "Duplicate longitude values " \ + "in contrail grid." + assert np.all((lon >= 0.) & (lon <= 360.)), "Longitude values must " \ + "vary between 0 and 360 degrees" + assert (0. in lon) != (360. in lon), "Longitude values must not " \ + "include both 0 and 360 deg values." + assert np.all(lon == np.sort(lon)), "Contrail grid longitudes must " \ + "be sorted in ascending order." + + def test_lat_vals(self): + """Tests latitude values.""" + lat = oac.cc_lat_vals + assert len(lat) > 0, "Contrail grid latitudes cannot be empty." + assert len(lat) == len(np.unique(lat)), "Duplicate latitude values " \ + "in contrail grid." + assert np.all((lat >= -90.) & (lat <= 90)), "Latitude values must " \ + "be between, but not equal to, -90 and +90 degrees." + assert np.all(lat == np.sort(lat)[::-1]), "Contrail grid latitudes " \ + "must be sorted in descending order." + + def test_plev_vals(self): + """Tests pressure level values.""" + plev = oac.cc_plev_vals + assert len(plev) > 0, "Contrail grid pressure levels cannot be empty." + assert len(plev) == len(np.unique(plev)), "Duplicate pressure level " \ + "values in contrail grid." + assert np.all(plev == np.sort(plev)[::-1]), "Contrail grid pressure " \ + "level values must be sorted in descending order." + assert np.all(plev <= 1014.), "Contrail grid pressure levels must " \ + "be at altitudes above ground level - defined as 1014 hPa." + + +class TestCalcContGridAreas: + """Tests function calc_cont_grid_areas(lat, lon)""" + + def test_unsorted_latitudes(self): + """Ensures that the latitude order does not affect results.""" + lat_vals = np.arange(-89.0, 89.0, 3.0) + rnd_lat_vals = np.arange(-89.0, 89.0, 3.0) + np.random.shuffle(rnd_lat_vals) + lon_vals = np.arange(0, 360, 3.75) + res_unsorted = oac.calc_cont_grid_areas(rnd_lat_vals, lon_vals) + res_sorted = oac.calc_cont_grid_areas(lat_vals, lon_vals) + assert np.all(res_unsorted == res_sorted), "Sorting of latitudes " \ + "unsuccessful." + + def test_unsorted_longitudes(self): + """Ensures that the longitude order does not affect results.""" + lat_vals = np.arange(-89.0, 89.0, 3.0) + lon_vals = np.arange(0, 360, 3.75) + rnd_lon_vals = np.arange(0, 360, 3.75) + np.random.shuffle(rnd_lon_vals) + res_unsorted = oac.calc_cont_grid_areas(lat_vals, rnd_lon_vals) + res_sorted = oac.calc_cont_grid_areas(lat_vals, lon_vals) + assert np.all(res_unsorted == res_sorted), "Sorting of longitudes " \ + "unsuccessful." + + +class TestCalcContWeighting: + """Tests function calc_cont_weighting(config, val)""" + + def test_invalid_value(self): + """Tests an invalid weighting value.""" + config = {"responses": {"cont":{"eff_fac": 1.0}}} + with pytest.raises(ValueError): + oac.calc_cont_weighting(config, "invalid_value") + + nlat = len(oac.cc_lat_vals) + @pytest.mark.parametrize("val,len_val", [("w1", nlat), + ("w2", nlat), + ("w3", nlat)]) + def test_weighting_size(self, val, len_val): + """Tests that calculated weightings are of size (nlat).""" + config = {"responses": {"cont": {"eff_fac": 1.0}}} + assert len(oac.calc_cont_weighting(config, val)) == len_val + + @pytest.mark.parametrize("config", [{}, + {"responses": {}}, + {"responses": {"cont": {}}}]) + def test_missing_config_values(self, config): + """Tests missing config values.""" + with pytest.raises(AssertionError): + oac.calc_cont_weighting(config, "w2") # only w2 uses config + + +class TestCalcCFDD: + """Tests function calc_cfdd(config, inv_dict)""" + + @pytest.fixture(scope="class") + def ds_cont(self): + """Fixture to load an example ds_cont file.""" + return create_test_resp_cont() + + @pytest.fixture(scope="class") + def inv_dict(self): + """Fixture to create an example inv_dict.""" + return {2020: create_test_inv(year=2020)} + + @pytest.mark.parametrize("config", [{}, + {"responses": {}}, + {"responses": {"cont": {}}}]) + def test_missing_config_values(self, config, inv_dict, ds_cont): + """Tests missing config values.""" + with pytest.raises(AssertionError): + oac.calc_cfdd(config, inv_dict, ds_cont) + + def test_invalid_g_comp(self, inv_dict, ds_cont): + """Tests an invalid G_comp value.""" + config = {"responses": {"cont": {"G_comp": 0.2}}} + with pytest.raises(AssertionError): + oac.calc_cfdd(config, inv_dict, ds_cont) + # test lower bound + config["responses"]["cont"]["G_comp"] = 0.02 + with pytest.raises(AssertionError): + oac.calc_cfdd(config, inv_dict, ds_cont) + + def test_output_structure(self, inv_dict, ds_cont): + """Tests the output structure.""" + config = {"responses": {"cont": {"G_comp": 0.1}}} + result = oac.calc_cfdd(config, inv_dict, ds_cont) + + # run tests + assert isinstance(result, dict), "Output is not a dictionary." + assert set(result.keys()) == set(inv_dict.keys()), "Output keys " \ + "do not match input keys." + for year, cfdd in result.items(): + assert isinstance(cfdd, np.ndarray), "CFDD is not an array." + assert cfdd.shape == (len(oac.cc_lat_vals), len(oac.cc_lon_vals)),\ + f"CFDD array has incorrect shape for year {year}." + + def test_empty_inventory(self, ds_cont): + """Tests the handling of an empty input inventory.""" + config = {"responses": {"cont": {"G_comp": 0.1}}} + inv_dict = {} # empty inventory + result = oac.calc_cfdd(config, inv_dict, ds_cont) + assert not result, "Result should be an empty dictionary for an " \ + "empty inventory." + + +class TestCalcCccov: + """Tests function calc_cccov(config, cfdd_dict)""" + + @pytest.fixture(scope="class") + def ds_cont(self): + """Fixture to load an example ds_cont file.""" + return create_test_resp_cont() + + def test_output_structure(self, ds_cont): + """Tests the output structure.""" + config = {"responses": {"cont": {"eff_fac": 0.5}}} + len_lon = len(oac.cc_lon_vals) + len_lat = len(oac.cc_lat_vals) + cfdd_dict = {2020: np.random.rand(len_lat, len_lon), + 2050: np.random.rand(len_lat, len_lon)} + result = oac.calc_cccov(config, cfdd_dict, ds_cont) + + # run assertions + assert isinstance(result, dict), "Output is not a dictionary." + assert set(result.keys()) == set(cfdd_dict.keys()), "Output keys " \ + "do not match input keys." + for year, cccov in result.items(): + assert isinstance(cccov, np.ndarray), "cccov is not an array." + assert cccov.shape == (len_lat, len_lon), "cccov array has " \ + f"incorrect shape for year {year}." + + def test_incorrect_cfdd_shape(self, ds_cont): + """Tests incorrect shape of each cfdd array within cfdd_dict.""" + config = {"responses": {"cont": {"eff_fac": 0.5}}} + cfdd_dict = {2020: np.random.rand(10, 10), + 2050: np.random.rand(10, 10)} + with pytest.raises(AssertionError): + oac.calc_cccov(config, cfdd_dict, ds_cont) + + @pytest.mark.parametrize("config", [{}, + {"responses": {}}, + {"responses": {"cont": {}}}]) + def test_missing_config_values(self, config, ds_cont): + """Tests missing config values.""" + len_lon = len(oac.cc_lon_vals) + len_lat = len(oac.cc_lat_vals) + cfdd_dict = {2020: np.random.rand(len_lat, len_lon), + 2050: np.random.rand(len_lat, len_lon)} + with pytest.raises(AssertionError): + oac.calc_cccov(config, cfdd_dict, ds_cont) + + def test_empty_cfdd_dict(self, ds_cont): + """Tests the output for an empty cfdd_dict.""" + config = {"responses": {"cont": {"eff_fac": 0.5}}} + cfdd_dict = {} + result = oac.calc_cccov(config, cfdd_dict, ds_cont) + assert not result, "Result should be an empty dictionary for an " \ + "empty cfdd_dict." + + +class TestCalcCccovTot: + """Tests function calc_cccov_tot(config, cccov_dict)""" + + def test_output_structure(self): + """Tests output structure.""" + len_lon = len(oac.cc_lon_vals) + len_lat = len(oac.cc_lat_vals) + cccov_dict = {2020: np.random.rand(len_lat, len_lon), + 2050: np.random.rand(len_lat, len_lon)} + config = {"responses": {"cont": {"eff_fac": 0.5}}} + result = oac.calc_cccov_tot(config, cccov_dict) + + # run assertions + assert isinstance(result, dict), "Output is not a dictionary." + assert set(result.keys()) == set(cccov_dict.keys()), "Output keys " \ + "do not match input keys." + for _, cccov_tot in result.items(): + assert isinstance(cccov_tot, np.ndarray), "cccov_tot should be " \ + "an array." + assert cccov_tot.shape == (len_lat,), "cccov_tot should be a " \ + "function of latitude only." + + @pytest.mark.parametrize("config", [{}, + {"responses": {}}, + {"responses": {"cont": {}}}]) + def test_missing_config_values(self, config): + """Tests missing config values.""" + len_lon = len(oac.cc_lon_vals) + len_lat = len(oac.cc_lat_vals) + cccov_dict = {2020: np.random.rand(len_lat, len_lon), + 2050: np.random.rand(len_lat, len_lon)} + with pytest.raises(AssertionError): + oac.calc_cccov_tot(config, cccov_dict) + + def test_incorrect_cccov_shape(self): + """Tests incorrect shape of each cccov array within cfdd_dict.""" + config = {"responses": {"cont": {"eff_fac": 0.5}}} + cccov_dict = {2020: np.random.rand(10, 10), + 2050: np.random.rand(10, 10)} + with pytest.raises(AssertionError): + oac.calc_cccov_tot(config, cccov_dict) + + def test_empty_cccov_dict(self): + """Tests the output for an empty cccov_dict.""" + config = {"responses": {"cont": {"eff_fac": 0.5}}} + cccov_dict = {} + result = oac.calc_cccov_tot(config, cccov_dict) + assert not result, "Result should be an empty dictionary for an " \ + "empty cccov_dict." + + +class TestCalcContRF: + """Tests function calc_cont_RF(config, cccov_tot_dict, inv_dict)""" + + @pytest.fixture(scope="class") + def inv_dict(self): + """Fixture to create an example inv_dict.""" + return {2020: create_test_inv(year=2020), + 2050: create_test_inv(year=2050)} + + def test_output_structure(self, inv_dict): + """Tests the output structure.""" + config = {"responses": {"cont": {"PMrel": 1.0}}, + "time": {"range": [2020, 2051, 1]}} + len_lat = len(oac.cc_lat_vals) + years = list(inv_dict.keys()) + cccov_tot_dict = {years[0]: np.random.rand(len_lat), + years[1]: np.random.rand(len_lat)} + result = oac.calc_cont_rf(config, cccov_tot_dict, inv_dict) + + # run assertions + assert isinstance(result, dict), "Output should be a dictionary" + assert "cont" in result, "Output does not include 'cont'." + assert len(result["cont"]) == 31, "Output length does not match the " \ + " number of years in inv_dict." + + def test_incorrect_keys(self, inv_dict): + """Tests differing keys in inv_dict and cccov_tot_dict.""" + config = {"responses": {"cont": {"PMrel": 1.0}}, + "time": {"range": [2020, 2051, 1]}} + len_lat = len(oac.cc_lat_vals) + cccov_tot_dict = {2021: np.random.rand(len_lat), + 2049: np.random.rand(len_lat)} + with pytest.raises(AssertionError): + oac.calc_cont_rf(config, cccov_tot_dict, inv_dict) + + @pytest.mark.parametrize("config", [{}, + {"responses": {}}, + {"responses": {"cont": {}}}, + {"time": {}}]) + def test_missing_config_values(self, config, inv_dict): + """Tests missing config values.""" + len_lat = len(oac.cc_lat_vals) + years = list(inv_dict.keys()) + cccov_tot_dict = {years[0]: np.random.rand(len_lat), + years[1]: np.random.rand(len_lat)} + with pytest.raises(AssertionError): + oac.calc_cont_rf(config, cccov_tot_dict, inv_dict) + + def test_empty_input_dicts(self): + """Tests empty input dicts.""" + config = {"responses": {"cont": {"PMrel": 1.0}}, + "time": {"range": [2020, 2051, 1]}} + with pytest.raises(AssertionError): + oac.calc_cont_rf(config, {}, {}) + \ No newline at end of file diff --git a/utils/create_test_data.py b/utils/create_test_data.py index 46c0c8d..fa15d0c 100644 --- a/utils/create_test_data.py +++ b/utils/create_test_data.py @@ -2,9 +2,14 @@ Creates data objects for testing """ +import sys +import os import numpy as np import xarray as xr -import create_artificial_inventories as cai + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.append(os.path.dirname(SCRIPT_DIR)) +from utils.create_artificial_inventories import ArtificialInventory def create_test_conc_resp(): @@ -87,5 +92,48 @@ def create_test_inv(year=2020, size=3): xr.Dataset: An xarray dataset with random inventory data. """ - inv = cai.ArtificialInventory(year, size=size).create() + inv = ArtificialInventory(year, size=size).create() return inv + + +def create_test_resp_cont(n_lat=48, n_lon=96, n_plev=39, seed=None): + """Creates example precalculated contrail input data for testing purposes. + + Args: + n_lat (int, optional): Number of latitude values. Defaults to 48. + n_lon (int, optional): Number of longitude values. Defaults to 96. + n_plev (int, optional): Number of pressure level values. Defaults to 39. + seed (int, optional): Random seed. + + Returns: + xr.Dataset: Example precalculated contrail input data. + """ + + # set random seed + np.random.seed(seed) + + # Create the coordinates + lon = np.linspace(0, 360, n_lon, endpoint=False) + lat = np.linspace(90, -90, n_lat + 2)[1:-1] # do not include 90 or -90 + plev = np.linspace(1014, 10, n_plev) + + # Create the data variables with random values between 0 and 1 + iss = np.random.rand(n_lat, n_lon) + sac_con = np.random.rand(n_lat, n_lon, n_plev) + sac_lh2 = np.random.rand(n_lat, n_lon, n_plev) + + # Combine into an xarray Dataset + ds_cont = xr.Dataset( + { + "ISS": (["lat", "lon"], iss), + "SAC_CON": (["lat", "lon", "plev"], sac_con), + "SAC_LH2": (["lat", "lon", "plev"], sac_lh2) + }, + coords={ + "lon": ("lon", lon), + "lat": ("lat", lat), + "plev": ("plev", plev) + } + ) + + return ds_cont diff --git a/utils/create_test_files.py b/utils/create_test_files.py index 6de47fa..b86eb2f 100644 --- a/utils/create_test_files.py +++ b/utils/create_test_files.py @@ -1,7 +1,11 @@ """Create files for testing purposes""" +import sys import os -import create_test_data as ctd +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.append(os.path.dirname(SCRIPT_DIR)) + +from utils.create_test_data import create_test_inv, create_test_rf_resp # CONSTANTS @@ -85,7 +89,7 @@ def create_test_inv_nc(repo_path, inv_name): if os.path.isfile(file_path): msg = "Overwrite existing file " + file_path print(msg) - inv = ctd.create_test_inv() + inv = create_test_inv() inv.to_netcdf(file_path) @@ -107,7 +111,7 @@ def create_test_resp_nc(repo_path, resp_name): if os.path.isfile(file_path): msg = "Overwrite existing file " + file_path print(msg) - resp = ctd.create_test_rf_resp() + resp = create_test_rf_resp() resp.to_netcdf(file_path)