From 8195c5c2a37c331a82ad1cf84fa880503a6563fa Mon Sep 17 00:00:00 2001 From: daflack Date: Fri, 2 Jan 2026 11:37:50 +0000 Subject: [PATCH 01/49] Basic conversion operators for humidity, temperature and pressure Fixes #1852 --- src/CSET/operators/humidity.py | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/CSET/operators/humidity.py diff --git a/src/CSET/operators/humidity.py b/src/CSET/operators/humidity.py new file mode 100644 index 000000000..c54590061 --- /dev/null +++ b/src/CSET/operators/humidity.py @@ -0,0 +1,35 @@ +# © 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 + + +def specific_humidity_to_mixing_ratio( + cubes: iris.cube.Cube | iris.cube.CubeList, +) -> iris.cube.Cube | iris.cube.CubeList: + """Convert specific humidity to mixing ratio.""" + w = iris.cube.CubeList([]) + for cube in iter_maybe(cubes): + mr = cube.copy() + mr = cube / (1 - cube) + mr.rename("mixing_ratio") + w.append(mr) + if len(w) == 1: + return w[0] + else: + return w From 24329ce531553663dfde331a7bd0301f88491f80 Mon Sep 17 00:00:00 2001 From: daflack Date: Fri, 2 Jan 2026 11:54:22 +0000 Subject: [PATCH 02/49] Add mixing ratio to specific humidity operator --- src/CSET/operators/humidity.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/CSET/operators/humidity.py b/src/CSET/operators/humidity.py index c54590061..8f37371fb 100644 --- a/src/CSET/operators/humidity.py +++ b/src/CSET/operators/humidity.py @@ -33,3 +33,19 @@ def specific_humidity_to_mixing_ratio( return w[0] else: return w + + +def mixing_ratio_to_specific_humidity( + cubes: iris.cube.Cube | iris.cube.CubeList, +) -> iris.cube.Cube | iris.cube.CubeList: + """Convert mixing ratio to specific humidity.""" + q = iris.cube.CubeList([]) + for cube in iter_maybe(cubes): + sh = cube.copy() + sh = cube / (1 + cube) + sh.rename("specific_humidity") + q.append(sh) + if len(q) == 1: + return q[0] + else: + return q From 1bed18f4f3f5f05ec5f6946b44351074bb6efe8f Mon Sep 17 00:00:00 2001 From: daflack Date: Fri, 2 Jan 2026 12:43:54 +0000 Subject: [PATCH 03/49] Add standard atmospheric constants --- src/CSET/operators/atmospheric_constants.py | 38 +++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/CSET/operators/atmospheric_constants.py diff --git a/src/CSET/operators/atmospheric_constants.py b/src/CSET/operators/atmospheric_constants.py new file mode 100644 index 000000000..2815c642a --- /dev/null +++ b/src/CSET/operators/atmospheric_constants.py @@ -0,0 +1,38 @@ +# © 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 From 6ddd18f6b379327ad0a31f6252f477b611a935d0 Mon Sep 17 00:00:00 2001 From: daflack Date: Fri, 2 Jan 2026 12:45:21 +0000 Subject: [PATCH 04/49] Add vapour pressure conversion --- src/CSET/operators/pressure.py | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/CSET/operators/pressure.py diff --git a/src/CSET/operators/pressure.py b/src/CSET/operators/pressure.py new file mode 100644 index 000000000..9d1bdc58b --- /dev/null +++ b/src/CSET/operators/pressure.py @@ -0,0 +1,37 @@ +# © 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 + + +def vapour_pressure( + temperature: iris.cube.Cube | iris.cube.CubeList, +) -> iris.cube.Cube | iris.cube.CubeList: + """Calculate the vapour pressure of the atmosphere.""" + vapour_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()) + vapour_pressure.append(es) + if len(vapour_pressure) == 1: + return vapour_pressure[0] + else: + return vapour_pressure From afd8447c845fb786ad73a8fdb1605b4d8aabaa37 Mon Sep 17 00:00:00 2001 From: daflack Date: Fri, 2 Jan 2026 13:17:08 +0000 Subject: [PATCH 05/49] Add vapour pressure if dewpoint unknown --- src/CSET/operators/pressure.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/CSET/operators/pressure.py b/src/CSET/operators/pressure.py index 9d1bdc58b..92f996836 100644 --- a/src/CSET/operators/pressure.py +++ b/src/CSET/operators/pressure.py @@ -30,8 +30,30 @@ def vapour_pressure( 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") vapour_pressure.append(es) if len(vapour_pressure) == 1: return vapour_pressure[0] else: return vapour_pressure + + +def vapour_pressure_if_Td_unknown( + 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.""" + vapour_pressure = iris.cube.CubeList([]) + for T, RH in zip( + iter_maybe(temperature), iter_maybe(relative_humidity), strict=True + ): + if RH.units == "%": + RH /= 100.0 + RH.units = "1" + vp = vapour_pressure(T) * RH + vapour_pressure.append(vp) + if len(vapour_pressure) == 1: + return vapour_pressure[0] + else: + return vapour_pressure From 121f46e3b8a18018bfa16b5820b83cf4880c0dc1 Mon Sep 17 00:00:00 2001 From: daflack Date: Fri, 2 Jan 2026 13:21:49 +0000 Subject: [PATCH 06/49] Update naming convections for vapour pressures --- src/CSET/operators/pressure.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/CSET/operators/pressure.py b/src/CSET/operators/pressure.py index 92f996836..7e59f0dfa 100644 --- a/src/CSET/operators/pressure.py +++ b/src/CSET/operators/pressure.py @@ -25,26 +25,26 @@ def vapour_pressure( temperature: iris.cube.Cube | iris.cube.CubeList, ) -> iris.cube.Cube | iris.cube.CubeList: """Calculate the vapour pressure of the atmosphere.""" - vapour_pressure = iris.cube.CubeList([]) + 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") - vapour_pressure.append(es) - if len(vapour_pressure) == 1: - return vapour_pressure[0] + v_pressure.append(es) + if len(v_pressure) == 1: + return v_pressure[0] else: - return vapour_pressure + return v_pressure -def vapour_pressure_if_Td_unknown( +def vapour_pressure_if_dewpoint_unknown( 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.""" - vapour_pressure = iris.cube.CubeList([]) + v_pressure = iris.cube.CubeList([]) for T, RH in zip( iter_maybe(temperature), iter_maybe(relative_humidity), strict=True ): @@ -52,8 +52,8 @@ def vapour_pressure_if_Td_unknown( RH /= 100.0 RH.units = "1" vp = vapour_pressure(T) * RH - vapour_pressure.append(vp) - if len(vapour_pressure) == 1: - return vapour_pressure[0] + v_pressure.append(vp) + if len(v_pressure) == 1: + return v_pressure[0] else: - return vapour_pressure + return v_pressure From e042a620e2ff3f89d0ab9e35b327b7acafc914f2 Mon Sep 17 00:00:00 2001 From: daflack Date: Fri, 2 Jan 2026 13:32:53 +0000 Subject: [PATCH 07/49] Calculate saturation mixing ratio and specific humidity --- src/CSET/operators/humidity.py | 36 ++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/CSET/operators/humidity.py b/src/CSET/operators/humidity.py index 8f37371fb..e39b76345 100644 --- a/src/CSET/operators/humidity.py +++ b/src/CSET/operators/humidity.py @@ -17,6 +17,8 @@ import iris.cube from CSET._common import iter_maybe +from CSET.operators.constants import EPSILON +from CSET.operators.pressure import vapour_pressure def specific_humidity_to_mixing_ratio( @@ -49,3 +51,37 @@ def mixing_ratio_to_specific_humidity( 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): + mr = (EPSILON * vapour_pressure(T)) / ((P / 100.0) - vapour_pressure(T)) + mr.units("kg/kg") + mr.rename("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): + sh = (EPSILON * vapour_pressure(T)) / (P / 100.0) + sh.units("kg/kg") + sh.rename("specific_humidity") + q.append(sh) + if len(q) == 1: + return q[0] + else: + return q From fdebe14ee58fbf1e4a896a99ac9221cee2bb6d9b Mon Sep 17 00:00:00 2001 From: daflack Date: Fri, 2 Jan 2026 13:34:48 +0000 Subject: [PATCH 08/49] Rename vapour_pressure_if_dewpoint_unknown to vapour_pressure_from_RH --- src/CSET/operators/pressure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CSET/operators/pressure.py b/src/CSET/operators/pressure.py index 7e59f0dfa..f25c33c1e 100644 --- a/src/CSET/operators/pressure.py +++ b/src/CSET/operators/pressure.py @@ -39,7 +39,7 @@ def vapour_pressure( return v_pressure -def vapour_pressure_if_dewpoint_unknown( +def vapour_pressure_from_RH( temperature: iris.cube.Cube | iris.cube.CubeList, relative_humidity: iris.cube.Cube | iris.cube.CubeList, ) -> iris.cube.Cube | iris.cube.CubeList: From 118affc26a853918a357d6ea08f28741d27bd9cc Mon Sep 17 00:00:00 2001 From: daflack Date: Fri, 2 Jan 2026 14:02:32 +0000 Subject: [PATCH 09/49] Add mixing ratio from relative humidity conversion --- src/CSET/operators/humidity.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/CSET/operators/humidity.py b/src/CSET/operators/humidity.py index e39b76345..d5272937b 100644 --- a/src/CSET/operators/humidity.py +++ b/src/CSET/operators/humidity.py @@ -85,3 +85,27 @@ def saturation_specific_humidity( return q[0] else: return q + + +def mixing_ratio_from_RH( + 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, + ): + if RH.units == "%": + RH /= 100.0 + RH.units = "1" + mr = saturation_mixing_ratio(T, P) * RH + w.append(mr) + if len(w) == 1: + return w[0] + else: + return w From 5250de4f28a5559e6e3007aef3281f42af944f8c Mon Sep 17 00:00:00 2001 From: daflack Date: Fri, 2 Jan 2026 14:06:45 +0000 Subject: [PATCH 10/49] Add specific humidity from RH conversion --- src/CSET/operators/humidity.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/CSET/operators/humidity.py b/src/CSET/operators/humidity.py index d5272937b..b48f452d6 100644 --- a/src/CSET/operators/humidity.py +++ b/src/CSET/operators/humidity.py @@ -109,3 +109,27 @@ def mixing_ratio_from_RH( return w[0] else: return w + + +def specific_humidity_from_RH( + 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, + ): + if RH.units == "%": + RH /= 100.0 + RH.units = "1" + sh = saturation_specific_humidity(T, P) * RH + q.append(sh) + if len(q) == 1: + return q[0] + else: + return q From bdf29a999eb8a2fbb3e87fe66c3ac916f6758cba Mon Sep 17 00:00:00 2001 From: daflack Date: Fri, 2 Jan 2026 14:19:29 +0000 Subject: [PATCH 11/49] Add relative humidity conversions from mixing ratio and specific humidity --- src/CSET/operators/humidity.py | 44 ++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/CSET/operators/humidity.py b/src/CSET/operators/humidity.py index b48f452d6..d83497649 100644 --- a/src/CSET/operators/humidity.py +++ b/src/CSET/operators/humidity.py @@ -133,3 +133,47 @@ def specific_humidity_from_RH( 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") + 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") + RH.append(rel_h) + if len(RH) == 1: + return RH[0] + else: + return RH From 007a7594a165f8ae997d1ba37b03dacb94e9b5bb Mon Sep 17 00:00:00 2001 From: daflack Date: Fri, 2 Jan 2026 14:32:46 +0000 Subject: [PATCH 12/49] Add dewpoint temperature calculation --- src/CSET/operators/temperature.py | 47 +++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/CSET/operators/temperature.py diff --git a/src/CSET/operators/temperature.py b/src/CSET/operators/temperature.py new file mode 100644 index 000000000..ed69f6d8c --- /dev/null +++ b/src/CSET/operators/temperature.py @@ -0,0 +1,47 @@ +# © 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.constants import T0 +from CSET.operators.pressure import vapour_pressure + + +def dewpoint( + 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.""" + Td = iris.cube.CubeList([]) + for T, RH in zip( + iter_maybe(temperature), iter_maybe(relative_humidity), strict=True + ): + vp = vapour_pressure(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[td.data - T0 < -35.0] = np.nan + td.data[td.data - T0 > 35.0] = np.nan + td.units("K") + td.rename("dewpoint temperature") + Td.append(td) + if len(Td) == 1: + return Td[0] + else: + return Td From 941322e9c2c3f937115ee1835ad691a01c00983d Mon Sep 17 00:00:00 2001 From: daflack Date: Fri, 2 Jan 2026 14:55:00 +0000 Subject: [PATCH 13/49] Add virtual temperature calculation --- src/CSET/operators/temperature.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/CSET/operators/temperature.py b/src/CSET/operators/temperature.py index ed69f6d8c..7d88e9f47 100644 --- a/src/CSET/operators/temperature.py +++ b/src/CSET/operators/temperature.py @@ -18,11 +18,11 @@ import numpy as np from CSET._common import iter_maybe -from CSET.operators.constants import T0 +from CSET.operators.constants import EPSILON, T0 from CSET.operators.pressure import vapour_pressure -def dewpoint( +def dewpoint_temperature( temperature: iris.cube.Cube | iris.cube.CubeList, relative_humidity: iris.cube.Cube | iris.cube.CubeList, ) -> iris.cube.Cube | iris.cube.CubeList: @@ -45,3 +45,19 @@ def dewpoint( 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 From 0cd899515d4809af97498b29b5d10cb33c37a5ee Mon Sep 17 00:00:00 2001 From: daflack Date: Fri, 2 Jan 2026 14:59:17 +0000 Subject: [PATCH 14/49] Update atmospheric constants with kappa --- src/CSET/operators/atmospheric_constants.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/CSET/operators/atmospheric_constants.py b/src/CSET/operators/atmospheric_constants.py index 2815c642a..85390b730 100644 --- a/src/CSET/operators/atmospheric_constants.py +++ b/src/CSET/operators/atmospheric_constants.py @@ -36,3 +36,6 @@ # Ratio between mixing ratio of dry and moist air. EPSILON = 0.622 + +# Ratio between specific gas constant and specific heat capacity. +KAPPA = RD / CPD From 8779ae3a6ad8e83c59e33674cbfebde71c0df425 Mon Sep 17 00:00:00 2001 From: daflack Date: Mon, 5 Jan 2026 09:34:57 +0000 Subject: [PATCH 15/49] Add potential temperature and exner pressure convertors --- src/CSET/operators/pressure.py | 19 ++++++++++++++++++- src/CSET/operators/temperature.py | 19 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/CSET/operators/pressure.py b/src/CSET/operators/pressure.py index f25c33c1e..f18d048cc 100644 --- a/src/CSET/operators/pressure.py +++ b/src/CSET/operators/pressure.py @@ -18,7 +18,7 @@ import numpy as np from CSET._common import iter_maybe -from CSET.operators.atmospheric_constants import E0 +from CSET.operators.atmospheric_constants import E0, KAPPA, P0 def vapour_pressure( @@ -57,3 +57,20 @@ def vapour_pressure_from_RH( 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() + 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 index 7d88e9f47..50fb8bb2d 100644 --- a/src/CSET/operators/temperature.py +++ b/src/CSET/operators/temperature.py @@ -19,7 +19,7 @@ from CSET._common import iter_maybe from CSET.operators.constants import EPSILON, T0 -from CSET.operators.pressure import vapour_pressure +from CSET.operators.pressure import exner_pressure, vapour_pressure def dewpoint_temperature( @@ -61,3 +61,20 @@ def virtual_temperature( return Tv[0] else: return Tv + + +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") + TH.units("K") + theta.append(TH) + if len(theta) == 1: + return theta[0] + else: + return theta From 5c6f4ba355ba23cf1272d5ba40acb92d7ef1ab03 Mon Sep 17 00:00:00 2001 From: daflack Date: Mon, 5 Jan 2026 09:58:48 +0000 Subject: [PATCH 16/49] Adds virtual potential temperature convertor --- src/CSET/operators/temperature.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/CSET/operators/temperature.py b/src/CSET/operators/temperature.py index 50fb8bb2d..49ce02e8a 100644 --- a/src/CSET/operators/temperature.py +++ b/src/CSET/operators/temperature.py @@ -72,9 +72,30 @@ def potential_temperature( for T, P in zip(iter_maybe(temperature), iter_maybe(pressure), strict=True): TH = T / exner_pressure(P) TH.rename("potential_temperature") - TH.units("K") 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 From 7e5c20832b66ae7125ad92c51b411ce55fbe027c Mon Sep 17 00:00:00 2001 From: daflack Date: Mon, 5 Jan 2026 11:03:39 +0000 Subject: [PATCH 17/49] Adds equivalent potential temperature conversion and fixes unit assignment --- src/CSET/operators/humidity.py | 4 ++-- src/CSET/operators/pressure.py | 2 +- src/CSET/operators/temperature.py | 37 +++++++++++++++++++++++++++++-- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/CSET/operators/humidity.py b/src/CSET/operators/humidity.py index d83497649..6af3917f2 100644 --- a/src/CSET/operators/humidity.py +++ b/src/CSET/operators/humidity.py @@ -61,7 +61,7 @@ def saturation_mixing_ratio( w = iris.cube.CubeList([]) for T, P in zip(iter_maybe(temperature), iter_maybe(pressure), strict=True): mr = (EPSILON * vapour_pressure(T)) / ((P / 100.0) - vapour_pressure(T)) - mr.units("kg/kg") + mr.units = "kg/kg" mr.rename("mixing_ratio") w.append(mr) if len(w) == 1: @@ -78,7 +78,7 @@ def saturation_specific_humidity( q = iris.cube.CubeList([]) for T, P in zip(iter_maybe(temperature), iter_maybe(pressure), strict=True): sh = (EPSILON * vapour_pressure(T)) / (P / 100.0) - sh.units("kg/kg") + sh.units = "kg/kg" sh.rename("specific_humidity") q.append(sh) if len(q) == 1: diff --git a/src/CSET/operators/pressure.py b/src/CSET/operators/pressure.py index f18d048cc..a0a30832c 100644 --- a/src/CSET/operators/pressure.py +++ b/src/CSET/operators/pressure.py @@ -68,7 +68,7 @@ def exner_pressure( PI = P.copy() PI.data[:] = (P.core_data() / P0) ** KAPPA PI.rename("exner_pressure") - PI.units("1") + PI.units = "1" pi.append(PI) if len(pi) == 1: return pi[0] diff --git a/src/CSET/operators/temperature.py b/src/CSET/operators/temperature.py index 49ce02e8a..3cb87e502 100644 --- a/src/CSET/operators/temperature.py +++ b/src/CSET/operators/temperature.py @@ -18,7 +18,8 @@ import numpy as np from CSET._common import iter_maybe -from CSET.operators.constants import EPSILON, T0 +from CSET.operators.constants import CPD, EPSILON, LV, RV, T0 +from CSET.operators.humidity import mixing_ratio_from_RH from CSET.operators.pressure import exner_pressure, vapour_pressure @@ -38,7 +39,7 @@ def dewpoint_temperature( ) td.data[td.data - T0 < -35.0] = np.nan td.data[td.data - T0 > 35.0] = np.nan - td.units("K") + td.units = "K" td.rename("dewpoint temperature") Td.append(td) if len(Td) == 1: @@ -99,3 +100,35 @@ def virtual_potential_temperature( 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, + ): + if RH.units == "%": + RH /= 100.0 + RH.units = "1" + theta = potential_temperature(T, P) + w = mixing_ratio_from_RH(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 From c725bd3bdf1cf9cc4b8b7054cb2f74afab1ecbd8 Mon Sep 17 00:00:00 2001 From: daflack Date: Mon, 5 Jan 2026 11:38:37 +0000 Subject: [PATCH 18/49] Remove if loop for RH and switch to convert units --- src/CSET/operators/humidity.py | 9 +++------ src/CSET/operators/pressure.py | 5 ++--- src/CSET/operators/temperature.py | 5 ++--- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/CSET/operators/humidity.py b/src/CSET/operators/humidity.py index 6af3917f2..d15ed319e 100644 --- a/src/CSET/operators/humidity.py +++ b/src/CSET/operators/humidity.py @@ -18,6 +18,7 @@ from CSET._common import iter_maybe from CSET.operators.constants import EPSILON +from CSET.operators.misc import convert_units from CSET.operators.pressure import vapour_pressure @@ -100,9 +101,7 @@ def mixing_ratio_from_RH( iter_maybe(relative_humidity), strict=True, ): - if RH.units == "%": - RH /= 100.0 - RH.units = "1" + RH = convert_units(RH, "1") mr = saturation_mixing_ratio(T, P) * RH w.append(mr) if len(w) == 1: @@ -124,9 +123,7 @@ def specific_humidity_from_RH( iter_maybe(relative_humidity), strict=True, ): - if RH.units == "%": - RH /= 100.0 - RH.units = "1" + RH = convert_units(RH, "1") sh = saturation_specific_humidity(T, P) * RH q.append(sh) if len(q) == 1: diff --git a/src/CSET/operators/pressure.py b/src/CSET/operators/pressure.py index a0a30832c..5f5f92c4a 100644 --- a/src/CSET/operators/pressure.py +++ b/src/CSET/operators/pressure.py @@ -19,6 +19,7 @@ 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( @@ -48,9 +49,7 @@ def vapour_pressure_from_RH( for T, RH in zip( iter_maybe(temperature), iter_maybe(relative_humidity), strict=True ): - if RH.units == "%": - RH /= 100.0 - RH.units = "1" + RH = convert_units(RH, "1") vp = vapour_pressure(T) * RH v_pressure.append(vp) if len(v_pressure) == 1: diff --git a/src/CSET/operators/temperature.py b/src/CSET/operators/temperature.py index 3cb87e502..738d264c5 100644 --- a/src/CSET/operators/temperature.py +++ b/src/CSET/operators/temperature.py @@ -20,6 +20,7 @@ from CSET._common import iter_maybe from CSET.operators.constants import CPD, EPSILON, LV, RV, T0 from CSET.operators.humidity import mixing_ratio_from_RH +from CSET.operators.misc import convert_units from CSET.operators.pressure import exner_pressure, vapour_pressure @@ -115,9 +116,7 @@ def equivalent_potential_temperature( iter_maybe(pressure), strict=True, ): - if RH.units == "%": - RH /= 100.0 - RH.units = "1" + RH = convert_units(RH, "1") theta = potential_temperature(T, P) w = mixing_ratio_from_RH(T, P, RH) second_term_power = -(w * RV) / CPD From 317d6391e040377c42fd20140ad0815627ca5c2e Mon Sep 17 00:00:00 2001 From: daflack Date: Mon, 5 Jan 2026 11:57:55 +0000 Subject: [PATCH 19/49] Adds wet-bulb temperature convertor --- src/CSET/operators/temperature.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/CSET/operators/temperature.py b/src/CSET/operators/temperature.py index 738d264c5..9117e59ca 100644 --- a/src/CSET/operators/temperature.py +++ b/src/CSET/operators/temperature.py @@ -65,6 +65,35 @@ def virtual_temperature( 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.""" + Tw = iris.cube.CubeList([]) + for T, RH in zip( + iter_maybe(temperature), iter_maybe(relative_humidity), strict=True + ): + RH = convert_units(RH, "1") + 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, From 366455eddae2c83b76b03daf843230c26191ea18 Mon Sep 17 00:00:00 2001 From: daflack Date: Mon, 5 Jan 2026 12:20:31 +0000 Subject: [PATCH 20/49] Adds relevant references for temperature calculations where required --- src/CSET/operators/temperature.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CSET/operators/temperature.py b/src/CSET/operators/temperature.py index 9117e59ca..ea53b091d 100644 --- a/src/CSET/operators/temperature.py +++ b/src/CSET/operators/temperature.py @@ -28,7 +28,7 @@ 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.""" + """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 @@ -69,7 +69,7 @@ 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.""" + """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 From 942195e0e68d06b222558d03b1495d6d67bdb0f1 Mon Sep 17 00:00:00 2001 From: daflack Date: Mon, 5 Jan 2026 12:38:28 +0000 Subject: [PATCH 21/49] Adds wet-bulb potential temperature convertor --- src/CSET/operators/temperature.py | 40 +++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/CSET/operators/temperature.py b/src/CSET/operators/temperature.py index ea53b091d..3d8b3dc4d 100644 --- a/src/CSET/operators/temperature.py +++ b/src/CSET/operators/temperature.py @@ -160,3 +160,43 @@ def equivalent_potential_temperature( 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 From dd8600a933c09c46fa5cb324f9f3a2982716874d Mon Sep 17 00:00:00 2001 From: daflack Date: Mon, 5 Jan 2026 13:36:30 +0000 Subject: [PATCH 22/49] Adds saturation equivalent potential temperature convertor --- src/CSET/operators/temperature.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/CSET/operators/temperature.py b/src/CSET/operators/temperature.py index 3d8b3dc4d..087ff24ca 100644 --- a/src/CSET/operators/temperature.py +++ b/src/CSET/operators/temperature.py @@ -19,7 +19,7 @@ from CSET._common import iter_maybe from CSET.operators.constants import CPD, EPSILON, LV, RV, T0 -from CSET.operators.humidity import mixing_ratio_from_RH +from CSET.operators.humidity import mixing_ratio_from_RH, saturation_mixing_ratio from CSET.operators.misc import convert_units from CSET.operators.pressure import exner_pressure, vapour_pressure @@ -200,3 +200,28 @@ def wet_bulb_potential_temperature( 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 From 72bab9f643ffd884e80ad73acfea398c5b0af4a3 Mon Sep 17 00:00:00 2001 From: daflack Date: Mon, 5 Jan 2026 13:54:59 +0000 Subject: [PATCH 23/49] Adds to init file and updates name for atmospheric constants --- src/CSET/operators/__init__.py | 6 ++++++ .../{atmospheric_constants.py => _atmospheric_constants.py} | 0 src/CSET/operators/humidity.py | 2 +- src/CSET/operators/pressure.py | 2 +- src/CSET/operators/temperature.py | 2 +- 5 files changed, 9 insertions(+), 3 deletions(-) rename src/CSET/operators/{atmospheric_constants.py => _atmospheric_constants.py} (100%) 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 similarity index 100% rename from src/CSET/operators/atmospheric_constants.py rename to src/CSET/operators/_atmospheric_constants.py diff --git a/src/CSET/operators/humidity.py b/src/CSET/operators/humidity.py index d15ed319e..25b4782c7 100644 --- a/src/CSET/operators/humidity.py +++ b/src/CSET/operators/humidity.py @@ -17,7 +17,7 @@ import iris.cube from CSET._common import iter_maybe -from CSET.operators.constants import EPSILON +from CSET.operators._atmospheric_constants import EPSILON from CSET.operators.misc import convert_units from CSET.operators.pressure import vapour_pressure diff --git a/src/CSET/operators/pressure.py b/src/CSET/operators/pressure.py index 5f5f92c4a..9210fc3b4 100644 --- a/src/CSET/operators/pressure.py +++ b/src/CSET/operators/pressure.py @@ -18,7 +18,7 @@ import numpy as np from CSET._common import iter_maybe -from CSET.operators.atmospheric_constants import E0, KAPPA, P0 +from CSET.operators._atmospheric_constants import E0, KAPPA, P0 from CSET.operators.misc import convert_units diff --git a/src/CSET/operators/temperature.py b/src/CSET/operators/temperature.py index 087ff24ca..5b66f1b78 100644 --- a/src/CSET/operators/temperature.py +++ b/src/CSET/operators/temperature.py @@ -18,7 +18,7 @@ import numpy as np from CSET._common import iter_maybe -from CSET.operators.constants import CPD, EPSILON, LV, RV, T0 +from CSET.operators._atmospheric_constants import CPD, EPSILON, LV, RV, T0 from CSET.operators.humidity import mixing_ratio_from_RH, saturation_mixing_ratio from CSET.operators.misc import convert_units from CSET.operators.pressure import exner_pressure, vapour_pressure From c382b3e8ce1c11b7bdf80e5448226a8ef058f16b Mon Sep 17 00:00:00 2001 From: daflack Date: Tue, 6 Jan 2026 09:51:11 +0000 Subject: [PATCH 24/49] Update argument names in specific humidity to mixing ratio and vice versa --- src/CSET/operators/humidity.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/CSET/operators/humidity.py b/src/CSET/operators/humidity.py index 25b4782c7..cf0ba2ae0 100644 --- a/src/CSET/operators/humidity.py +++ b/src/CSET/operators/humidity.py @@ -23,13 +23,13 @@ def specific_humidity_to_mixing_ratio( - cubes: iris.cube.Cube | iris.cube.CubeList, + 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 cube in iter_maybe(cubes): - mr = cube.copy() - mr = cube / (1 - cube) + 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: @@ -39,13 +39,13 @@ def specific_humidity_to_mixing_ratio( def mixing_ratio_to_specific_humidity( - cubes: iris.cube.Cube | iris.cube.CubeList, + 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 cube in iter_maybe(cubes): - sh = cube.copy() - sh = cube / (1 + cube) + 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: From b8bac07f934812334d537cf567490b6e16aa2160 Mon Sep 17 00:00:00 2001 From: daflack Date: Tue, 6 Jan 2026 11:47:55 +0000 Subject: [PATCH 25/49] Uses convert_units for pressure consistency --- src/CSET/operators/humidity.py | 6 ++++-- src/CSET/operators/pressure.py | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/CSET/operators/humidity.py b/src/CSET/operators/humidity.py index cf0ba2ae0..072f2d170 100644 --- a/src/CSET/operators/humidity.py +++ b/src/CSET/operators/humidity.py @@ -61,7 +61,8 @@ def saturation_mixing_ratio( """Calculate saturation mixing ratio.""" w = iris.cube.CubeList([]) for T, P in zip(iter_maybe(temperature), iter_maybe(pressure), strict=True): - mr = (EPSILON * vapour_pressure(T)) / ((P / 100.0) - vapour_pressure(T)) + P = convert_units(P, "hPa") + mr = (EPSILON * vapour_pressure(T)) / (P - vapour_pressure(T)) mr.units = "kg/kg" mr.rename("mixing_ratio") w.append(mr) @@ -78,7 +79,8 @@ def saturation_specific_humidity( """Calculate saturation specific humidity.""" q = iris.cube.CubeList([]) for T, P in zip(iter_maybe(temperature), iter_maybe(pressure), strict=True): - sh = (EPSILON * vapour_pressure(T)) / (P / 100.0) + P = convert_units(P, "hPa") + sh = (EPSILON * vapour_pressure(T)) / P sh.units = "kg/kg" sh.rename("specific_humidity") q.append(sh) diff --git a/src/CSET/operators/pressure.py b/src/CSET/operators/pressure.py index 9210fc3b4..fa394b321 100644 --- a/src/CSET/operators/pressure.py +++ b/src/CSET/operators/pressure.py @@ -65,6 +65,7 @@ def 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" From b101562d06c0dad814959681fa428d4879fe8392 Mon Sep 17 00:00:00 2001 From: daflack Date: Wed, 7 Jan 2026 10:12:13 +0000 Subject: [PATCH 26/49] Adds names to humidity cubes where missing --- src/CSET/operators/humidity.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/CSET/operators/humidity.py b/src/CSET/operators/humidity.py index 072f2d170..b68e2c3a2 100644 --- a/src/CSET/operators/humidity.py +++ b/src/CSET/operators/humidity.py @@ -64,7 +64,7 @@ def saturation_mixing_ratio( P = convert_units(P, "hPa") mr = (EPSILON * vapour_pressure(T)) / (P - vapour_pressure(T)) mr.units = "kg/kg" - mr.rename("mixing_ratio") + mr.rename("saturation_mixing_ratio") w.append(mr) if len(w) == 1: return w[0] @@ -82,7 +82,7 @@ def saturation_specific_humidity( P = convert_units(P, "hPa") sh = (EPSILON * vapour_pressure(T)) / P sh.units = "kg/kg" - sh.rename("specific_humidity") + sh.rename("saturation_specific_humidity") q.append(sh) if len(q) == 1: return q[0] @@ -105,6 +105,8 @@ def mixing_ratio_from_RH( ): 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] @@ -127,6 +129,8 @@ def specific_humidity_from_RH( ): 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] From b8ef61b125dfca00bfd9a2f1caeabca9eaa60d94 Mon Sep 17 00:00:00 2001 From: daflack Date: Wed, 7 Jan 2026 10:57:21 +0000 Subject: [PATCH 27/49] Correct units --- src/CSET/operators/pressure.py | 2 ++ src/CSET/operators/temperature.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/CSET/operators/pressure.py b/src/CSET/operators/pressure.py index fa394b321..38d4d5ee9 100644 --- a/src/CSET/operators/pressure.py +++ b/src/CSET/operators/pressure.py @@ -51,6 +51,8 @@ def vapour_pressure_from_RH( ): 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] diff --git a/src/CSET/operators/temperature.py b/src/CSET/operators/temperature.py index 5b66f1b78..a4eaa555f 100644 --- a/src/CSET/operators/temperature.py +++ b/src/CSET/operators/temperature.py @@ -74,7 +74,7 @@ def wet_bulb_temperature( for T, RH in zip( iter_maybe(temperature), iter_maybe(relative_humidity), strict=True ): - RH = convert_units(RH, "1") + RH = convert_units(RH, "%") T = convert_units(T, "Celsius") wetT = ( T * np.arctan(0.151977 * (RH.core_data() + 8.313659) ** 0.5) From d51b5c2fd76a4ba8e08e92b6254485654a5b4f93 Mon Sep 17 00:00:00 2001 From: daflack Date: Wed, 7 Jan 2026 11:34:02 +0000 Subject: [PATCH 28/49] Adds tests data and fixtures --- tests/conftest.py | 38 ++++++++++++++++++ tests/test_data/pressure/air_temperature.nc | Bin 0 -> 15681 bytes tests/test_data/pressure/pressure.nc | Bin 0 -> 15516 bytes tests/test_data/pressure/relative_humidity.nc | Bin 0 -> 15759 bytes 4 files changed, 38 insertions(+) create mode 100644 tests/test_data/pressure/air_temperature.nc create mode 100644 tests/test_data/pressure/pressure.nc create mode 100644 tests/test_data/pressure/relative_humidity.nc diff --git a/tests/conftest.py b/tests/conftest.py index 7bc1c40bb..b372970fa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -323,3 +323,41 @@ 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() diff --git a/tests/test_data/pressure/air_temperature.nc b/tests/test_data/pressure/air_temperature.nc new file mode 100644 index 0000000000000000000000000000000000000000..c49d20d9d5387ed0cb904ab6e6c1aa59af2ee587 GIT binary patch literal 15681 zcmeHOZ)_aJ6@PbYU(U`taYC9vh?>PoX$Z;X9J@g_HTZnKi>>%i@HsS4l+AIwXRqqp zJ@(d?KhOeA)RqqjwP_zRf=k@YNZ(lGT2#~x$3c7@-=8$Pn0Up>te;H7u&;FtG{QfQzI?+j*s`P@@~ zXX-a~ghQLDDjW`lgj}eW);Ch0l-qe{E{dRpu<=(zm#ffB^6a8VYQOF0eijZ zO)A1KQCyUC^d zn~4U(Y_@U(fk&gONum;xbBpuMT17OLBG1vbDM{6KkP=}ulRx>mu|3nq%TvNl@a-PjgUH?%K@_Rn!tai)Z% z*~pZ!;E$T7#uaGdqRp)9jq7gKuqI2j$m)J?aDPG_AtO@YCN7Kh)^(V4*YKr9Tk z$OU*b(XD1^Y!n2Fb$^~*xz5xk_$`wOuAdcLp(|^njB{;*5mn{WKON;B!%UZU5;EQ1{=w8xrhj-y&mFu zkXD8{=9*?nZ3S4yddv7qUe-3Ut}(kF_(77!T^fk}fSQ;4vs^S+<{j6}WlNrpJGA8( zo7lb#@aMIOlR~qdhwUqLYZx^|QbY%S(lDn@G+}M8#E6r-<=#DV8+9qpQO0}cAt|3hTVrnvOM-x!61?| zEB)8ORe(T%K!8AiK!8AiK!8AiK!8AiK!8AiK!8Ai!2b^dI6Bl z6<7&zarmn_GIDAjYfvbLbR z_9E8%>G+P*iS$U{UM4Bb8W0T9{VW|{(y1XDkPVf^Nl#6=ph^by<0@i3ccN{WNit=+)cdZT+id2S>}rp7#M-uv z8~LwwwZ>Z8W4j`^ARBt3WVc{$Z{hVxNilM$$L%z9IElk%tP!$;^_Fs+1pk<@>cnmzGQIyZzcno?IO(!w`v5LuG zazF-C2w&Gzgif`9jQ#(+6Edm8>8GbtFQ@x@d&)6;toY@Fn;R57r7t-khVUVhDdnk= z@&R{#1c!umPUz$Uu^}Xg1n*Js1kd9}3OWibjq4?6(#`P*F*>0WS0Ub7inm!i+jc3q zZ}9MuS*{kr)r)=xE$=!7ZP4_z;e6i8nc6srgmD} zVsQ!yV=XY-5J78<>x<*n$P>~`o{-tNAm$07_$BgmNCybT8&P{iG9^Tu23EviuDm9m z8p4DW9AwsoTt&PSIUw5bOS0yK&S>Rp*UX!)Y3EGcLm!PcZdzmGo<@1RE1>HK0lw%% zs3P1`1l`RKldNAs2B&P5aXSFVcFSO|w1`KNb#kc>R}G?4H9~2DDNkN0GQM$~xg{Ra zyM(2rY??gE3$~K31hydS}*``;l-L_VY>+ zW6XW15r%T8PKF(?E02P|g&i?|^I!JA`e0=g+t3`VQEw$h6+9L2WhpyhQtTgFRmrlb z6S8~Bqu)JqwIe32_ue^rNb6l>%<#Cg;F!q`tDxc|#iFA^ps~?qiziKoimn%(lI2-b ze0w7<`c4*E6d~s3&~xWc{*iE%dRL!y+(vuwd`I*UPpBiDMsIm!-?akuK<%Jj?L@#p z!bKsM`!{V=9Ell$M7uPYx5 zjA12Gg+^kZO*;0JX?y6?=Z=190?G9D*4Q1VPQRd2gSE_ppTBtg+0l_yivCMOzSodx z3)i9Q??&-2Fp@zUflTE~1z8`Y0RjO60RjO60RjO60RjO6*Ea&w2mUtw>Z;?b9@^CX d?r)YIyYu2<{qm15e)!?{wXquk63;gu{5K9!(9Hk< literal 0 HcmV?d00001 diff --git a/tests/test_data/pressure/pressure.nc b/tests/test_data/pressure/pressure.nc new file mode 100644 index 0000000000000000000000000000000000000000..9d3f1864c5265df0016842e827e69868e13bd365 GIT binary patch literal 15516 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 literal 0 HcmV?d00001 diff --git a/tests/test_data/pressure/relative_humidity.nc b/tests/test_data/pressure/relative_humidity.nc new file mode 100644 index 0000000000000000000000000000000000000000..908b6e32c96aec869838440042045006d3ea5d8a GIT binary patch literal 15759 zcmeHOeQX@X6@PnszFeI3M<697iOAN0CIoUm$0jsszRu^n*uo!)&!7<6ET`K&dnMoP zvA2#~HEojy1^P!7rD^jIDvAVcRTb2x0i>!QL~8yhjSwJ#1hlCHQVBtYrYe6_NHl$K zKJL6{pW`EG)kr(`-R#c1dGqEsZ{F^_dH0?EzI03Tisn!>3Nl?0>DV@25*HOOwJv^d zq`z-8q&*kvU}frJQ6-zy=FK5ch$M15eJf%5rnJz%nML3btu`U;8_+fXD{A zP{OPkUafE)FY8&RLG<2~X&IAFahzRkQhA&9cFJR70Qd7kfF<%mRnv)%LkX-LieE%4NL(lrJP?yms3}2NZs7c`y{6HA+pb{VwsBk zR`A**LgJr_TwR8IV5z>E5d!G2JPOOdMZ`z3VshW_CJZ-g+Ee95cVTXcwF$(h8}FU&wAd;tz6c4`@# z8x?_K!=J@hT9w8aziKhYO;yG-WMzF+aBhq-qN;xKmy=x64k!xF7@vb#hN`aBn5lsP zmk)9~>D)@30KUhwid~9cRsWWOMv7w$TVBr0_aq(1HSHoA=dw@n>nVzM7uUZCSL5Vh zB9mdNOXizYw{Ku5HJlk388$M>#9#`vbzG~Oc!S}@P>RvF3VIH&)CXcudee$h;wq6+ z_SyJ+@yjO$uC3$>A=qc9?J{Fe%1z=t2yo%U7oJFG`uhN$6)G<^U*>`r_P86S0RO5?jlgUGP@+61g-UTLd4CSA*6;=1j#8 zGYwNk+v9^+gakAQ(;9aztE_baEMnj=y0VuonpxMZnh!swaxIhxV~>lTVB@33Pe~N6 zMJL2F>dP z+uS3+WmJoas)bYCQA>61AGrr-Vio(WSmFE5yjYO{G)~_Tr*{`mrZbrobP)8I6jYfE zgX5$3WAqTuy#4R@cO^##nd-1t7O5M%>a-$ruT*7DD#=DEmZ@H}}wmH^)_k@|h zXIodSt0%UJd$c^^x4|w_w_t#i)_UY26FZRFl$&0Y_O!8RondIu!nIvy; zc?acMVpW3Q|BKzXSMGUf@Esr!AP^uBAP^uBAaG+L&^tDm#`lefNa`Mbdh>_r&QW-q z3Pc>kpFTKq7+$AC9z2kwS2Ro8egcjWg$$supF9meqXK-_pyS*<&2K#qTd9eNPB{PW zx!=GQDi{Nxlf?7JH(r9zQ^D;#(qZ7E+yO{NJ|haaG=cz+#<+AH zqcNTmyABrRg`&tPvHxl`2APePBJ7f}vr`|tq|4F*PueTDj$BH}GzIj~5UHXE5JWyJ zP;!o74`M~Mcc{3B=Yb#v16h_QjIuN3=6Hk{?PrOq5br9-yX}oV_o(>f;PD_;st!Tb zhn@yK?>Ys2$nx}&eBRDk`u#;aZ(F85EwGqK5L8*kd+Q$JnZu{ecb3()sAvknB^PXrL2oq9qn%NL? zGvb}d!O)E#;+1z?E0-;M26`WC$J8tLu9df3tC+J454|+{gk_IUcsgBUt03Jd2ymkh z%8X!75pp*>NV2JwR83tk<8}a^+$CeT(jp#7)=5(zsvmf#6Gn3^_$DkS)tkU|Th=y` ze$Qh{q=l|1CRbEgPuv%t_8q=&OVu~`U3_X_N0-f;5|&}i%PZ*mdpIn-rz_W;e~vgy zJshslL+y(ssEYdxZsxL+76k_~D^0pegOJas_RSD1i-~sOU3QVsVLhRg-@C&d>i$xJtR}uVb#G z-^X=I=@FjMKzRDwo;S{2FHrY44(hcogtb?JT)q#`gO4N8M2}8PxP|6P8VZ0PnN#}+ z)Ch}GR>I=-7$W@X*&XK%BDA{D!bn>hCgzWeMWYFc#B)386c1QM56=Mj!GKhOWO_qa z?A~QZw)RtpjjV!S{OZ){vC&kDe)l0yN@RW;7qIU4BjQIjl0Yhf%qzzuk|4+f1OfyC z1OfyC1OfyC1OfzZXauJB=N>+!JQjH@a`b5A=u^8|mDi5{e$fvf26+18C8z%bqG;Lw literal 0 HcmV?d00001 From e5d1de9b8776e8e39f0a36a1b6c3a3cd9224f5e7 Mon Sep 17 00:00:00 2001 From: daflack Date: Wed, 7 Jan 2026 11:36:10 +0000 Subject: [PATCH 29/49] Update copyright year --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index b372970fa..dbc08c7c2 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. From 94a279b6a18b3a220620e1a619f97a1f030ae3a4 Mon Sep 17 00:00:00 2001 From: daflack Date: Wed, 7 Jan 2026 11:55:40 +0000 Subject: [PATCH 30/49] Add tests for vapour pressure --- tests/operators/test_pressure.py | 74 ++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/operators/test_pressure.py diff --git a/tests/operators/test_pressure.py b/tests/operators/test_pressure.py new file mode 100644 index 000000000..ec83bb182 --- /dev/null +++ b/tests/operators/test_pressure.py @@ -0,0 +1,74 @@ +# © 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 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 = 6.1078 * 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 = 6.1078 * 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) From 7c93b3af5e3832b5b501db6ba9f7bb071a10adb5 Mon Sep 17 00:00:00 2001 From: daflack Date: Wed, 7 Jan 2026 12:02:11 +0000 Subject: [PATCH 31/49] Update to assignment in pressure convertors --- src/CSET/operators/pressure.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CSET/operators/pressure.py b/src/CSET/operators/pressure.py index 38d4d5ee9..b381ae1c9 100644 --- a/src/CSET/operators/pressure.py +++ b/src/CSET/operators/pressure.py @@ -30,7 +30,7 @@ def vapour_pressure( 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.data = E0 * np.exp(exponent.core_data()) es.units = "hPa" es.rename("vapour_pressure") v_pressure.append(es) @@ -68,7 +68,7 @@ def exner_pressure( for P in iter_maybe(pressure): PI = P.copy() P = convert_units(P, "hPa") - PI.data[:] = (P.core_data() / P0) ** KAPPA + PI.data = (P.core_data() / P0) ** KAPPA PI.rename("exner_pressure") PI.units = "1" pi.append(PI) From 16fa2b998e7b8da0e600e0fa7ea391bb629b74b7 Mon Sep 17 00:00:00 2001 From: daflack Date: Wed, 7 Jan 2026 12:12:57 +0000 Subject: [PATCH 32/49] Rename vapour_pressure_from_RH to relative_humidity_to_vapour_pressure --- src/CSET/operators/pressure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CSET/operators/pressure.py b/src/CSET/operators/pressure.py index b381ae1c9..14fa734c6 100644 --- a/src/CSET/operators/pressure.py +++ b/src/CSET/operators/pressure.py @@ -40,7 +40,7 @@ def vapour_pressure( return v_pressure -def vapour_pressure_from_RH( +def relative_humidity_to_vapour_pressure( temperature: iris.cube.Cube | iris.cube.CubeList, relative_humidity: iris.cube.Cube | iris.cube.CubeList, ) -> iris.cube.Cube | iris.cube.CubeList: From 975b4261ba9f5baf9f31542b82aa6cc46e3cf91c Mon Sep 17 00:00:00 2001 From: daflack Date: Wed, 7 Jan 2026 12:25:59 +0000 Subject: [PATCH 33/49] Adds tests for relative_humidity_to_vapour_pressure --- tests/operators/test_pressure.py | 64 ++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/operators/test_pressure.py b/tests/operators/test_pressure.py index ec83bb182..cd838fc32 100644 --- a/tests/operators/test_pressure.py +++ b/tests/operators/test_pressure.py @@ -72,3 +72,67 @@ def test_vapour_pressure_cube_list(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_relative_humidity_to_vapour_pressure( + 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.relative_humidity_to_vapour_pressure( + temperature_for_conversions_cube, relative_humidity_for_conversions_cube + ).data, + rtol=1e-6, + atol=1e-2, + ) + + +def test_relative_humidity_to_vapour_pressure_name( + temperature_for_conversions_cube, relative_humidity_for_conversions_cube +): + """Test naming of vapour pressure cube.""" + expected_name = "vapour_pressure" + assert ( + expected_name + == pressure.relative_humidity_to_vapour_pressure( + temperature_for_conversions_cube, relative_humidity_for_conversions_cube + ).name() + ) + + +def test_relative_humidity_to_vapour_pressure_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.relative_humidity_to_vapour_pressure( + temperature_for_conversions_cube, relative_humidity_for_conversions_cube + ).units + ) + + +def test_relative_humidity_to_vapour_pressure_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.relative_humidity_to_vapour_pressure( + 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) From 5523a45db6ef764f39bbd7ed189a389cf7e07cb1 Mon Sep 17 00:00:00 2001 From: daflack Date: Wed, 7 Jan 2026 12:57:20 +0000 Subject: [PATCH 34/49] Adds tests for exner pressure --- tests/operators/test_pressure.py | 45 ++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/operators/test_pressure.py b/tests/operators/test_pressure.py index cd838fc32..4ac249b83 100644 --- a/tests/operators/test_pressure.py +++ b/tests/operators/test_pressure.py @@ -136,3 +136,48 @@ def test_relative_humidity_to_vapour_pressure_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_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() / 1000.0 + ) ** (1005.7 / 287.0) + 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() / 1000.0 + ) ** (1005.7 / 287.0) + 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) From f7b728ac6ef72ba2b2ebfea63bf8c34d7071d715 Mon Sep 17 00:00:00 2001 From: daflack Date: Wed, 7 Jan 2026 13:11:33 +0000 Subject: [PATCH 35/49] Uses atmospheric constants in tests --- tests/operators/test_pressure.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/operators/test_pressure.py b/tests/operators/test_pressure.py index 4ac249b83..ee194b161 100644 --- a/tests/operators/test_pressure.py +++ b/tests/operators/test_pressure.py @@ -18,7 +18,7 @@ import iris.cube import numpy as np -from CSET.operators import pressure +from CSET.operators import _atmospheric_constants, pressure def test_vapour_pressure(temperature_for_conversions_cube): @@ -29,7 +29,7 @@ def test_vapour_pressure(temperature_for_conversions_cube): * (temperature_for_conversions_cube - 273.16) / (temperature_for_conversions_cube - 35.86) ) - expected_data.data = 6.1078 * np.exp(exponent.core_data()) + 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, @@ -64,7 +64,7 @@ def test_vapour_pressure_cube_list(temperature_for_conversions_cube): * (temperature_for_conversions_cube - 273.16) / (temperature_for_conversions_cube - 35.86) ) - expected_data.data = 6.1078 * np.exp(exponent.core_data()) + 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] @@ -142,8 +142,8 @@ 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() / 1000.0 - ) ** (1005.7 / 287.0) + (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, @@ -172,8 +172,8 @@ 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() / 1000.0 - ) ** (1005.7 / 287.0) + (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] From e1a11d76220965c1614e99967fda14b9b05bee48 Mon Sep 17 00:00:00 2001 From: daflack Date: Wed, 7 Jan 2026 14:13:25 +0000 Subject: [PATCH 36/49] Change to vapour pressure from relative humidity as more intuitive --- src/CSET/operators/pressure.py | 2 +- tests/operators/test_pressure.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/CSET/operators/pressure.py b/src/CSET/operators/pressure.py index 14fa734c6..fe03c3b8c 100644 --- a/src/CSET/operators/pressure.py +++ b/src/CSET/operators/pressure.py @@ -40,7 +40,7 @@ def vapour_pressure( return v_pressure -def relative_humidity_to_vapour_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: diff --git a/tests/operators/test_pressure.py b/tests/operators/test_pressure.py index ee194b161..42e7d78ff 100644 --- a/tests/operators/test_pressure.py +++ b/tests/operators/test_pressure.py @@ -74,7 +74,7 @@ def test_vapour_pressure_cube_list(temperature_for_conversions_cube): assert np.allclose(cube_a.data, cube_b.data, rtol=1e-6, atol=1e-2) -def test_relative_humidity_to_vapour_pressure( +def test_vapour_pressure_from_relative_humidity( temperature_for_conversions_cube, relative_humidity_for_conversions_cube ): """Test calculation of vapour pressure from relative humidity.""" @@ -83,7 +83,7 @@ def test_relative_humidity_to_vapour_pressure( ) assert np.allclose( expected_data.data, - pressure.relative_humidity_to_vapour_pressure( + pressure.vapour_pressure_from_relative_humidity( temperature_for_conversions_cube, relative_humidity_for_conversions_cube ).data, rtol=1e-6, @@ -91,33 +91,33 @@ def test_relative_humidity_to_vapour_pressure( ) -def test_relative_humidity_to_vapour_pressure_name( +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.relative_humidity_to_vapour_pressure( + == pressure.vapour_pressure_from_relative_humidity( temperature_for_conversions_cube, relative_humidity_for_conversions_cube ).name() ) -def test_relative_humidity_to_vapour_pressure_units( +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.relative_humidity_to_vapour_pressure( + == pressure.vapour_pressure_from_relative_humidity( temperature_for_conversions_cube, relative_humidity_for_conversions_cube ).units ) -def test_relative_humidity_to_vapour_pressure_cubelist( +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.""" @@ -131,7 +131,7 @@ def test_relative_humidity_to_vapour_pressure_cubelist( relative_humidity_input_list = iris.cube.CubeList( [relative_humidity_for_conversions_cube, relative_humidity_for_conversions_cube] ) - actual_cubelist = pressure.relative_humidity_to_vapour_pressure( + 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): From 60e58fb0b6121058e43f3c3b89e9b83dc11bd259 Mon Sep 17 00:00:00 2001 From: daflack Date: Wed, 7 Jan 2026 14:18:47 +0000 Subject: [PATCH 37/49] Update aming convention to be x_from_y rather than y_from_x --- src/CSET/operators/humidity.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/CSET/operators/humidity.py b/src/CSET/operators/humidity.py index b68e2c3a2..a0305beaf 100644 --- a/src/CSET/operators/humidity.py +++ b/src/CSET/operators/humidity.py @@ -22,7 +22,7 @@ from CSET.operators.pressure import vapour_pressure -def specific_humidity_to_mixing_ratio( +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.""" @@ -38,7 +38,7 @@ def specific_humidity_to_mixing_ratio( return w -def mixing_ratio_to_specific_humidity( +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.""" @@ -90,7 +90,7 @@ def saturation_specific_humidity( return q -def mixing_ratio_from_RH( +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, @@ -114,7 +114,7 @@ def mixing_ratio_from_RH( return w -def specific_humidity_from_RH( +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, From 05eb6bc6305893d9cd5628cf8b18dee6596310b5 Mon Sep 17 00:00:00 2001 From: daflack Date: Wed, 7 Jan 2026 15:32:09 +0000 Subject: [PATCH 38/49] Update operators in temperature for correct conversion name --- src/CSET/operators/temperature.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/CSET/operators/temperature.py b/src/CSET/operators/temperature.py index a4eaa555f..c1221bc5b 100644 --- a/src/CSET/operators/temperature.py +++ b/src/CSET/operators/temperature.py @@ -19,7 +19,10 @@ 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_RH, saturation_mixing_ratio +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 @@ -147,7 +150,7 @@ def equivalent_potential_temperature( ): RH = convert_units(RH, "1") theta = potential_temperature(T, P) - w = mixing_ratio_from_RH(T, P, RH) + 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) From fabc9333d193a19761bfbc9b5d1c45c2053e7ab3 Mon Sep 17 00:00:00 2001 From: daflack Date: Wed, 7 Jan 2026 15:50:30 +0000 Subject: [PATCH 39/49] Adds test data and fixtures for humidity convertors --- tests/conftest.py | 28 ++++++++++++++++++ tests/test_data/humidity/mixing_ratio.nc | Bin 0 -> 20369 bytes tests/test_data/humidity/specific_humidity.nc | Bin 0 -> 20369 bytes 3 files changed, 28 insertions(+) create mode 100644 tests/test_data/humidity/mixing_ratio.nc create mode 100644 tests/test_data/humidity/specific_humidity.nc diff --git a/tests/conftest.py b/tests/conftest.py index dbc08c7c2..a04207353 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -361,3 +361,31 @@ def relative_humidity_for_conversions_cube( ): """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/test_data/humidity/mixing_ratio.nc b/tests/test_data/humidity/mixing_ratio.nc new file mode 100644 index 0000000000000000000000000000000000000000..a30b3751b8a4857c3040c02a6c752fde1e3d7187 GIT binary patch literal 20369 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 Date: Wed, 7 Jan 2026 15:57:56 +0000 Subject: [PATCH 40/49] Adds tests for specific humidity and mixing ratio conversions --- tests/operators/test_humidity.py | 160 +++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 tests/operators/test_humidity.py diff --git a/tests/operators/test_humidity.py b/tests/operators/test_humidity.py new file mode 100644 index 000000000..d208d3960 --- /dev/null +++ b/tests/operators/test_humidity.py @@ -0,0 +1,160 @@ +# © 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 humidity + + +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, + ) From 456b3e8c25db4246f6514eeaad4f956fd367152e Mon Sep 17 00:00:00 2001 From: daflack Date: Thu, 8 Jan 2026 08:54:33 +0000 Subject: [PATCH 41/49] Adds tests for saturation mixing ratio --- tests/operators/test_humidity.py | 72 +++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/tests/operators/test_humidity.py b/tests/operators/test_humidity.py index d208d3960..587cd354b 100644 --- a/tests/operators/test_humidity.py +++ b/tests/operators/test_humidity.py @@ -18,7 +18,7 @@ import iris.cube import numpy as np -from CSET.operators import humidity +from CSET.operators import _atmospheric_constants, humidity, misc, pressure def test_mixing_ratio_from_specific_humidity(specific_humidity_for_conversions_cube): @@ -158,3 +158,73 @@ def test_specific_humidity_from_mixing_ratio_using_calculated( 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) From c067c188118cf7d2a441175fc020aac7bb1b79c9 Mon Sep 17 00:00:00 2001 From: daflack Date: Thu, 8 Jan 2026 09:08:32 +0000 Subject: [PATCH 42/49] Adds tests for saturation specific humidity --- tests/operators/test_humidity.py | 66 ++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/operators/test_humidity.py b/tests/operators/test_humidity.py index 587cd354b..d2a0a6a29 100644 --- a/tests/operators/test_humidity.py +++ b/tests/operators/test_humidity.py @@ -228,3 +228,69 @@ def test_saturation_mixing_ratio_cubelist( 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) From dac721654759602bee00082c23f650358fab9619 Mon Sep 17 00:00:00 2001 From: daflack Date: Thu, 8 Jan 2026 11:53:05 +0000 Subject: [PATCH 43/49] Adds tests for mixing ratio from relative humidity --- tests/operators/test_humidity.py | 81 ++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/tests/operators/test_humidity.py b/tests/operators/test_humidity.py index d2a0a6a29..f235f4320 100644 --- a/tests/operators/test_humidity.py +++ b/tests/operators/test_humidity.py @@ -294,3 +294,84 @@ def test_saturation_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_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 ration 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) From 7de8b87dbb7c2a683c27d4bdc269c44e6370af51 Mon Sep 17 00:00:00 2001 From: daflack Date: Thu, 8 Jan 2026 12:00:53 +0000 Subject: [PATCH 44/49] Adds tests for specific humidity from relative humidity --- tests/operators/test_humidity.py | 83 +++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/tests/operators/test_humidity.py b/tests/operators/test_humidity.py index f235f4320..d7b272941 100644 --- a/tests/operators/test_humidity.py +++ b/tests/operators/test_humidity.py @@ -356,7 +356,7 @@ def test_mixing_ratio_from_relative_humidity_cubelist( pressure_for_conversions_cube, relative_humidity_for_conversions_cube, ): - """Test calculation of mixing ration from relative humidity for a CubeList.""" + """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") @@ -375,3 +375,84 @@ def test_mixing_ratio_from_relative_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_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) From 91b573ce1159099da2e4136d52be425c3e196089 Mon Sep 17 00:00:00 2001 From: daflack Date: Thu, 8 Jan 2026 13:24:53 +0000 Subject: [PATCH 45/49] Adds tests for relative humidity from mixing ratio --- tests/operators/test_humidity.py | 70 ++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/operators/test_humidity.py b/tests/operators/test_humidity.py index d7b272941..e41460ad3 100644 --- a/tests/operators/test_humidity.py +++ b/tests/operators/test_humidity.py @@ -377,6 +377,29 @@ def test_mixing_ratio_from_relative_humidity_cubelist( 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, @@ -456,3 +479,50 @@ def test_specific_humidity_from_relative_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_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 = ( + 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, + misc.convert_units(relative_humidity_for_conversions_cube, "1").data, + rtol=1e-6, + atol=1e-2, + ) From 626f7e7e57aa792412ef5172f5409df521184c34 Mon Sep 17 00:00:00 2001 From: daflack Date: Thu, 8 Jan 2026 13:55:30 +0000 Subject: [PATCH 46/49] Update tests for relative humidity from mixing ratio and correct RH units --- src/CSET/operators/humidity.py | 2 + tests/operators/test_humidity.py | 67 +++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/CSET/operators/humidity.py b/src/CSET/operators/humidity.py index a0305beaf..9ef248d9e 100644 --- a/src/CSET/operators/humidity.py +++ b/src/CSET/operators/humidity.py @@ -153,6 +153,7 @@ def relative_humidity_from_mixing_ratio( ): 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] @@ -175,6 +176,7 @@ def relative_humidity_from_specific_humidity( ): 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] diff --git a/tests/operators/test_humidity.py b/tests/operators/test_humidity.py index e41460ad3..16df29b6c 100644 --- a/tests/operators/test_humidity.py +++ b/tests/operators/test_humidity.py @@ -487,7 +487,7 @@ def test_relative_humidity_from_mixing_ratio( pressure_for_conversions_cube, ): """Test calculation of relative humidity from mixing ratio.""" - expected_data = ( + expected_data = 100.0 * ( mixing_ratio_for_conversions_cube / humidity.saturation_mixing_ratio( temperature_for_conversions_cube, pressure_for_conversions_cube @@ -522,7 +522,70 @@ def test_relative_humidity_from_mixing_ratio_calculated( ) assert np.allclose( calculated_rh.data, - misc.convert_units(relative_humidity_for_conversions_cube, "1").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) From c8790c66248a9fef7cd97b2184923decb3bd0fae Mon Sep 17 00:00:00 2001 From: daflack Date: Thu, 8 Jan 2026 14:05:28 +0000 Subject: [PATCH 47/49] Adds tests for relative humidity from specific humidity --- tests/operators/test_humidity.py | 133 +++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/tests/operators/test_humidity.py b/tests/operators/test_humidity.py index 16df29b6c..9ff94d14d 100644 --- a/tests/operators/test_humidity.py +++ b/tests/operators/test_humidity.py @@ -481,6 +481,29 @@ def test_specific_humidity_from_relative_humidity_cubelist( 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, @@ -589,3 +612,113 @@ def test_relative_humidity_from_mixing_ratio_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_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) From 0198f63eb88ddfa2b6f5f07cfe158276409810cc Mon Sep 17 00:00:00 2001 From: daflack Date: Thu, 8 Jan 2026 14:43:44 +0000 Subject: [PATCH 48/49] Adds tests and fixes calculation for dewpoint temperature --- src/CSET/operators/temperature.py | 18 +++-- tests/operators/test_temperature.py | 107 ++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 tests/operators/test_temperature.py diff --git a/src/CSET/operators/temperature.py b/src/CSET/operators/temperature.py index c1221bc5b..f432cff83 100644 --- a/src/CSET/operators/temperature.py +++ b/src/CSET/operators/temperature.py @@ -24,7 +24,10 @@ saturation_mixing_ratio, ) from CSET.operators.misc import convert_units -from CSET.operators.pressure import exner_pressure, vapour_pressure +from CSET.operators.pressure import ( + exner_pressure, + vapour_pressure_from_relative_humidity, +) def dewpoint_temperature( @@ -36,15 +39,16 @@ def dewpoint_temperature( for T, RH in zip( iter_maybe(temperature), iter_maybe(relative_humidity), strict=True ): - vp = vapour_pressure(T, RH) + vp = vapour_pressure_from_relative_humidity(T, RH) td = vp.copy() - td.data = (243.5 * np.log(vp.core_data()) - 440.8) / ( + td.data = ((243.5 * np.log(vp.core_data())) - 440.8) / ( 19.48 - np.log(vp.core_data()) ) - td.data[td.data - T0 < -35.0] = np.nan - td.data[td.data - T0 > 35.0] = np.nan - td.units = "K" - td.rename("dewpoint temperature") + 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] diff --git a/tests/operators/test_temperature.py b/tests/operators/test_temperature.py new file mode 100644 index 000000000..e12b4f8d1 --- /dev/null +++ b/tests/operators/test_temperature.py @@ -0,0 +1,107 @@ +# © 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) From 7b848987214d42cbeb2e2e1725c7f73c86cfbd09 Mon Sep 17 00:00:00 2001 From: daflack Date: Thu, 8 Jan 2026 16:47:28 +0000 Subject: [PATCH 49/49] Adds tests for virtual temperature --- tests/operators/test_temperature.py | 66 +++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/operators/test_temperature.py b/tests/operators/test_temperature.py index e12b4f8d1..670c51185 100644 --- a/tests/operators/test_temperature.py +++ b/tests/operators/test_temperature.py @@ -105,3 +105,69 @@ def test_dewpoint_temperature_cubelist( 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)