Repository: SciTools/iris (python)

Issue:
convert_units does not convert valid_min/valid_max
## 🐛 Bug Report
<!-- Provide a clear description of what the bug is -->

`iris.cube.Cube.convert_units` does not convert the valid_min/valid_max attributes. This can lead to the entire cube becoming masked if it is saved and loaded again because the data values are outside the valid range.

## How To Reproduce
Steps to reproduce the behaviour:

```python
In [1]: import iris

In [2]: import iris.cube

In [3]: iris.FUTURE.save_split_attrs = True

In [4]: cube = iris.cube.Cube([263], standard_name='air_temperature', units='K')

In [5]: cube.attributes.locals["valid_min"] = 0

In [6]: cube.convert_units("degrees_C")

In [7]: cube.data
Out[7]: array([-10.15])

In [8]: cube.attributes.locals["valid_min"]
Out[8]: 0

In [9]: iris.save(cube, target='tmp.nc')

In [10]: result = iris.load_cube("tmp.nc")

In [11]: result.data
Out[11]: 
masked_array(data=[--],
             mask=[ True],
       fill_value=9.969209968386869e+36,
            dtype=float64)

In [12]: result.attributes.locals["valid_min"]
Out[12]: np.float64(0.0)
```

## Expected behaviour
<!-- A clear and concise description of what you expected to happen -->

I would expect convert_units to also convert the valid_min and valid_max attributes.

## Environment 
 - OS & Version: [e.g., Ubuntu 20.04 LTS]
 - Iris Version: 3.11

## Additional context
<!-- Provide any further information to help us understand -->
<details>
<summary>Click to expand <b>this section...</b></summary>

```
Please add additional verbose information in this section e.g., code, output, tracebacks, screenshots etc
```
</details>



Pull request:
Also convert attribute units when calling `convert_units`
## 🚀 Pull Request

### Description
<!-- Provide a clear description about your awesome pull request -->
<!-- Tell us all about your new feature, improvement, or bug fix -->

Also convert the [attributes](https://cfconventions.org/cf-conventions/cf-conventions.html#attribute-appendix) "actual_range", "valid_max", "valid_min", and "valid_range" on cubes and coordinates.

Closes #6411

---
[Consult Iris pull request check list]( https://scitools-iris.readthedocs.io/en/latest/developers_guide/contributing_pull_request_checklist.html)

---
Add any of the below labels to trigger actions on this PR:

- https://github.com/SciTools/iris/labels/benchmark_this


Patch:
diff --git a/lib/iris/coords.py b/lib/iris/coords.py
index ca73dcb72..9fdd20d6f 100644
--- a/lib/iris/coords.py
+++ b/lib/iris/coords.py
@@ -747,6 +747,9 @@ class _DimensionalMetadata(CFVariableMixin, metaclass=ABCMeta):
             else:
                 new_bounds = self.units.convert(self.bounds, unit)
             self.bounds = new_bounds
+        for key in "actual_range", "valid_max", "valid_min", "valid_range":
+            if key in self.attributes:
+                self.attributes[key] = self.units.convert(self.attributes[key], unit)
         self.units = unit
 
     def is_compatible(self, other, ignore=None):
diff --git a/lib/iris/cube.py b/lib/iris/cube.py
index 77191c3a9..be8ddcfc4 100644
--- a/lib/iris/cube.py
+++ b/lib/iris/cube.py
@@ -1478,6 +1478,11 @@ class Cube(CFVariableMixin):
         else:
             new_data = self.units.convert(self.data, unit)
         self.data = new_data
+        for key in "actual_range", "valid_max", "valid_min", "valid_range":
+            if key in self.attributes.locals:
+                self.attributes.locals[key] = self.units.convert(
+                    self.attributes.locals[key], unit
+                )
         self.units = unit
 
     def add_cell_method(self, cell_method: CellMethod) -> None:


Test patch:
diff --git a/docs/src/whatsnew/latest.rst b/docs/src/whatsnew/latest.rst
index 00131411c..b90039e96 100644
--- a/docs/src/whatsnew/latest.rst
+++ b/docs/src/whatsnew/latest.rst
@@ -40,13 +40,15 @@ This document explains the changes made to Iris for this release
 #. `@HGWright`_ added a new warning to inform users that the boolean coordinate generated by
    :meth:`iris.coord_categorisation.add_season_membership` is not saveable to netcdf. (:pull:`6305`)
 
+#. `@bouweandela`_ changed the ``convert_units`` method on cubes and coordinates
+   so it also converts the values of the attributes ``"actual_range"``,
+   ``"valid_max"``, ``"valid_min"``, and ``"valid_range"``. (:pull:`6416`)
 
 💣 Incompatible Changes
 =======================
 
 #. N/A
 
-
 🚀 Performance Enhancements
 ===========================
 
diff --git a/lib/iris/tests/unit/coords/test_Coord.py b/lib/iris/tests/unit/coords/test_Coord.py
index 97429f58f..ba77379a5 100644
--- a/lib/iris/tests/unit/coords/test_Coord.py
+++ b/lib/iris/tests/unit/coords/test_Coord.py
@@ -23,6 +23,7 @@ import iris
 from iris.coords import AuxCoord, Coord, DimCoord
 from iris.cube import Cube
 from iris.exceptions import UnitConversionError
+from iris.tests import _shared_utils
 from iris.tests.unit.coords import CoordTestMixin
 from iris.warnings import IrisVagueMetadataWarning
 
@@ -1177,16 +1178,35 @@ class Test__sanity_check_bounds(tests.IrisTest):
             coord._sanity_check_bounds()
 
 
-class Test_convert_units(tests.IrisTest):
+class Test_convert_units:
     def test_convert_unknown_units(self):
         coord = iris.coords.AuxCoord(1, units="unknown")
         emsg = (
             "Cannot convert from unknown units. "
             'The "units" attribute may be set directly.'
         )
-        with self.assertRaisesRegex(UnitConversionError, emsg):
+        with pytest.raises(UnitConversionError, match=emsg):
             coord.convert_units("degrees")
 
+    @pytest.mark.parametrize(
+        "attribute",
+        [
+            "valid_min",
+            "valid_max",
+            "valid_range",
+            "actual_range",
+        ],
+    )
+    def test_convert_attributes(self, attribute):
+        coord = iris.coords.AuxCoord(1, units="m")
+        value = np.array([0, 10]) if attribute.endswith("range") else 0
+        coord.attributes[attribute] = value
+        coord.convert_units("ft")
+        converted_value = cf_units.Unit("m").convert(value, "ft")
+        _shared_utils.assert_array_all_close(
+            coord.attributes[attribute], converted_value
+        )
+
 
 class Test___str__(tests.IrisTest):
     def test_short_time_interval(self):
diff --git a/lib/iris/tests/unit/cube/test_Cube.py b/lib/iris/tests/unit/cube/test_Cube.py
index 21fa27d2f..0652ab9d5 100644
--- a/lib/iris/tests/unit/cube/test_Cube.py
+++ b/lib/iris/tests/unit/cube/test_Cube.py
@@ -3024,6 +3024,25 @@ class Test_convert_units:
         cube.data
         _client.close()
 
+    @pytest.mark.parametrize(
+        "attribute",
+        [
+            "valid_min",
+            "valid_max",
+            "valid_range",
+            "actual_range",
+        ],
+    )
+    def test_convert_attributes(self, attribute):
+        cube = iris.cube.Cube(1, units="degrees_C")
+        value = np.array([0, 10]) if attribute.endswith("range") else 0
+        cube.attributes.locals[attribute] = value
+        cube.convert_units("K")
+        converted_value = Unit("degrees_C").convert(value, "K")
+        _shared_utils.assert_array_all_close(
+            cube.attributes.locals[attribute], converted_value
+        )
+
 
 class Test__eq__data:
     """Partial cube equality testing, for data type only."""