diff --git a/src/CSET/operators/__init__.py b/src/CSET/operators/__init__.py index ef40fdf22..bb9247c75 100644 --- a/src/CSET/operators/__init__.py +++ b/src/CSET/operators/__init__.py @@ -34,12 +34,15 @@ convection, ensembles, filters, + humidity, imageprocessing, mesoscale, misc, plot, + pressure, read, regrid, + temperature, transect, wind, write, @@ -56,13 +59,16 @@ "ensembles", "execute_recipe", "filters", + "humidity", "get_operator", "imageprocessing", "mesoscale", "misc", "plot", + "pressure", "read", "regrid", + "temperature", "transect", "wind", "write", diff --git a/src/CSET/operators/_atmospheric_constants.py b/src/CSET/operators/_atmospheric_constants.py new file mode 100644 index 000000000..85390b730 --- /dev/null +++ b/src/CSET/operators/_atmospheric_constants.py @@ -0,0 +1,41 @@ +# © Crown copyright, Met Office (2022-2026) and CSET contributors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Constants for the atmosphere.""" + +# Reference pressure. +P0 = 1000.0 # hPa + +# Specific gas constant for dry air. +RD = 287.0 + +# Specific gas constant for water vapour. +RV = 461.0 + +# Specific heat capacity for dry air. +CPD = 1005.7 + +# Latent heat of vaporization. +LV = 2.501e6 + +# Reference vapour pressure. +E0 = 6.1078 # hPa + +# Reference temperature. +T0 = 273.15 # K + +# Ratio between mixing ratio of dry and moist air. +EPSILON = 0.622 + +# Ratio between specific gas constant and specific heat capacity. +KAPPA = RD / CPD diff --git a/src/CSET/operators/humidity.py b/src/CSET/operators/humidity.py new file mode 100644 index 000000000..9ef248d9e --- /dev/null +++ b/src/CSET/operators/humidity.py @@ -0,0 +1,184 @@ +# © Crown copyright, Met Office (2022-2026) and CSET contributors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Operators for humidity conversions.""" + +import iris.cube + +from CSET._common import iter_maybe +from CSET.operators._atmospheric_constants import EPSILON +from CSET.operators.misc import convert_units +from CSET.operators.pressure import vapour_pressure + + +def mixing_ratio_from_specific_humidity( + specific_humidity: iris.cube.Cube | iris.cube.CubeList, +) -> iris.cube.Cube | iris.cube.CubeList: + """Convert specific humidity to mixing ratio.""" + w = iris.cube.CubeList([]) + for q in iter_maybe(specific_humidity): + mr = q.copy() + mr = q / (1 - q) + mr.rename("mixing_ratio") + w.append(mr) + if len(w) == 1: + return w[0] + else: + return w + + +def specific_humidity_from_mixing_ratio( + mixing_ratio: iris.cube.Cube | iris.cube.CubeList, +) -> iris.cube.Cube | iris.cube.CubeList: + """Convert mixing ratio to specific humidity.""" + q = iris.cube.CubeList([]) + for w in iter_maybe(mixing_ratio): + sh = w.copy() + sh = w / (1 + w) + sh.rename("specific_humidity") + q.append(sh) + if len(q) == 1: + return q[0] + else: + return q + + +def saturation_mixing_ratio( + temperature: iris.cube.Cube | iris.cube.CubeList, + pressure: iris.cube.Cube | iris.cube.CubeList, +) -> iris.cube.Cube | iris.cube.CubeList: + """Calculate saturation mixing ratio.""" + w = iris.cube.CubeList([]) + for T, P in zip(iter_maybe(temperature), iter_maybe(pressure), strict=True): + P = convert_units(P, "hPa") + mr = (EPSILON * vapour_pressure(T)) / (P - vapour_pressure(T)) + mr.units = "kg/kg" + mr.rename("saturation_mixing_ratio") + w.append(mr) + if len(w) == 1: + return w[0] + else: + return w + + +def saturation_specific_humidity( + temperature: iris.cube.Cube | iris.cube.CubeList, + pressure: iris.cube.Cube | iris.cube.CubeList, +) -> iris.cube.Cube | iris.cube.CubeList: + """Calculate saturation specific humidity.""" + q = iris.cube.CubeList([]) + for T, P in zip(iter_maybe(temperature), iter_maybe(pressure), strict=True): + P = convert_units(P, "hPa") + sh = (EPSILON * vapour_pressure(T)) / P + sh.units = "kg/kg" + sh.rename("saturation_specific_humidity") + q.append(sh) + if len(q) == 1: + return q[0] + else: + return q + + +def mixing_ratio_from_relative_humidity( + temperature: iris.cube.Cube | iris.cube.CubeList, + pressure: iris.cube.Cube | iris.cube.CubeList, + relative_humidity: iris.cube.Cube | iris.cube.CubeList, +) -> iris.cube.Cube | iris.cube.CubeList: + """Calculate the mixing ratio from RH.""" + w = iris.cube.CubeList([]) + for T, P, RH in zip( + iter_maybe(temperature), + iter_maybe(pressure), + iter_maybe(relative_humidity), + strict=True, + ): + RH = convert_units(RH, "1") + mr = saturation_mixing_ratio(T, P) * RH + mr.rename("mixing_ratio") + mr.units = "kg/kg" + w.append(mr) + if len(w) == 1: + return w[0] + else: + return w + + +def specific_humidity_from_relative_humidity( + temperature: iris.cube.Cube | iris.cube.CubeList, + pressure: iris.cube.Cube | iris.cube.CubeList, + relative_humidity: iris.cube.Cube | iris.cube.CubeList, +) -> iris.cube.Cube | iris.cube.CubeList: + """Calculate the mixing ratio from RH.""" + q = iris.cube.CubeList([]) + for T, P, RH in zip( + iter_maybe(temperature), + iter_maybe(pressure), + iter_maybe(relative_humidity), + strict=True, + ): + RH = convert_units(RH, "1") + sh = saturation_specific_humidity(T, P) * RH + sh.rename("specific_humidity") + sh.units = "kg/kg" + q.append(sh) + if len(q) == 1: + return q[0] + else: + return q + + +def relative_humidity_from_mixing_ratio( + mixing_ratio: iris.cube.Cube | iris.cube.CubeList, + temperature: iris.cube.Cube | iris.cube.CubeList, + pressure: iris.cube.Cube | iris.cube.CubeList, +) -> iris.cube.Cube | iris.cube.CubeList: + """Convert mixing ratio to relative humidity.""" + RH = iris.cube.CubeList([]) + for W, T, P in zip( + iter_maybe(mixing_ratio), + iter_maybe(temperature), + iter_maybe(pressure), + strict=True, + ): + rel_h = W / saturation_mixing_ratio(T, P) + rel_h.rename("relative_humidity") + rel_h = convert_units(rel_h, "%") + RH.append(rel_h) + if len(RH) == 1: + return RH[0] + else: + return RH + + +def relative_humidity_from_specific_humidity( + specific_humidity: iris.cube.Cube | iris.cube.CubeList, + temperature: iris.cube.Cube | iris.cube.CubeList, + pressure: iris.cube.Cube | iris.cube.CubeList, +) -> iris.cube.Cube | iris.cube.CubeList: + """Convert specific humidity to relative humidity.""" + RH = iris.cube.CubeList([]) + for Q, T, P in zip( + iter_maybe(specific_humidity), + iter_maybe(temperature), + iter_maybe(pressure), + strict=True, + ): + rel_h = Q / saturation_specific_humidity(T, P) + rel_h.rename("relative_humidity") + rel_h = convert_units(rel_h, "%") + RH.append(rel_h) + if len(RH) == 1: + return RH[0] + else: + return RH diff --git a/src/CSET/operators/pressure.py b/src/CSET/operators/pressure.py new file mode 100644 index 000000000..fe03c3b8c --- /dev/null +++ b/src/CSET/operators/pressure.py @@ -0,0 +1,78 @@ +# © Crown copyright, Met Office (2022-2026) and CSET contributors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Operators for pressure conversions.""" + +import iris.cube +import numpy as np + +from CSET._common import iter_maybe +from CSET.operators._atmospheric_constants import E0, KAPPA, P0 +from CSET.operators.misc import convert_units + + +def vapour_pressure( + temperature: iris.cube.Cube | iris.cube.CubeList, +) -> iris.cube.Cube | iris.cube.CubeList: + """Calculate the vapour pressure of the atmosphere.""" + v_pressure = iris.cube.CubeList([]) + for T in iter_maybe(temperature): + es = T.copy() + exponent = 17.27 * (T - 273.16) / (T - 35.86) + es.data = E0 * np.exp(exponent.core_data()) + es.units = "hPa" + es.rename("vapour_pressure") + v_pressure.append(es) + if len(v_pressure) == 1: + return v_pressure[0] + else: + return v_pressure + + +def vapour_pressure_from_relative_humidity( + temperature: iris.cube.Cube | iris.cube.CubeList, + relative_humidity: iris.cube.Cube | iris.cube.CubeList, +) -> iris.cube.Cube | iris.cube.CubeList: + """Calculate the vapour pressure using RH.""" + v_pressure = iris.cube.CubeList([]) + for T, RH in zip( + iter_maybe(temperature), iter_maybe(relative_humidity), strict=True + ): + RH = convert_units(RH, "1") + vp = vapour_pressure(T) * RH + vp.units = "hPa" + vp.rename("vapour_pressure") + v_pressure.append(vp) + if len(v_pressure) == 1: + return v_pressure[0] + else: + return v_pressure + + +def exner_pressure( + pressure: iris.cube.Cube | iris.cube.CubeList, +) -> iris.cube.Cube | iris.cube.CubeList: + """Calculate the exner pressure.""" + pi = iris.cube.CubeList([]) + for P in iter_maybe(pressure): + PI = P.copy() + P = convert_units(P, "hPa") + PI.data = (P.core_data() / P0) ** KAPPA + PI.rename("exner_pressure") + PI.units = "1" + pi.append(PI) + if len(pi) == 1: + return pi[0] + else: + return pi diff --git a/src/CSET/operators/temperature.py b/src/CSET/operators/temperature.py new file mode 100644 index 000000000..f432cff83 --- /dev/null +++ b/src/CSET/operators/temperature.py @@ -0,0 +1,234 @@ +# © Crown copyright, Met Office (2022-2026) and CSET contributors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Operators for temperature conversions.""" + +import iris.cube +import numpy as np + +from CSET._common import iter_maybe +from CSET.operators._atmospheric_constants import CPD, EPSILON, LV, RV, T0 +from CSET.operators.humidity import ( + mixing_ratio_from_relative_humidity, + saturation_mixing_ratio, +) +from CSET.operators.misc import convert_units +from CSET.operators.pressure import ( + exner_pressure, + vapour_pressure_from_relative_humidity, +) + + +def dewpoint_temperature( + temperature: iris.cube.Cube | iris.cube.CubeList, + relative_humidity: iris.cube.Cube | iris.cube.CubeList, +) -> iris.cube.Cube | iris.cube.CubeList: + """Calculate the dewpoint temperature following Bolton (1980).""" + Td = iris.cube.CubeList([]) + for T, RH in zip( + iter_maybe(temperature), iter_maybe(relative_humidity), strict=True + ): + vp = vapour_pressure_from_relative_humidity(T, RH) + td = vp.copy() + td.data = ((243.5 * np.log(vp.core_data())) - 440.8) / ( + 19.48 - np.log(vp.core_data()) + ) + td.data[T.data - T0 < -35.0] = np.nan + td.data[T.data - T0 > 35.0] = np.nan + td.units = "Celsius" + td.rename("dewpoint_temperature") + td = convert_units(td, "K") + Td.append(td) + if len(Td) == 1: + return Td[0] + else: + return Td + + +def virtual_temperature( + temperature: iris.cube.Cube | iris.cube.CubeList, + mixing_ratio: iris.cube.Cube | iris.cube.CubeList, +) -> iris.cube.Cube | iris.cube.CubeList: + """Calculate the virtual temperature.""" + Tv = iris.cube.CubeList([]) + for T, W in zip(iter_maybe(temperature), iter_maybe(mixing_ratio), strict=True): + virT = T * ((W + EPSILON) / (EPSILON * (1 + W))) + virT.rename("virtual_temperature") + Tv.append(virT) + if len(Tv) == 1: + return Tv[0] + else: + return Tv + + +def wet_bulb_temperature( + temperature: iris.cube.Cube | iris.cube.CubeList, + relative_humidity: iris.cube.Cube | iris.cube.CubeList, +) -> iris.cube.Cube | iris.cube.CubeList: + """Calculate the wet-bulb temperature following Stull (2011).""" + Tw = iris.cube.CubeList([]) + for T, RH in zip( + iter_maybe(temperature), iter_maybe(relative_humidity), strict=True + ): + RH = convert_units(RH, "%") + T = convert_units(T, "Celsius") + wetT = ( + T * np.arctan(0.151977 * (RH.core_data() + 8.313659) ** 0.5) + + np.arctan(T.core_data() + RH.core_data()) + - np.arctan(RH.core_data() - 1.676331) + + 0.00391838 + * (RH.core_data()) ** (3.0 / 2.0) + * np.arctan(0.023101 * RH.core_data()) + - 4.686035 + ) + wetT.rename("wet_bulb_temperature") + wetT = convert_units(wetT, "K") + Tw.append(wetT) + if len(Tw) == 1: + return Tw[0] + else: + return Tw + + +def potential_temperature( + temperature: iris.cube.Cube | iris.cube.CubeList, + pressure: iris.cube.Cube | iris.cube.CubeList, +) -> iris.cube.Cube | iris.cube.CubeList: + """Calculate the potenital temperature.""" + theta = iris.cube.CubeList([]) + for T, P in zip(iter_maybe(temperature), iter_maybe(pressure), strict=True): + TH = T / exner_pressure(P) + TH.rename("potential_temperature") + theta.append(TH) + if len(theta) == 1: + return theta[0] + else: + return theta + + +def virtual_potential_temperature( + temperature: iris.cube.Cube | iris.cube.CubeList, + mixing_ratio: iris.cube.Cube | iris.cube.CubeList, + pressure: iris.cube.Cube | iris.cube.CubeList, +) -> iris.cube.Cube | iris.cube.CubeList: + """Calculate the virtual potential temperature.""" + theta_v = iris.cube.CubeList([]) + for T, W, P in zip( + iter_maybe(temperature), + iter_maybe(mixing_ratio), + iter_maybe(pressure), + strict=True, + ): + TH_V = virtual_temperature(T, W) / exner_pressure(P) + TH_V.rename("virtual_potential_temperature") + theta_v.append(TH_V) + if len(theta_v) == 1: + return theta_v[0] + else: + return theta_v + + +def equivalent_potential_temperature( + temperature: iris.cube.Cube | iris.cube.CubeList, + relative_humidity: iris.cube.Cube | iris.cube.CubeList, + pressure: iris.cube.Cube | iris.cube.CubeList, +) -> iris.cube.Cube | iris.cube.CubeList: + """Calculate the equivalent potenital temperature.""" + theta_e = iris.cube.CubeList([]) + for T, RH, P in zip( + iter_maybe(temperature), + iter_maybe(relative_humidity), + iter_maybe(pressure), + strict=True, + ): + RH = convert_units(RH, "1") + theta = potential_temperature(T, P) + w = mixing_ratio_from_relative_humidity(T, P, RH) + second_term_power = -(w * RV) / CPD + second_term = RH.core_data() ** second_term_power.core_data() + third_term_power = LV * w / (CPD * T) + third_term = np.exp(third_term_power.core_data()) + TH_E = theta * second_term * third_term + TH_E.rename("equivalent_potential_temperature") + TH_E.units = "K" + theta_e.append(TH_E) + if len(theta_e) == 1: + return theta_e[0] + else: + return theta_e + + +def wet_bulb_potential_temperature( + temperature: iris.cube.Cube | iris.cube.CubeList, + relative_humidity: iris.cube.Cube | iris.cube.CubeList, + pressure: iris.cube.Cube | iris.cube.CubeList, +) -> iris.cube.Cube | iris.cube.CubeList: + """Calculate wet-bulb potential temperature after Davies-Jones (2008).""" + theta_w = iris.cube.CubeList([]) + for T, RH, P in zip( + iter_maybe(temperature), + iter_maybe(relative_humidity), + iter_maybe(pressure), + strict=True, + ): + TH_E = equivalent_potential_temperature(T, P, RH) + X = TH_E / T0 + X.units = "1" + A0 = 7.101574 + A1 = -20.68208 + A2 = 16.11182 + A3 = 2.574631 + A4 = -5.205688 + B1 = -3.552497 + B2 = 3.781782 + B3 = -0.6899655 + B4 = -0.5929340 + exponent = (A0 + A1 * X + A2 * X**2 + A3 * X**3 + A4 * X**4) / ( + 1.0 + B1 * X + B2 * X**2 + B3 * X**3 + B4 * X**4 + ) + TH_W = TH_E.copy() + TH_W.data[:] = TH_E.core_data() - np.exp(exponent.core_data()) + TH_W.rename("wet_bulb_potential_temperature") + TH_W.data[TH_W.data - T0 < -30.0] = np.nan + TH_W.data[TH_W.data - T0 > 50.0] = np.nan + theta_w.append(TH_W) + if len(theta_w) == 1: + return theta_w[0] + else: + return theta_w + + +def saturation_equivalent_potential_temperature( + temperature: iris.cube.Cube | iris.cube.CubeList, + pressure: iris.cube.Cube | iris.cube.CubeList, +) -> iris.cube.Cube | iris.cube.CubeList: + """Calculate the saturation equivalent potenital temperature.""" + theta_s = iris.cube.CubeList([]) + for T, P in zip( + iter_maybe(temperature), + iter_maybe(pressure), + strict=True, + ): + theta = potential_temperature(T, P) + ws = saturation_mixing_ratio(T, P) + second_term_power = LV * ws / (CPD * T) + second_term = np.exp(second_term_power.core_data()) + TH_S = theta * second_term + TH_S.rename("saturation_equivalent_potential_temperature") + TH_S.units = "K" + theta_s.append(TH_S) + if len(theta_s) == 1: + return theta_s[0] + else: + return theta_s diff --git a/tests/conftest.py b/tests/conftest.py index 7bc1c40bb..a04207353 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,4 @@ -# © Crown copyright, Met Office (2022-2024) and CSET contributors. +# © Crown copyright, Met Office (2022-2026) and CSET contributors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -323,3 +323,69 @@ def orography_4D_cube_read_only(): def orography_4D_cube(orography_4D_cube_read_only): """Get 4D orography cube to run tests on. It is safe to modify.""" return orography_4D_cube_read_only.copy() + + +@pytest.fixture() +def temperature_for_conversions_cube_read_only(): + """Get temperature cube for conversions to run tests on. It is NOT safe to modify.""" + return read.read_cube("tests/test_data/pressure/air_temperature.nc") + + +@pytest.fixture() +def temperature_for_conversions_cube(temperature_for_conversions_cube_read_only): + """Get temperature cube for conversions to run tests on. It is safe to modify.""" + return temperature_for_conversions_cube_read_only.copy() + + +@pytest.fixture() +def pressure_for_conversions_cube_read_only(): + """Get pressure cube for conversions to run tests on. It is NOT safe to modify.""" + return read.read_cube("tests/test_data/pressure/pressure.nc") + + +@pytest.fixture() +def pressure_for_conversions_cube(pressure_for_conversions_cube_read_only): + """Get pressure cube for conversions to run tests on. It is safe to modify.""" + return pressure_for_conversions_cube_read_only.copy() + + +@pytest.fixture() +def relative_humidity_for_conversions_cube_read_only(): + """Get relative humidity cube for conversions to run tests on. It is NOT safe to modify.""" + return read.read_cube("tests/test_data/pressure/relative_humidity.nc") + + +@pytest.fixture() +def relative_humidity_for_conversions_cube( + relative_humidity_for_conversions_cube_read_only, +): + """Get relative humidity cube for conversions to run tests on. It is safe to modify.""" + return relative_humidity_for_conversions_cube_read_only.copy() + + +@pytest.fixture() +def specific_humidity_for_conversions_cube_read_only(): + """Get specific humidity cube for conversions to run tests on. It is NOT safe to modify.""" + return read.read_cube("tests/test_data/humidity/specific_humidity.nc") + + +@pytest.fixture() +def specific_humidity_for_conversions_cube( + specific_humidity_for_conversions_cube_read_only, +): + """Get specific humidity cube for conversions to run tests on. It is safe to modify.""" + return specific_humidity_for_conversions_cube_read_only.copy() + + +@pytest.fixture() +def mixing_ratio_for_conversions_cube_read_only(): + """Get mixing ratio cube for conversions to run tests on. It is NOT safe to modify.""" + return read.read_cube("tests/test_data/humidity/mixing_ratio.nc") + + +@pytest.fixture() +def mixing_ratio_for_conversions_cube( + mixing_ratio_for_conversions_cube_read_only, +): + """Get mixing ratio cube for conversions to run tests on. It is safe to modify.""" + return mixing_ratio_for_conversions_cube_read_only.copy() diff --git a/tests/operators/test_humidity.py b/tests/operators/test_humidity.py new file mode 100644 index 000000000..9ff94d14d --- /dev/null +++ b/tests/operators/test_humidity.py @@ -0,0 +1,724 @@ +# © Crown copyright, Met Office (2022-2026) and CSET contributors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Test humidity operators.""" + +import cf_units +import iris.cube +import numpy as np + +from CSET.operators import _atmospheric_constants, humidity, misc, pressure + + +def test_mixing_ratio_from_specific_humidity(specific_humidity_for_conversions_cube): + """Test calculation of mixing ratio from specific humidity.""" + expected_data = specific_humidity_for_conversions_cube / ( + 1 - specific_humidity_for_conversions_cube + ) + assert np.allclose( + expected_data.data, + humidity.mixing_ratio_from_specific_humidity( + specific_humidity_for_conversions_cube + ).data, + rtol=1e-6, + atol=1e-2, + ) + + +def test_mixing_ratio_from_specific_humidity_name( + specific_humidity_for_conversions_cube, +): + """Test naming of mixing ratio cube.""" + expected_name = "mixing_ratio" + assert ( + expected_name + == humidity.mixing_ratio_from_specific_humidity( + specific_humidity_for_conversions_cube + ).name() + ) + + +def test_mixing_ratio_from_specific_humidity_unit( + specific_humidity_for_conversions_cube, +): + """Test units for mixing ratio.""" + expected_units = cf_units.Unit("kg/kg") + assert ( + expected_units + == humidity.mixing_ratio_from_specific_humidity( + specific_humidity_for_conversions_cube + ).units + ) + + +def test_mixing_ratio_from_specific_humidity_cubelist( + specific_humidity_for_conversions_cube, +): + """Test calculation of mixing ratio from specific humidity for a CubeList.""" + expected_data = specific_humidity_for_conversions_cube / ( + 1 - specific_humidity_for_conversions_cube + ) + expected_list = iris.cube.CubeList([expected_data, expected_data]) + input_list = iris.cube.CubeList( + [specific_humidity_for_conversions_cube, specific_humidity_for_conversions_cube] + ) + actual_cubelist = humidity.mixing_ratio_from_specific_humidity(input_list) + 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_mixing_ratio_from_specific_humidity_using_calculated( + mixing_ratio_for_conversions_cube, +): + """Test mixing ratio calculation using calculated q from mixing ratio.""" + calculated_w = humidity.mixing_ratio_from_specific_humidity( + humidity.specific_humidity_from_mixing_ratio(mixing_ratio_for_conversions_cube) + ) + assert np.allclose( + mixing_ratio_for_conversions_cube.data, calculated_w.data, rtol=1e-6, atol=1e-2 + ) + + +def test_specific_humidity_from_mixing_ratio(mixing_ratio_for_conversions_cube): + """Test specific humidity calculation from mixing ratio.""" + expected_data = mixing_ratio_for_conversions_cube / ( + 1 + mixing_ratio_for_conversions_cube + ) + assert np.allclose( + expected_data.data, + humidity.specific_humidity_from_mixing_ratio( + mixing_ratio_for_conversions_cube + ).data, + rtol=1e-6, + atol=1e-2, + ) + + +def test_specific_humidity_from_mixing_ratio_name(mixing_ratio_for_conversions_cube): + """Test specific humidity cube is named.""" + expected_name = "specific_humidity" + assert ( + expected_name + == humidity.specific_humidity_from_mixing_ratio( + mixing_ratio_for_conversions_cube + ).name() + ) + + +def test_specific_humidity_from_mixing_ratio_units(mixing_ratio_for_conversions_cube): + """Test units of specific humidity.""" + expected_units = cf_units.Unit("kg/kg") + assert ( + expected_units + == humidity.specific_humidity_from_mixing_ratio( + mixing_ratio_for_conversions_cube + ).units + ) + + +def test_specific_humidity_from_mixing_ratio_cubelist( + mixing_ratio_for_conversions_cube, +): + """Test specific humidity calculation from mixing ratio with a CuebList.""" + expected_data = mixing_ratio_for_conversions_cube / ( + 1 + mixing_ratio_for_conversions_cube + ) + expected_list = iris.cube.CubeList([expected_data, expected_data]) + input_list = iris.cube.CubeList( + [mixing_ratio_for_conversions_cube, mixing_ratio_for_conversions_cube] + ) + actual_cubelist = humidity.specific_humidity_from_mixing_ratio(input_list) + 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_specific_humidity_from_mixing_ratio_using_calculated( + specific_humidity_for_conversions_cube, +): + """Test specific humidity calculation using calculated w from specific humidity.""" + calculated_q = humidity.specific_humidity_from_mixing_ratio( + humidity.mixing_ratio_from_specific_humidity( + specific_humidity_for_conversions_cube + ) + ) + assert np.allclose( + specific_humidity_for_conversions_cube.data, + calculated_q.data, + rtol=1e-6, + atol=1e-2, + ) + + +def test_saturation_mixing_ratio( + temperature_for_conversions_cube, pressure_for_conversions_cube +): + """Test calculation of saturation mixing ratio.""" + expected_data = ( + _atmospheric_constants.EPSILON + * pressure.vapour_pressure(temperature_for_conversions_cube) + ) / ( + (misc.convert_units(pressure_for_conversions_cube, "hPa")) + - pressure.vapour_pressure(temperature_for_conversions_cube) + ) + assert np.allclose( + expected_data.data, + humidity.saturation_mixing_ratio( + temperature_for_conversions_cube, pressure_for_conversions_cube + ).data, + rtol=1e-6, + atol=1e-2, + ) + + +def test_saturation_mixing_ratio_name( + temperature_for_conversions_cube, pressure_for_conversions_cube +): + """Test naming of saturation mixing ratio cube.""" + expected_name = "saturation_mixing_ratio" + assert ( + expected_name + == humidity.saturation_mixing_ratio( + temperature_for_conversions_cube, pressure_for_conversions_cube + ).name() + ) + + +def test_saturation_mixing_ratio_units( + temperature_for_conversions_cube, pressure_for_conversions_cube +): + """Test units of saturation mixing ratio cube.""" + expected_units = cf_units.Unit("kg/kg") + assert ( + expected_units + == humidity.saturation_mixing_ratio( + temperature_for_conversions_cube, pressure_for_conversions_cube + ).units + ) + + +def test_saturation_mixing_ratio_cubelist( + temperature_for_conversions_cube, pressure_for_conversions_cube +): + """Test calculation of saturation mixing ratio as CubeList.""" + expected_data = ( + _atmospheric_constants.EPSILON + * pressure.vapour_pressure(temperature_for_conversions_cube) + ) / ( + (misc.convert_units(pressure_for_conversions_cube, "hPa")) + - pressure.vapour_pressure(temperature_for_conversions_cube) + ) + expected_list = iris.cube.CubeList([expected_data, expected_data]) + temperature_list = iris.cube.CubeList( + [temperature_for_conversions_cube, temperature_for_conversions_cube] + ) + pressure_list = iris.cube.CubeList( + [pressure_for_conversions_cube, pressure_for_conversions_cube] + ) + actual_cubelist = humidity.saturation_mixing_ratio(temperature_list, pressure_list) + 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_saturation_specific_humidity( + temperature_for_conversions_cube, pressure_for_conversions_cube +): + """Test calculation of saturation specific humidity.""" + expected_data = ( + _atmospheric_constants.EPSILON + * pressure.vapour_pressure(temperature_for_conversions_cube) + ) / misc.convert_units(pressure_for_conversions_cube, "hPa") + assert np.allclose( + expected_data.data, + humidity.saturation_specific_humidity( + temperature_for_conversions_cube, pressure_for_conversions_cube + ).data, + rtol=1e-6, + atol=1e-2, + ) + + +def test_saturation_specific_humidity_name( + temperature_for_conversions_cube, pressure_for_conversions_cube +): + """Test naming of saturation specific humidity cube.""" + expected_name = "saturation_specific_humidity" + assert ( + expected_name + == humidity.saturation_specific_humidity( + temperature_for_conversions_cube, pressure_for_conversions_cube + ).name() + ) + + +def test_saturation_specific_humidity_units( + temperature_for_conversions_cube, pressure_for_conversions_cube +): + """Test units of saturation specific humidity.""" + expected_units = cf_units.Unit("kg/kg") + assert ( + expected_units + == humidity.saturation_specific_humidity( + temperature_for_conversions_cube, pressure_for_conversions_cube + ).units + ) + + +def test_saturation_specific_humidity_cubelist( + temperature_for_conversions_cube, pressure_for_conversions_cube +): + """Test calculation of saturation specific humidity for a CubeList.""" + expected_data = ( + _atmospheric_constants.EPSILON + * pressure.vapour_pressure(temperature_for_conversions_cube) + ) / misc.convert_units(pressure_for_conversions_cube, "hPa") + expected_list = iris.cube.CubeList([expected_data, expected_data]) + temperature_list = iris.cube.CubeList( + [temperature_for_conversions_cube, temperature_for_conversions_cube] + ) + pressure_list = iris.cube.CubeList( + [pressure_for_conversions_cube, pressure_for_conversions_cube] + ) + actual_cubelist = humidity.saturation_specific_humidity( + temperature_list, pressure_list + ) + 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_mixing_ratio_from_relative_humidity( + temperature_for_conversions_cube, + pressure_for_conversions_cube, + relative_humidity_for_conversions_cube, +): + """Test calculation of mixing ratio from relative humidity.""" + expected_data = humidity.saturation_mixing_ratio( + temperature_for_conversions_cube, pressure_for_conversions_cube + ) * misc.convert_units(relative_humidity_for_conversions_cube, "1") + assert np.allclose( + expected_data.data, + humidity.mixing_ratio_from_relative_humidity( + temperature_for_conversions_cube, + pressure_for_conversions_cube, + relative_humidity_for_conversions_cube, + ).data, + rtol=1e-6, + atol=1e-2, + ) + + +def test_mixing_ratio_from_relative_humidity_name( + temperature_for_conversions_cube, + pressure_for_conversions_cube, + relative_humidity_for_conversions_cube, +): + """Test naming of mixing ratio cube.""" + expected_name = "mixing_ratio" + assert ( + expected_name + == humidity.mixing_ratio_from_relative_humidity( + temperature_for_conversions_cube, + pressure_for_conversions_cube, + relative_humidity_for_conversions_cube, + ).name() + ) + + +def test_mixing_ratio_from_relative_humidity_units( + temperature_for_conversions_cube, + pressure_for_conversions_cube, + relative_humidity_for_conversions_cube, +): + """Test units of mixing ratio cube.""" + expected_units = cf_units.Unit("kg/kg") + assert ( + expected_units + == humidity.mixing_ratio_from_relative_humidity( + temperature_for_conversions_cube, + pressure_for_conversions_cube, + relative_humidity_for_conversions_cube, + ).units + ) + + +def test_mixing_ratio_from_relative_humidity_cubelist( + temperature_for_conversions_cube, + pressure_for_conversions_cube, + relative_humidity_for_conversions_cube, +): + """Test calculation of mixing ratio from relative humidity for a CubeList.""" + expected_data = humidity.saturation_mixing_ratio( + temperature_for_conversions_cube, pressure_for_conversions_cube + ) * misc.convert_units(relative_humidity_for_conversions_cube, "1") + expected_list = iris.cube.CubeList([expected_data, expected_data]) + temperature_list = iris.cube.CubeList( + [temperature_for_conversions_cube, temperature_for_conversions_cube] + ) + pressure_list = iris.cube.CubeList( + [pressure_for_conversions_cube, pressure_for_conversions_cube] + ) + rh_list = iris.cube.CubeList( + [relative_humidity_for_conversions_cube, relative_humidity_for_conversions_cube] + ) + actual_cubelist = humidity.mixing_ratio_from_relative_humidity( + temperature_list, pressure_list, rh_list + ) + 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_mixing_ratio_from_relative_humidity_calculated( + temperature_for_conversions_cube, + pressure_for_conversions_cube, + mixing_ratio_for_conversions_cube, +): + """Test mixing ratio from relative humidity using calculated relative humidity.""" + calculated_w = humidity.mixing_ratio_from_relative_humidity( + temperature_for_conversions_cube, + pressure_for_conversions_cube, + humidity.relative_humidity_from_mixing_ratio( + mixing_ratio_for_conversions_cube, + temperature_for_conversions_cube, + pressure_for_conversions_cube, + ), + ) + assert np.allclose( + calculated_w.data, + mixing_ratio_for_conversions_cube.data, + rtol=1e-6, + atol=1e-2, + ) + + +def test_specific_humidity_from_relative_humidity( + temperature_for_conversions_cube, + pressure_for_conversions_cube, + relative_humidity_for_conversions_cube, +): + """Test calculation of specific humidity from relative humidity.""" + expected_data = humidity.saturation_specific_humidity( + temperature_for_conversions_cube, pressure_for_conversions_cube + ) * misc.convert_units(relative_humidity_for_conversions_cube, "1") + assert np.allclose( + expected_data.data, + humidity.specific_humidity_from_relative_humidity( + temperature_for_conversions_cube, + pressure_for_conversions_cube, + relative_humidity_for_conversions_cube, + ).data, + rtol=1e-6, + atol=1e-2, + ) + + +def test_specific_humidity_from_relative_humidity_name( + temperature_for_conversions_cube, + pressure_for_conversions_cube, + relative_humidity_for_conversions_cube, +): + """Test naming of specific humidity cube.""" + expected_name = "specific_humidity" + assert ( + expected_name + == humidity.specific_humidity_from_relative_humidity( + temperature_for_conversions_cube, + pressure_for_conversions_cube, + relative_humidity_for_conversions_cube, + ).name() + ) + + +def test_specific_humidity_from_relative_humidity_units( + temperature_for_conversions_cube, + pressure_for_conversions_cube, + relative_humidity_for_conversions_cube, +): + """Test units of specific humidity cube.""" + expected_units = cf_units.Unit("kg/kg") + assert ( + expected_units + == humidity.specific_humidity_from_relative_humidity( + temperature_for_conversions_cube, + pressure_for_conversions_cube, + relative_humidity_for_conversions_cube, + ).units + ) + + +def test_specific_humidity_from_relative_humidity_cubelist( + temperature_for_conversions_cube, + pressure_for_conversions_cube, + relative_humidity_for_conversions_cube, +): + """Test calculation of specific humidity from relative humidity for a CubeList.""" + expected_data = humidity.saturation_specific_humidity( + temperature_for_conversions_cube, pressure_for_conversions_cube + ) * misc.convert_units(relative_humidity_for_conversions_cube, "1") + expected_list = iris.cube.CubeList([expected_data, expected_data]) + temperature_list = iris.cube.CubeList( + [temperature_for_conversions_cube, temperature_for_conversions_cube] + ) + pressure_list = iris.cube.CubeList( + [pressure_for_conversions_cube, pressure_for_conversions_cube] + ) + rh_list = iris.cube.CubeList( + [relative_humidity_for_conversions_cube, relative_humidity_for_conversions_cube] + ) + actual_cubelist = humidity.specific_humidity_from_relative_humidity( + temperature_list, pressure_list, rh_list + ) + 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_specific_humidity_from_relative_humidity_calculated( + temperature_for_conversions_cube, + pressure_for_conversions_cube, + specific_humidity_for_conversions_cube, +): + """Test specific humidity from relative humidity using calculated relative humidity.""" + calculated_q = humidity.specific_humidity_from_relative_humidity( + temperature_for_conversions_cube, + pressure_for_conversions_cube, + humidity.relative_humidity_from_specific_humidity( + specific_humidity_for_conversions_cube, + temperature_for_conversions_cube, + pressure_for_conversions_cube, + ), + ) + assert np.allclose( + calculated_q.data, + specific_humidity_for_conversions_cube.data, + rtol=1e-6, + atol=1e-2, + ) + + +def test_relative_humidity_from_mixing_ratio( + mixing_ratio_for_conversions_cube, + temperature_for_conversions_cube, + pressure_for_conversions_cube, +): + """Test calculation of relative humidity from mixing ratio.""" + expected_data = 100.0 * ( + mixing_ratio_for_conversions_cube + / humidity.saturation_mixing_ratio( + temperature_for_conversions_cube, pressure_for_conversions_cube + ) + ) + assert np.allclose( + expected_data.data, + humidity.relative_humidity_from_mixing_ratio( + mixing_ratio_for_conversions_cube, + temperature_for_conversions_cube, + pressure_for_conversions_cube, + ).data, + rtol=1e-6, + atol=1e-2, + ) + + +def test_relative_humidity_from_mixing_ratio_calculated( + temperature_for_conversions_cube, + pressure_for_conversions_cube, + relative_humidity_for_conversions_cube, +): + """Test relative humidity from mixing ratio using calculated mixing ratio.""" + calculated_rh = humidity.relative_humidity_from_mixing_ratio( + humidity.mixing_ratio_from_relative_humidity( + temperature_for_conversions_cube, + pressure_for_conversions_cube, + relative_humidity_for_conversions_cube, + ), + temperature_for_conversions_cube, + pressure_for_conversions_cube, + ) + assert np.allclose( + calculated_rh.data, + relative_humidity_for_conversions_cube.data, + rtol=1e-6, + atol=1e-2, + ) + + +def test_relative_humidity_from_mixing_ratio_name( + mixing_ratio_for_conversions_cube, + temperature_for_conversions_cube, + pressure_for_conversions_cube, +): + """Test name of relative humidity cube.""" + expected_name = "relative_humidity" + assert ( + expected_name + == humidity.relative_humidity_from_mixing_ratio( + mixing_ratio_for_conversions_cube, + temperature_for_conversions_cube, + pressure_for_conversions_cube, + ).name() + ) + + +def test_relative_humidity_from_mixing_ratio_units( + mixing_ratio_for_conversions_cube, + temperature_for_conversions_cube, + pressure_for_conversions_cube, +): + """Test units of relative humidity cube.""" + expected_units = cf_units.Unit("%") + assert ( + expected_units + == humidity.relative_humidity_from_mixing_ratio( + mixing_ratio_for_conversions_cube, + temperature_for_conversions_cube, + pressure_for_conversions_cube, + ).units + ) + + +def test_relative_humidity_from_mixing_ratio_cubelist( + mixing_ratio_for_conversions_cube, + temperature_for_conversions_cube, + pressure_for_conversions_cube, +): + """Test calculation of relative humidity from mixing ratio for a CubeList.""" + expected_data = 100.0 * ( + mixing_ratio_for_conversions_cube + / humidity.saturation_mixing_ratio( + temperature_for_conversions_cube, pressure_for_conversions_cube + ) + ) + expected_list = iris.cube.CubeList([expected_data, expected_data]) + mixing_ratio_list = iris.cube.CubeList( + [mixing_ratio_for_conversions_cube, mixing_ratio_for_conversions_cube] + ) + temperature_list = iris.cube.CubeList( + [temperature_for_conversions_cube, temperature_for_conversions_cube] + ) + pressure_list = iris.cube.CubeList( + [pressure_for_conversions_cube, pressure_for_conversions_cube] + ) + actual_cubelist = humidity.relative_humidity_from_mixing_ratio( + mixing_ratio_list, temperature_list, pressure_list + ) + 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_relative_humidity_from_specific_humidity( + specific_humidity_for_conversions_cube, + temperature_for_conversions_cube, + pressure_for_conversions_cube, +): + """Test calculation of relative humidity from specific humidity.""" + expected_data = 100.0 * ( + specific_humidity_for_conversions_cube + / humidity.saturation_specific_humidity( + temperature_for_conversions_cube, pressure_for_conversions_cube + ) + ) + assert np.allclose( + expected_data.data, + humidity.relative_humidity_from_specific_humidity( + specific_humidity_for_conversions_cube, + temperature_for_conversions_cube, + pressure_for_conversions_cube, + ).data, + rtol=1e-6, + atol=1e-2, + ) + + +def test_relative_humidity_from_specific_humidity_calculated( + temperature_for_conversions_cube, + pressure_for_conversions_cube, + relative_humidity_for_conversions_cube, +): + """Test relative humidity from specific humidity using calculated specific humidity.""" + calculated_rh = humidity.relative_humidity_from_specific_humidity( + humidity.specific_humidity_from_relative_humidity( + temperature_for_conversions_cube, + pressure_for_conversions_cube, + relative_humidity_for_conversions_cube, + ), + temperature_for_conversions_cube, + pressure_for_conversions_cube, + ) + assert np.allclose( + calculated_rh.data, + relative_humidity_for_conversions_cube.data, + rtol=1e-6, + atol=1e-2, + ) + + +def test_relative_humidity_from_specific_humidity_name( + specific_humidity_for_conversions_cube, + temperature_for_conversions_cube, + pressure_for_conversions_cube, +): + """Test name of relative humidity cube.""" + expected_name = "relative_humidity" + assert ( + expected_name + == humidity.relative_humidity_from_specific_humidity( + specific_humidity_for_conversions_cube, + temperature_for_conversions_cube, + pressure_for_conversions_cube, + ).name() + ) + + +def test_relative_humidity_from_specific_humidity_units( + specific_humidity_for_conversions_cube, + temperature_for_conversions_cube, + pressure_for_conversions_cube, +): + """Test units of relative humidity cube.""" + expected_units = cf_units.Unit("%") + assert ( + expected_units + == humidity.relative_humidity_from_specific_humidity( + specific_humidity_for_conversions_cube, + temperature_for_conversions_cube, + pressure_for_conversions_cube, + ).units + ) + + +def test_relative_humidity_from_specific_humidity_cubelist( + specific_humidity_for_conversions_cube, + temperature_for_conversions_cube, + pressure_for_conversions_cube, +): + """Test calculation of relative humidity from specific humidity for a CubeList.""" + expected_data = 100.0 * ( + specific_humidity_for_conversions_cube + / humidity.saturation_specific_humidity( + temperature_for_conversions_cube, pressure_for_conversions_cube + ) + ) + expected_list = iris.cube.CubeList([expected_data, expected_data]) + specific_humidity_list = iris.cube.CubeList( + [specific_humidity_for_conversions_cube, specific_humidity_for_conversions_cube] + ) + temperature_list = iris.cube.CubeList( + [temperature_for_conversions_cube, temperature_for_conversions_cube] + ) + pressure_list = iris.cube.CubeList( + [pressure_for_conversions_cube, pressure_for_conversions_cube] + ) + actual_cubelist = humidity.relative_humidity_from_specific_humidity( + specific_humidity_list, temperature_list, pressure_list + ) + 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) diff --git a/tests/operators/test_pressure.py b/tests/operators/test_pressure.py new file mode 100644 index 000000000..42e7d78ff --- /dev/null +++ b/tests/operators/test_pressure.py @@ -0,0 +1,183 @@ +# © Crown copyright, Met Office (2022-2026) and CSET contributors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Test pressure operators.""" + +import cf_units +import iris.cube +import numpy as np + +from CSET.operators import _atmospheric_constants, pressure + + +def test_vapour_pressure(temperature_for_conversions_cube): + """Test calculation of vapour pressure for a cube.""" + expected_data = temperature_for_conversions_cube.copy() + exponent = ( + 17.27 + * (temperature_for_conversions_cube - 273.16) + / (temperature_for_conversions_cube - 35.86) + ) + expected_data.data = _atmospheric_constants.E0 * np.exp(exponent.core_data()) + assert np.allclose( + expected_data.data, + pressure.vapour_pressure(temperature_for_conversions_cube).data, + rtol=1e-6, + atol=1e-2, + ) + + +def test_vapour_pressure_name(temperature_for_conversions_cube): + """Test renaming of vapour pressure cube.""" + expected_name = "vapour_pressure" + assert ( + expected_name + == pressure.vapour_pressure(temperature_for_conversions_cube).name() + ) + + +def test_vapour_pressure_units(temperature_for_conversions_cube): + """Test units for vapour pressure cube.""" + expected_units = cf_units.Unit("hPa") + assert ( + expected_units + == pressure.vapour_pressure(temperature_for_conversions_cube).units + ) + + +def test_vapour_pressure_cube_list(temperature_for_conversions_cube): + """Test calculation of vapour pressure for a CubeList.""" + expected_data = temperature_for_conversions_cube.copy() + exponent = ( + 17.27 + * (temperature_for_conversions_cube - 273.16) + / (temperature_for_conversions_cube - 35.86) + ) + expected_data.data = _atmospheric_constants.E0 * np.exp(exponent.core_data()) + expected_list = iris.cube.CubeList([expected_data, expected_data]) + input_cubelist = iris.cube.CubeList( + [temperature_for_conversions_cube, temperature_for_conversions_cube] + ) + actual_cubelist = pressure.vapour_pressure(input_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_vapour_pressure_from_relative_humidity( + temperature_for_conversions_cube, relative_humidity_for_conversions_cube +): + """Test calculation of vapour pressure from relative humidity.""" + expected_data = pressure.vapour_pressure(temperature_for_conversions_cube) * ( + relative_humidity_for_conversions_cube / 100.0 + ) + assert np.allclose( + expected_data.data, + pressure.vapour_pressure_from_relative_humidity( + temperature_for_conversions_cube, relative_humidity_for_conversions_cube + ).data, + rtol=1e-6, + atol=1e-2, + ) + + +def test_vapour_pressure_from_relative_humidity_name( + temperature_for_conversions_cube, relative_humidity_for_conversions_cube +): + """Test naming of vapour pressure cube.""" + expected_name = "vapour_pressure" + assert ( + expected_name + == pressure.vapour_pressure_from_relative_humidity( + temperature_for_conversions_cube, relative_humidity_for_conversions_cube + ).name() + ) + + +def test_vapour_pressure_from_relative_humidity_units( + temperature_for_conversions_cube, relative_humidity_for_conversions_cube +): + """Test units of vapour pressure cube.""" + expected_units = cf_units.Unit("hPa") + assert ( + expected_units + == pressure.vapour_pressure_from_relative_humidity( + temperature_for_conversions_cube, relative_humidity_for_conversions_cube + ).units + ) + + +def test_vapour_pressure_from_relative_humidity_cubelist( + temperature_for_conversions_cube, relative_humidity_for_conversions_cube +): + """Test calculation of vapour pressure from relative humidity as a CubeList.""" + expected_data = pressure.vapour_pressure(temperature_for_conversions_cube) * ( + relative_humidity_for_conversions_cube / 100.0 + ) + expected_list = iris.cube.CubeList([expected_data, expected_data]) + temperature_input_list = iris.cube.CubeList( + [temperature_for_conversions_cube, temperature_for_conversions_cube] + ) + relative_humidity_input_list = iris.cube.CubeList( + [relative_humidity_for_conversions_cube, relative_humidity_for_conversions_cube] + ) + actual_cubelist = pressure.vapour_pressure_from_relative_humidity( + temperature_input_list, relative_humidity_input_list + ) + 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_exner_pressure(pressure_for_conversions_cube): + """Test calculation of exner pressure.""" + expected_data = pressure_for_conversions_cube.copy() + expected_data.data = ( + (pressure_for_conversions_cube / 100).core_data() / _atmospheric_constants.P0 + ) ** _atmospheric_constants.KAPPA + assert np.allclose( + expected_data.data, + pressure.exner_pressure(pressure_for_conversions_cube).data, + rtol=1e-6, + atol=1e-2, + ) + + +def test_exner_pressure_name(pressure_for_conversions_cube): + """Test naming of exner pressure.""" + expected_name = "exner_pressure" + assert ( + expected_name == pressure.exner_pressure(pressure_for_conversions_cube).name() + ) + + +def test_exner_pressure_units(pressure_for_conversions_cube): + """Test units of exner pressure.""" + expected_units = cf_units.Unit("1") + assert ( + expected_units == pressure.exner_pressure(pressure_for_conversions_cube).units + ) + + +def test_exner_pressure_cubelist(pressure_for_conversions_cube): + """Test calculation of exner pressure for a CubeList.""" + expected_data = pressure_for_conversions_cube.copy() + expected_data.data = ( + (pressure_for_conversions_cube / 100).core_data() / _atmospheric_constants.P0 + ) ** _atmospheric_constants.KAPPA + expected_list = iris.cube.CubeList([expected_data, expected_data]) + input_list = iris.cube.CubeList( + [pressure_for_conversions_cube, pressure_for_conversions_cube] + ) + actual_cubelist = pressure.exner_pressure(input_list) + 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) diff --git a/tests/operators/test_temperature.py b/tests/operators/test_temperature.py new file mode 100644 index 000000000..670c51185 --- /dev/null +++ b/tests/operators/test_temperature.py @@ -0,0 +1,173 @@ +# © Crown copyright, Met Office (2022-2026) and CSET contributors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Test temperature operators.""" + +import cf_units +import iris.cube +import numpy as np + +from CSET.operators import _atmospheric_constants, misc, pressure, temperature + + +def test_dewpoint_temperature( + temperature_for_conversions_cube, relative_humidity_for_conversions_cube +): + """Test calculation of dewpoint temperature.""" + vp = pressure.vapour_pressure_from_relative_humidity( + temperature_for_conversions_cube, relative_humidity_for_conversions_cube + ) + expected_data = vp.copy() + expected_data.data = (243.5 * np.log(vp.core_data()) - 440.8) / ( + 19.48 - np.log(vp.core_data()) + ) + expected_data.data[ + temperature_for_conversions_cube.data - _atmospheric_constants.T0 < -35.0 + ] = np.nan + expected_data.data[ + temperature_for_conversions_cube.data - _atmospheric_constants.T0 > 35.0 + ] = np.nan + expected_data.units = "Celsius" + expected_data = misc.convert_units(expected_data, "K") + assert np.allclose( + expected_data.data, + temperature.dewpoint_temperature( + temperature_for_conversions_cube, relative_humidity_for_conversions_cube + ).data, + rtol=1e-6, + atol=1e-2, + ) + + +def test_dewpoint_temperature_name( + temperature_for_conversions_cube, relative_humidity_for_conversions_cube +): + """Test name of dewpoint temperature cube.""" + expected_name = "dewpoint_temperature" + assert ( + expected_name + == temperature.dewpoint_temperature( + temperature_for_conversions_cube, relative_humidity_for_conversions_cube + ).name() + ) + + +def test_dewpoint_temperature_unit( + temperature_for_conversions_cube, relative_humidity_for_conversions_cube +): + """Test units for dewpoint temperature cube.""" + expected_units = cf_units.Unit("K") + assert ( + expected_units + == temperature.dewpoint_temperature( + temperature_for_conversions_cube, relative_humidity_for_conversions_cube + ).units + ) + + +def test_dewpoint_temperature_cubelist( + temperature_for_conversions_cube, relative_humidity_for_conversions_cube +): + """Test calculation of dewpoint temperature for a CubeList.""" + vp = pressure.vapour_pressure_from_relative_humidity( + temperature_for_conversions_cube, relative_humidity_for_conversions_cube + ) + expected_data = vp.copy() + expected_data.data = (243.5 * np.log(vp.core_data()) - 440.8) / ( + 19.48 - np.log(vp.core_data()) + ) + expected_data.data[ + temperature_for_conversions_cube.data - _atmospheric_constants.T0 < -35.0 + ] = np.nan + expected_data.data[ + temperature_for_conversions_cube.data - _atmospheric_constants.T0 > 35.0 + ] = np.nan + expected_data.units = "Celsius" + expected_data = misc.convert_units(expected_data, "K") + expected_list = iris.cube.CubeList([expected_data, expected_data]) + temperature_list = iris.cube.CubeList( + [temperature_for_conversions_cube, temperature_for_conversions_cube] + ) + rh_list = iris.cube.CubeList( + [relative_humidity_for_conversions_cube, relative_humidity_for_conversions_cube] + ) + actual_cubelist = temperature.dewpoint_temperature(temperature_list, rh_list) + 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_virtual_temperature( + temperature_for_conversions_cube, mixing_ratio_for_conversions_cube +): + """Test to calculate virtual temperature.""" + expected_data = temperature_for_conversions_cube * ( + (mixing_ratio_for_conversions_cube + _atmospheric_constants.EPSILON) + / (_atmospheric_constants.EPSILON * (1 + mixing_ratio_for_conversions_cube)) + ) + assert np.allclose( + expected_data.data, + temperature.virtual_temperature( + temperature_for_conversions_cube, mixing_ratio_for_conversions_cube + ).data, + rtol=1e-6, + atol=1e-2, + ) + + +def test_virtual_temperature_name( + temperature_for_conversions_cube, mixing_ratio_for_conversions_cube +): + """Test name of virtual temperature cube.""" + expected_name = "virtual_temperature" + assert ( + expected_name + == temperature.virtual_temperature( + temperature_for_conversions_cube, mixing_ratio_for_conversions_cube + ).name() + ) + + +def test_virtual_temperature_units( + temperature_for_conversions_cube, mixing_ratio_for_conversions_cube +): + """Test units of virtual temperature cube.""" + expected_units = cf_units.Unit("K") + assert ( + expected_units + == temperature.virtual_temperature( + temperature_for_conversions_cube, mixing_ratio_for_conversions_cube + ).units + ) + + +def test_virtual_temperature_cubelist( + temperature_for_conversions_cube, mixing_ratio_for_conversions_cube +): + """Test to calculate virtual temperature for a CubeList.""" + expected_data = temperature_for_conversions_cube * ( + (mixing_ratio_for_conversions_cube + _atmospheric_constants.EPSILON) + / (_atmospheric_constants.EPSILON * (1 + mixing_ratio_for_conversions_cube)) + ) + expected_list = iris.cube.CubeList([expected_data, expected_data]) + temperature_list = iris.cube.CubeList( + [temperature_for_conversions_cube, temperature_for_conversions_cube] + ) + mixing_ratio_list = iris.cube.CubeList( + [mixing_ratio_for_conversions_cube, mixing_ratio_for_conversions_cube] + ) + actual_cubelist = temperature.virtual_temperature( + temperature_list, mixing_ratio_list + ) + 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) diff --git a/tests/test_data/humidity/mixing_ratio.nc b/tests/test_data/humidity/mixing_ratio.nc new file mode 100644 index 000000000..a30b3751b Binary files /dev/null and b/tests/test_data/humidity/mixing_ratio.nc differ diff --git a/tests/test_data/humidity/specific_humidity.nc b/tests/test_data/humidity/specific_humidity.nc new file mode 100644 index 000000000..e85049faf Binary files /dev/null and b/tests/test_data/humidity/specific_humidity.nc differ diff --git a/tests/test_data/pressure/air_temperature.nc b/tests/test_data/pressure/air_temperature.nc new file mode 100644 index 000000000..c49d20d9d Binary files /dev/null and b/tests/test_data/pressure/air_temperature.nc differ diff --git a/tests/test_data/pressure/pressure.nc b/tests/test_data/pressure/pressure.nc new file mode 100644 index 000000000..9d3f1864c Binary files /dev/null and b/tests/test_data/pressure/pressure.nc differ diff --git a/tests/test_data/pressure/relative_humidity.nc b/tests/test_data/pressure/relative_humidity.nc new file mode 100644 index 000000000..908b6e32c Binary files /dev/null and b/tests/test_data/pressure/relative_humidity.nc differ