diff --git a/src/CSET/cset_workflow/meta/diagnostics/rose-meta.conf b/src/CSET/cset_workflow/meta/diagnostics/rose-meta.conf index 77483988b..9dbf34512 100644 --- a/src/CSET/cset_workflow/meta/diagnostics/rose-meta.conf +++ b/src/CSET/cset_workflow/meta/diagnostics/rose-meta.conf @@ -1866,6 +1866,18 @@ type=python_boolean compulsory=true sort-key=xp-maulp3 +[template variables=SATURATION_FRACTION] +ns = Diagnostics/Derived/XPPN +title= Saturation fraction spatial plot +description=PROCESS-BASED DIAGNOSTIC. + Determines the saturation fraction of a column in the atmosphere. + Requires "air temperature", "air_pressure" and "specific_humidity" on model levels. +help=This diagnostic identifies the moisture throughout the column. The larger + the saturation fraction, the more moisture there is in depth throughout the column. + +type=python_boolean +compulsory=true +sort-key=xp-satfrac ################################### # Ensembles [Diagnostics/Ensembles] diff --git a/src/CSET/cset_workflow/rose-suite.conf.example b/src/CSET/cset_workflow/rose-suite.conf.example index 5a8612e3f..bde0a6585 100644 --- a/src/CSET/cset_workflow/rose-suite.conf.example +++ b/src/CSET/cset_workflow/rose-suite.conf.example @@ -125,6 +125,7 @@ PROFILE_PLEVEL_AGGREGATION=False,False,False,False RAIN_PRESENCE_DOMAIN_MEAN_TIMESERIES=False RAIN_PRESENCE_SPATIAL_DIFFERENCE=False RAIN_PRESENCE_SPATIAL_PLOT=False +SATURATION_FRACTION=False SCREEN_LEVEL_TEMPERATURE_PROBABILITIES=False !!SCREEN_LEVEL_TEMPERATURE_SPATIAL_PROBABILITY_WITHOUT_CONTROL_MEMBER=False SELECT_SUBAREA=False diff --git a/src/CSET/loaders/spatial_field.py b/src/CSET/loaders/spatial_field.py index 1517c6816..87ca867ec 100644 --- a/src/CSET/loaders/spatial_field.py +++ b/src/CSET/loaders/spatial_field.py @@ -651,6 +651,22 @@ def load(conf: Config): aggregation=False, ) + # Saturation fraction + if conf.SATURATION_FRACTION: + for model in models: + yield RawRecipe( + recipe="saturation_fraction_spatial_plot.yaml", + variables={ + "MODEL_NAME": model["name"], + "SUBAREA_TYPE": conf.SUBAREA_TYPE if conf.SELECT_SUBAREA else None, + "SUBAREA_EXTENT": conf.SUBAREA_EXTENT + if conf.SELECT_SUBAREA + else None, + }, + model_ids=model["id"], + aggregation=False, + ) + # Screen-level temperature probabilities for model, condition, threshold in itertools.product( models, diff --git a/src/CSET/operators/humidity.py b/src/CSET/operators/humidity.py index 0854397f1..ebd572b60 100644 --- a/src/CSET/operators/humidity.py +++ b/src/CSET/operators/humidity.py @@ -15,9 +15,11 @@ """Operators for humidity conversions.""" import iris.cube +import numpy as np from CSET._common import iter_maybe from CSET.operators._atmospheric_constants import EPSILON +from CSET.operators._utils import get_cube_coordindex from CSET.operators.misc import convert_units from CSET.operators.pressure import vapour_pressure @@ -443,3 +445,246 @@ def relative_humidity_from_specific_humidity( return RH[0] else: return RH + + +def precipitable_water( + mixing_ratio: iris.cube.Cube | iris.cube.CubeList, +) -> iris.cube.Cube | iris.cube.CubeList: + r"""Calculate the precipitable water. + + Arguments + --------- + mixing_ratio: iris.cube.Cube | iris.cube.CubeList + A cube or cubelist of the mixing ratio. It can be + calculated within a recipe or a direct model output. + + Returns + ------- + iris.cube.Cube | iris.cube.CubeList + A cube or cubelist of the precipitable water. + + Notes + ----- + The precipitable water is the total depth of liquid water produced by + condensing all of the moisture in a column of the atmopshere. + + It can be calculated as + + .. math:: pw = frac{1}{\rho_w} \int w dz + + for pw the precipitable water, ..math::\rho_{w} the density of water, + w the mixing ratio, and z the height. It is integrated from the surface + to the top of the atmosphere. + + Generally, the precipitable water is widely applicable across the globe. + It is likely that larger precipitation totals are associated with greater + precipitable water. However, this is not strictly the case and you can get + lower precipitable water values with large precipitaiton amounts + (e.g. [Daviesetal24]_). Therefore, caution is needed with its interpretation. + A diagnostic such as saturation fraction maybe more beneficial (e.g. [Daviesetal26]_). + + Examples + -------- + >>> pwat = humidity.precipitable_water(mixing_ratio) + + References + ---------- + .. [Daviesetal24] Davies, P.A., Fowler, H.J, Villalobos-Herrera, R., + Slingo, J., Flack, D.L.A., and Taszarek, M (2024) + "A New Conceptual Model for Understanding and Predicting Life-Threatening + Rainfall Extremes." Weather and Climate Extremes, vol. 45, 100696, + doi: 10.1016/j.wace.2024.100696 + .. [Daviesetal26] Davies, P. A., Flack, D. L. A., Pirret, J., Fowler, H. J. + (2026) "Application of the Davies Four-Stage Conceptual Model for + Life-Threatening Rainfall Extremes on the April 2024 United Arab Emirates + and Oman Floods." Weather and Climate Extremes, vol. 51, 100846. + doi:10.1016/j.wace.2025.100846 + """ + precipitable_water = iris.cube.CubeList([]) + for w in iter_maybe(mixing_ratio): + # Integrate the data in the vertical using np.trapezoid + # (following trapezoid rule). + pw = np.trapezoid( + w.data, + x=w.coord("level_height").points[:], + axis=get_cube_coordindex(w, "level_height"), + ) + # Determine array information of input cube to get + # correct cube to copy across to. + if len(w.coord("realization").points) != 1 and len(w.coord("time").points) != 1: + pwat = w[:, :, 0, :, :].copy() + elif ( + len(w.coord("realization").points) != 1 and len(w.coord("time").points) == 1 + ): + pwat = w[:, 0, :, :].copy() + elif ( + len(w.coord("time").points) != 1 and len(w.coord("realization").points) == 1 + ): + pwat = w[:, 0, :, :].copy() + else: + pwat = w[0, :, :].copy() + # Create the data array, rename, and correct units. + pwat.data = pw + pwat.rename("precipitable_water") + # Setting units to mm to account for normalization by density of water. + pwat.units = "mm" + precipitable_water.append(pwat) + # Output the data. + if len(precipitable_water) == 1: + return precipitable_water[0] + else: + return precipitable_water + + +def saturation_precipitable_water( + mixing_ratio: iris.cube.Cube | iris.cube.CubeList, + relative_humidity: iris.cube.Cube | iris.cube.CubeList, +) -> iris.cube.Cube | iris.cube.CubeList: + r"""Calculate saturation precipitable water. + + Arguments + --------- + mixing_ratio: iris.cube.Cube | iris.cube.CubeList + A cube or cubelist of the mixing ratio. It can be + calculated within a recipe or a direct model output. + relative_humidity: iris.cube.Cube | iris.cube.CubeList + A cube or cubelist of the relative humidity. It can + either be calculated or used as model output. + + Returns + ------- + iris.cube.Cube | iris.cube.CubeList + A cube or cubelist of the saturation precipitable water. + + Notes + ----- + The saturation precipitable water is equivalent to the precipitable + water assuming that the atmosphere was fully saturated. + + It can be calculated as + + .. math:: spw = frac{1}{\rho_w} \int \frac{w}{RH} dz + + for spw the saturated precipitable water, ..math::\rho_{w} the density of water, + w the mixing ratio, RH the relative humidity (as a decimal) and z the height. + It is integrated from the surface to the top of the atmosphere. + + It is applicable throughout the globe and is, perhaps, best considered + in relation to the precipitable water. A useful way to do this is + via the saturation fraction. + + Examples + -------- + >>> sat_pwat = humidity.saturated_precipitable_water(mixing_ratio, RH) + """ + saturation_precipitable_water = iris.cube.CubeList([]) + for w, rh in zip( + iter_maybe(mixing_ratio), iter_maybe(relative_humidity), strict=True + ): + # Integrate the data in the vertical using np.trapezoid + # (following trapezoid rule). + rh = convert_units(rh, "1") + spw = np.trapezoid( + (w / rh).data, + x=w.coord("level_height").points[:], + axis=get_cube_coordindex(w, "level_height"), + ) + # Determine array information of input cube to get + # correct cube to copy across to. + if len(w.coord("realization").points) != 1 and len(w.coord("time").points) != 1: + satpw = w[:, :, 0, :, :].copy() + elif ( + len(w.coord("realization").points) != 1 and len(w.coord("time").points) == 1 + ): + satpw = w[:, 0, :, :].copy() + elif ( + len(w.coord("time").points) != 1 and len(w.coord("realization").points) == 1 + ): + satpw = w[:, 0, :, :].copy() + else: + satpw = w[0, :, :].copy() + # Store the data for output, rename cube, and correct units. + satpw.data = spw + satpw.rename("saturation_precipitable_water") + # Setting units to mm to account for normalization by density of water. + satpw.units = "mm" + saturation_precipitable_water.append(satpw) + # Output cube/cubelist. + if len(saturation_precipitable_water) == 1: + return saturation_precipitable_water[0] + else: + return saturation_precipitable_water + + +def saturation_fraction( + mixing_ratio: iris.cube.Cube | iris.cube.CubeList, + relative_humidity: iris.cube.Cube | iris.cube.CubeList, +) -> iris.cube.Cube | iris.cube.CubeList: + r"""Calculate saturation fraction. + + Arguments + --------- + mixing_ratio: iris.cube.Cube | iris.cube.CubeList + A cube or cubelist of the mixing ratio. It can be + calculated within a recipe or a direct model output. + relative_humidity: iris.cube.Cube | iris.cube.CubeList + A cube or cubelist of the relative humidity. It can be + calculated within a recipe or used as a direct model output. + + Returns + ------- + iris.cube.Cube | iris.cube.CubeList + A cube or cubelist of the saturation fraction. + + Notes + ----- + The saturation fraction indicates how moist a column of the atmosphere + is. A value close to one implies that the atmosphere is fully saturated + throughout the entire column. Smaller values imply the atmosphere is + drier throughout the column. It is based around ideas of specific entropy + ([Zengetal05]_) but can be simplified to an approximation following [Daviesetal2026]_. + + It can be approximated as + + .. math:: saturation_fraction = \frac{precipitable_water}{saturation_precipitable_water} + + and can be used throughout the globe with the same interpretation. + + For a recent, example, [Daviesetal2026]_ have applied the concept to their + conceptual model for extreme rainfall. Thus it is a potentially useful diagnostic + to consider for extreme events, and is thought of as more reliable than + using precipitable water on its own. + + Examples + -------- + >>> sf = humidity.saturation_fraction(mixing_ratio, relative_humidity) + + References + ---------- + .. [Daviesetal2026] Davies, P. A., Flack, D. L. A., Pirret, J., Fowler, H. J. + (2026) "Application of the Davies Four-Stage Conceptual Model for + Life-Threatening Rainfall Extremes on the April 2024 United Arab Emirates + and Oman Floods." Weather and Climate Extremes, vol. 51, 100846. + doi:10.1016/j.wace.2025.100846 + .. [Zengetal05] Zeng, X., Tao, W-K, and Simpson, J. (2005) "An Equation for Moist + Entropy in a Precipitating and Icy Atmosphere" Journal of the Atmospheric Sciences, + vol. 2, 4293-4309, doi: 10.1175/JAS3570.1 + """ + saturation_fraction = iris.cube.CubeList([]) + for w, rh in zip( + iter_maybe(mixing_ratio), iter_maybe(relative_humidity), strict=True + ): + # Calculate both precipitable water and saturation + # precipitable water. + pw = precipitable_water(w) + spw = saturation_precipitable_water(w, rh) + # Calculate the saturation fraction by taking the ratio. + sf = pw / spw + # Rename the cube and append to cubelist. + sf.rename("saturation_fraction") + saturation_fraction.append(sf) + # Output the cube/cubelist. + if len(saturation_fraction) == 1: + return saturation_fraction[0] + else: + return saturation_fraction diff --git a/src/CSET/operators/precipitation.py b/src/CSET/operators/precipitation.py index a7e23b513..7f4316a2a 100644 --- a/src/CSET/operators/precipitation.py +++ b/src/CSET/operators/precipitation.py @@ -68,6 +68,20 @@ def MAUL_properties( If number of MAULs is the desired output it will be set to zero. The MAUL diagnostic is applicable anywhere in the globe and across all scales. + The properties used here are based upon [Daviesetal24]_ and [Daviesetal26]_. + + References + ---------- + .. [Daviesetal24] Davies, P.A., Fowler, H.J, Villalobos-Herrera, R., + Slingo, J., Flack, D.L.A., and Taszarek, M (2024) + "A New Conceptual Model for Understanding and Predicting Life-Threatening + Rainfall Extremes." Weather and Climate Extremes, vol. 45, 100696, + doi: 10.1016/j.wace.2024.100696 + .. [Daviesetal26] Davies, P. A., Flack, D. L. A., Pirret, J., Fowler, H. J. + (2026) "Application of the Davies Four-Stage Conceptual Model for + Life-Threatening Rainfall Extremes on the April 2024 United Arab Emirates + and Oman Floods." Weather and Climate Extremes, vol. 51, 100846. + doi:10.1016/j.wace.2025.100846 """ num_MAULs = iris.cube.CubeList([]) maul_d = iris.cube.CubeList([]) diff --git a/src/CSET/recipes/derived_diagnostics/precipitation/extreme_precipitation/saturation_fraction_spatial_plot.yaml b/src/CSET/recipes/derived_diagnostics/precipitation/extreme_precipitation/saturation_fraction_spatial_plot.yaml new file mode 100644 index 000000000..4006c0d8a --- /dev/null +++ b/src/CSET/recipes/derived_diagnostics/precipitation/extreme_precipitation/saturation_fraction_spatial_plot.yaml @@ -0,0 +1,71 @@ +category: Extreme precipitation +title: $MODEL_NAME Saturation fraction spatial plot +description: | + Generates a spatial plot of the saturation fraction. The saturation fraction + is calculated from the specific humidity converted to a mixing ratio, and the + specific humidity, temperature, and air pressure to calculate the relative + humidity. From these diagnostics the precipitable water and saturation + precipitable water are derived, before their ratios are taken for the + saturation fraction. + + A saturation fraction close to one implies that the atmopshere is fully + saturated throughout the column. Smaller values imply the atmosphere is dry. + Traditionally for precipitation the saturation fraction is greater than 0.6. + The saturation fraction is applicable throughout the globe. + +steps: + - operator: read.read_cubes + file_paths: $INPUT_PATHS + model_names: $MODEL_NAME + constraints: ["air_temperature", "air_pressure", "specific_humidity"] + subarea_type: $SUBAREA_TYPE + subarea_extent: $SUBAREA_EXTENT + + - operator: humidity.saturation_fraction + mixing_ratio: + operator: humidity.mixing_ratio_from_specific_humidity + specific_humidity: + operator: filters.filter_multiple_cubes + constraint: + operator: constraints.combine_constraints + constraint_1: + operator: constraints.generate_var_constraint + varname: "specific_humidity" + constraint_2: + operator: constraints.generate_remove_single_level_constraint + coord: "model_level_number" + level: 0 + relative_humidity: + operator: humidity.relative_humidity_from_specific_humidity + specific_humidity: + operator: filters.filter_multiple_cubes + constraint: + operator: constraints.combine_constraints + constraint_1: + operator: constraints.generate_var_constraint + varname: "specific_humidity" + constraint_2: + operator: constraints.generate_remove_single_level_constraint + coord: "model_level_number" + level: 0 + temperature: + operator: filters.filter_multiple_cubes + constraint: + operator: constraints.generate_var_constraint + varname: "air_temperature" + pressure: + operator: filters.filter_multiple_cubes + constraint: + operator: constraints.combine_constraints + constraint_1: + operator: constraints.generate_var_constraint + varname: "m01s00i408" + constraint_2: + operator: constraints.generate_remove_single_level_constraint + coord: "model_level_number" + level: 0 + + - operator: plot.spatial_pcolormesh_plot + + - operator: write.write_cube_to_nc + overwrite: True diff --git a/tests/conftest.py b/tests/conftest.py index 4daba7627..b374f167b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -585,3 +585,207 @@ def precalc_maul_depth_5d_read_only(): def precalc_maul_depth_5d(precalc_maul_depth_5d_read_only): """Get precalculated depth for 5D data. It is safe to modify.""" return precalc_maul_depth_5d_read_only.copy() + + +@pytest.fixture() +def mr_3d_read_only(): + """Get mixing ratio 3D data. It is NOT safe to modify.""" + return read.read_cube("tests/test_data/humidity/mr_basic.nc") + + +@pytest.fixture() +def mr_3d(mr_3d_read_only): + """Get mixing ratio 3D data. It is safe to modify.""" + return mr_3d_read_only.copy() + + +@pytest.fixture() +def mr_time_read_only(): + """Get mixing ratio 4D data varying in time. It is NOT safe to modify.""" + return read.read_cube("tests/test_data/humidity/mr_time.nc") + + +@pytest.fixture() +def mr_time(mr_time_read_only): + """Get mixing ratio 4D data varying in time. It is safe to modify.""" + return mr_time_read_only.copy() + + +@pytest.fixture() +def mr_member_read_only(): + """Get mixing ratio 4D data varying in member. It is NOT safe to modify.""" + return read.read_cube("tests/test_data/humidity/mr_member.nc") + + +@pytest.fixture() +def mr_member(mr_member_read_only): + """Get mixing ratio 4D data varying in member. It is safe to modify.""" + return mr_member_read_only.copy() + + +@pytest.fixture() +def mr_5d_read_only(): + """Get mixing ratio 5D data. It is NOT safe to modify.""" + return read.read_cube("tests/test_data/humidity/mr_all.nc") + + +@pytest.fixture() +def mr_5d(mr_5d_read_only): + """Get mixing ratio 5D data. It is safe to modify.""" + return mr_5d_read_only.copy() + + +@pytest.fixture() +def rh_3d_read_only(): + """Get relative humidity 3D data. It is NOT safe to modify.""" + return read.read_cube("tests/test_data/humidity/rh_basic.nc") + + +@pytest.fixture() +def rh_3d(rh_3d_read_only): + """Get relative humidity 3D data. It is safe to modify.""" + return rh_3d_read_only.copy() + + +@pytest.fixture() +def rh_time_read_only(): + """Get relative humidity 4D data varying in time. It is NOT safe to modify.""" + return read.read_cube("tests/test_data/humidity/rh_time.nc") + + +@pytest.fixture() +def rh_time(rh_time_read_only): + """Get relative humidity 4D data varying in time. It is safe to modify.""" + return rh_time_read_only.copy() + + +@pytest.fixture() +def rh_member_read_only(): + """Get relative humidity 4D data varying in member. It is NOT safe to modify.""" + return read.read_cube("tests/test_data/humidity/rh_member.nc") + + +@pytest.fixture() +def rh_member(rh_member_read_only): + """Get relative humidity 4D data varying in member. It is safe to modify.""" + return rh_member_read_only.copy() + + +@pytest.fixture() +def rh_5d_read_only(): + """Get relative humidity 5D data. It is NOT safe to modify.""" + return read.read_cube("tests/test_data/humidity/rh_all.nc") + + +@pytest.fixture() +def rh_5d(rh_5d_read_only): + """Get relative humidity 5D data. It is safe to modify.""" + return rh_5d_read_only.copy() + + +@pytest.fixture() +def pw_3d_read_only(): + """Get precipitable water 3D data. It is NOT safe to modify.""" + return read.read_cube("tests/test_data/humidity/pw_basic.nc") + + +@pytest.fixture() +def pw_3d(pw_3d_read_only): + """Get precipitable water 3D data. It is safe to modify.""" + return pw_3d_read_only.copy() + + +@pytest.fixture() +def pw_time_read_only(): + """Get precipitable water 4D data varying in time. It is NOT safe to modify.""" + return read.read_cube("tests/test_data/humidity/pw_time.nc") + + +@pytest.fixture() +def pw_time(pw_time_read_only): + """Get precipitable water 4D data varying in time. It is safe to modify.""" + return pw_time_read_only.copy() + + +@pytest.fixture() +def pw_member_read_only(): + """Get precipitable water 4D data varying in member. It is NOT safe to modify.""" + return read.read_cube("tests/test_data/humidity/pw_member.nc") + + +@pytest.fixture() +def pw_member(pw_member_read_only): + """Get precipitable water 4D data varying in member. It is safe to modify.""" + return pw_member_read_only.copy() + + +@pytest.fixture() +def pw_5d_read_only(): + """Get precipitable water 5D data. It is NOT safe to modify.""" + return read.read_cube("tests/test_data/humidity/pw_all.nc") + + +@pytest.fixture() +def pw_5d(pw_5d_read_only): + """Get precipitable water 5D data. It is safe to modify.""" + return pw_5d_read_only.copy() + + +@pytest.fixture() +def spw_3d_read_only(): + """Get saturation precipitable water 3D data. It is NOT safe to modify.""" + return read.read_cube("tests/test_data/humidity/spw_basic.nc") + + +@pytest.fixture() +def spw_3d(spw_3d_read_only): + """Get saturation precipitable water 3D data. It is safe to modify.""" + return spw_3d_read_only.copy() + + +@pytest.fixture() +def spw_time_read_only(): + """Get saturation precipitable water 4D data varying in time. It is NOT safe to modify.""" + return read.read_cube("tests/test_data/humidity/spw_time.nc") + + +@pytest.fixture() +def spw_time(spw_time_read_only): + """Get saturation precipitable water 4D data varying in time. It is safe to modify.""" + return spw_time_read_only.copy() + + +@pytest.fixture() +def spw_member_read_only(): + """Get saturation precipitable water 4D data varying in member. It is NOT safe to modify.""" + return read.read_cube("tests/test_data/humidity/spw_member.nc") + + +@pytest.fixture() +def spw_member(spw_member_read_only): + """Get saturation precipitable water 4D data varying in member. It is safe to modify.""" + return spw_member_read_only.copy() + + +@pytest.fixture() +def spw_5d_read_only(): + """Get saturation precipitable water 5D data. It is NOT safe to modify.""" + return read.read_cube("tests/test_data/humidity/spw_all.nc") + + +@pytest.fixture() +def spw_5d(spw_5d_read_only): + """Get saturation precipitable water 5D data. It is safe to modify.""" + return spw_5d_read_only.copy() + + +@pytest.fixture() +def sf_3d_read_only(): + """Get saturation fraction 3D data. It is NOT safe to modify.""" + return read.read_cube("tests/test_data/humidity/sf_basic.nc") + + +@pytest.fixture() +def sf_3d(sf_3d_read_only): + """Get saturation fraction 3D data. It is safe to modify.""" + return sf_3d_read_only.copy() diff --git a/tests/operators/test_humidity.py b/tests/operators/test_humidity.py index 9ff94d14d..c7e20eb49 100644 --- a/tests/operators/test_humidity.py +++ b/tests/operators/test_humidity.py @@ -722,3 +722,148 @@ def test_relative_humidity_from_specific_humidity_cubelist( ) for cube_a, cube_b in zip(expected_list, actual_cubelist, strict=True): assert np.allclose(cube_a.data, cube_b.data, rtol=1e-6, atol=1e-2) + + +def test_precipitable_water(mr_3d, pw_3d): + """Test calculation of precipitable water for 3D data.""" + assert np.allclose( + pw_3d.data, humidity.precipitable_water(mr_3d).data, rtol=1e-6, atol=1e-2 + ) + + +def test_precipitable_water_cubelist(mr_3d, pw_3d): + """Test calculation of precipitable water in a cubelist.""" + input_cube = iris.cube.CubeList([mr_3d, mr_3d]) + actual_cubelist = humidity.precipitable_water(input_cube) + expected_cubelist = iris.cube.CubeList([pw_3d, pw_3d]) + for cube_a, cube_b in zip(expected_cubelist, actual_cubelist, strict=True): + assert np.allclose(cube_a.data, cube_b.data, rtol=1e-6, atol=1e-2) + + +def test_precipitable_water_name(mr_3d): + """Test name of precipitable water cube.""" + assert humidity.precipitable_water(mr_3d).name() == "precipitable_water" + + +def test_precipitable_water_units(mr_3d): + """Test units of precipitable water cube.""" + assert humidity.precipitable_water(mr_3d).units == cf_units.Unit("mm") + + +def test_precipitable_water_time(mr_time, pw_time): + """Test precipitable water for cube varying in time.""" + assert np.allclose( + pw_time.data, humidity.precipitable_water(mr_time).data, rtol=1e-6, atol=1e-2 + ) + + +def test_precipitable_water_member(mr_member, pw_member): + """Test precipitable water for cube varying in member.""" + assert np.allclose( + pw_member.data, + humidity.precipitable_water(mr_member).data, + rtol=1e-6, + atol=1e-2, + ) + + +def test_precipitable_water_5d(mr_5d, pw_5d): + """Test precipitable water for 5D data.""" + assert np.allclose( + pw_5d.data, humidity.precipitable_water(mr_5d).data, rtol=1e-6, atol=1e-2 + ) + + +def test_saturation_precipitable_water(mr_3d, rh_3d, spw_3d): + """Test calculation of saturation precipitable water for 3D data.""" + assert np.allclose( + spw_3d.data, + humidity.saturation_precipitable_water(mr_3d, rh_3d).data, + rtol=1e-6, + atol=1e-2, + ) + + +def test_saturation_precipitable_water_cubelist(mr_3d, rh_3d, spw_3d): + """Test calculation of saturation precipitable water in a cubelist.""" + input_cube = iris.cube.CubeList([mr_3d, mr_3d]) + input_rh = iris.cube.CubeList([rh_3d, rh_3d]) + actual_cubelist = humidity.saturation_precipitable_water(input_cube, input_rh) + expected_cubelist = iris.cube.CubeList([spw_3d, spw_3d]) + for cube_a, cube_b in zip(expected_cubelist, actual_cubelist, strict=True): + assert np.allclose(cube_a.data, cube_b.data, rtol=1e-6, atol=1e-2) + + +def test_saturation_precipitable_water_name(mr_3d, rh_3d): + """Test name of saturation precipitable water cube.""" + assert ( + humidity.saturation_precipitable_water(mr_3d, rh_3d).name() + == "saturation_precipitable_water" + ) + + +def test_saturation_precipitable_water_units(mr_3d, rh_3d): + """Test units of saturation precipitable water cube.""" + assert humidity.saturation_precipitable_water(mr_3d, rh_3d).units == cf_units.Unit( + "mm" + ) + + +def test_saturation_precipitable_water_time(mr_time, rh_time, spw_time): + """Test saturation precipitable water for cube varying in time.""" + assert np.allclose( + spw_time.data, + humidity.saturation_precipitable_water(mr_time, rh_time).data, + rtol=1e-6, + atol=1e-2, + ) + + +def test_saturation_precipitable_water_member(mr_member, rh_member, spw_member): + """Test saturation precipitable water for cube varying in member.""" + assert np.allclose( + spw_member.data, + humidity.saturation_precipitable_water(mr_member, rh_member).data, + rtol=1e-6, + atol=1e-2, + ) + + +def test_saturation_precipitable_water_5d(mr_5d, rh_5d, spw_5d): + """Test saturation precipitable water for 5D data.""" + assert np.allclose( + spw_5d.data, + humidity.saturation_precipitable_water(mr_5d, rh_5d).data, + rtol=1e-6, + atol=1e-2, + ) + + +def test_saturation_fraction(mr_3d, rh_3d, sf_3d): + """Test calculation of saturation fraction for 3D data.""" + assert np.allclose( + sf_3d.data, + humidity.saturation_fraction(mr_3d, rh_3d).data, + rtol=1e-6, + atol=1e-2, + ) + + +def test_saturation_fraction_cubelist(mr_3d, rh_3d, sf_3d): + """Test calculation of saturation fraction in a cubelist.""" + input_cube = iris.cube.CubeList([mr_3d, mr_3d]) + input_rh = iris.cube.CubeList([rh_3d, rh_3d]) + actual_cubelist = humidity.saturation_fraction(input_cube, input_rh) + expected_cubelist = iris.cube.CubeList([sf_3d, sf_3d]) + for cube_a, cube_b in zip(expected_cubelist, actual_cubelist, strict=True): + assert np.allclose(cube_a.data, cube_b.data, rtol=1e-6, atol=1e-2) + + +def test_saturation_fraction_name(mr_3d, rh_3d): + """Test name of saturation fraction cube.""" + assert humidity.saturation_fraction(mr_3d, rh_3d).name() == "saturation_fraction" + + +def test_saturation_fraction_units(mr_3d, rh_3d): + """Test units of saturation fraction cube.""" + assert humidity.saturation_fraction(mr_3d, rh_3d).units == cf_units.Unit("1") diff --git a/tests/test_data/humidity/mr_all.nc b/tests/test_data/humidity/mr_all.nc new file mode 100644 index 000000000..0062b576b Binary files /dev/null and b/tests/test_data/humidity/mr_all.nc differ diff --git a/tests/test_data/humidity/mr_basic.nc b/tests/test_data/humidity/mr_basic.nc new file mode 100644 index 000000000..504de1007 Binary files /dev/null and b/tests/test_data/humidity/mr_basic.nc differ diff --git a/tests/test_data/humidity/mr_member.nc b/tests/test_data/humidity/mr_member.nc new file mode 100644 index 000000000..c91835823 Binary files /dev/null and b/tests/test_data/humidity/mr_member.nc differ diff --git a/tests/test_data/humidity/mr_time.nc b/tests/test_data/humidity/mr_time.nc new file mode 100644 index 000000000..c4066a5a4 Binary files /dev/null and b/tests/test_data/humidity/mr_time.nc differ diff --git a/tests/test_data/humidity/pw_all.nc b/tests/test_data/humidity/pw_all.nc new file mode 100644 index 000000000..17b099456 Binary files /dev/null and b/tests/test_data/humidity/pw_all.nc differ diff --git a/tests/test_data/humidity/pw_basic.nc b/tests/test_data/humidity/pw_basic.nc new file mode 100644 index 000000000..c9a13724a Binary files /dev/null and b/tests/test_data/humidity/pw_basic.nc differ diff --git a/tests/test_data/humidity/pw_member.nc b/tests/test_data/humidity/pw_member.nc new file mode 100644 index 000000000..1bc7703ac Binary files /dev/null and b/tests/test_data/humidity/pw_member.nc differ diff --git a/tests/test_data/humidity/pw_time.nc b/tests/test_data/humidity/pw_time.nc new file mode 100644 index 000000000..e42d41afa Binary files /dev/null and b/tests/test_data/humidity/pw_time.nc differ diff --git a/tests/test_data/humidity/rh_all.nc b/tests/test_data/humidity/rh_all.nc new file mode 100644 index 000000000..bfeab4ebb Binary files /dev/null and b/tests/test_data/humidity/rh_all.nc differ diff --git a/tests/test_data/humidity/rh_basic.nc b/tests/test_data/humidity/rh_basic.nc new file mode 100644 index 000000000..7b490395f Binary files /dev/null and b/tests/test_data/humidity/rh_basic.nc differ diff --git a/tests/test_data/humidity/rh_member.nc b/tests/test_data/humidity/rh_member.nc new file mode 100644 index 000000000..4c4a3d5af Binary files /dev/null and b/tests/test_data/humidity/rh_member.nc differ diff --git a/tests/test_data/humidity/rh_time.nc b/tests/test_data/humidity/rh_time.nc new file mode 100644 index 000000000..bc0ef933f Binary files /dev/null and b/tests/test_data/humidity/rh_time.nc differ diff --git a/tests/test_data/humidity/sf_basic.nc b/tests/test_data/humidity/sf_basic.nc new file mode 100644 index 000000000..2057bdc6e Binary files /dev/null and b/tests/test_data/humidity/sf_basic.nc differ diff --git a/tests/test_data/humidity/spw_all.nc b/tests/test_data/humidity/spw_all.nc new file mode 100644 index 000000000..06225db94 Binary files /dev/null and b/tests/test_data/humidity/spw_all.nc differ diff --git a/tests/test_data/humidity/spw_basic.nc b/tests/test_data/humidity/spw_basic.nc new file mode 100644 index 000000000..5263b471d Binary files /dev/null and b/tests/test_data/humidity/spw_basic.nc differ diff --git a/tests/test_data/humidity/spw_member.nc b/tests/test_data/humidity/spw_member.nc new file mode 100644 index 000000000..912179750 Binary files /dev/null and b/tests/test_data/humidity/spw_member.nc differ diff --git a/tests/test_data/humidity/spw_time.nc b/tests/test_data/humidity/spw_time.nc new file mode 100644 index 000000000..d37c3e2c0 Binary files /dev/null and b/tests/test_data/humidity/spw_time.nc differ