|
| 1 | +# Copyright Iris contributors |
| 2 | +# |
| 3 | +# This file is part of Iris and is released under the LGPL license. |
| 4 | +# See COPYING and COPYING.LESSER in the root of the repository for full |
| 5 | +# licensing details. |
| 6 | +""" |
| 7 | +Unit tests for the engine.activate() call within the |
| 8 | +`iris.fileformats.netcdf._load_cube` function. |
| 9 | +
|
| 10 | +Tests for rules behaviour in identifying latitude/longitude dim-coords, both |
| 11 | +rotated and non-rotated. |
| 12 | +
|
| 13 | +""" |
| 14 | +import iris.tests as tests # isort: skip |
| 15 | + |
| 16 | +from iris.coord_systems import GeogCS, RotatedGeogCS |
| 17 | +from iris.tests.unit.fileformats.nc_load_rules.actions import ( |
| 18 | + Mixin__nc_load_actions, |
| 19 | +) |
| 20 | + |
| 21 | + |
| 22 | +class Mixin_latlon_dimcoords(Mixin__nc_load_actions): |
| 23 | + # Tests for the recognition and construction of latitude/longitude coords. |
| 24 | + |
| 25 | + # Control to test either longitude or latitude coords. |
| 26 | + # Set by inheritor classes, which are actual TestCases. |
| 27 | + lat_1_or_lon_0 = None |
| 28 | + |
| 29 | + def setUp(self): |
| 30 | + super().setUp() |
| 31 | + # Generate some useful settings : just to generalise operation over |
| 32 | + # both latitude and longitude. |
| 33 | + islat = self.lat_1_or_lon_0 |
| 34 | + assert islat in (0, 1) |
| 35 | + self.unrotated_name = "latitude" if islat else "longitude" |
| 36 | + self.rotated_name = "grid_latitude" if islat else "grid_longitude" |
| 37 | + self.unrotated_units = "degrees_north" if islat else "degrees_east" |
| 38 | + # Note: there are many alternative valid forms for the rotated units, |
| 39 | + # but we are not testing that here. |
| 40 | + self.rotated_units = "degrees" # NB this one is actually constant |
| 41 | + self.axis = "y" if islat else "x" |
| 42 | + |
| 43 | + def _make_testcase_cdl( |
| 44 | + self, |
| 45 | + standard_name=None, |
| 46 | + long_name=None, |
| 47 | + var_name=None, |
| 48 | + units=None, |
| 49 | + axis=None, |
| 50 | + grid_mapping=None, |
| 51 | + ): |
| 52 | + # Inner routine called by 'run_testcase' (in Mixin__nc_load_actions), |
| 53 | + # to generate CDL which is then translated into a testfile and loaded. |
| 54 | + if var_name is None: |
| 55 | + # Can't have *no* var-name |
| 56 | + # N.B. it is also the name of the dimension. |
| 57 | + var_name = "dim" |
| 58 | + |
| 59 | + def attribute_str(name, value): |
| 60 | + if value is None or value == "": |
| 61 | + result = "" |
| 62 | + else: |
| 63 | + result = f'{var_name}:{name} = "{value}" ;' |
| 64 | + |
| 65 | + return result |
| 66 | + |
| 67 | + standard_name_str = attribute_str("standard_name", standard_name) |
| 68 | + long_name_str = attribute_str("long_name", long_name) |
| 69 | + units_str = attribute_str("units", units) |
| 70 | + axis_str = attribute_str("axis", axis) |
| 71 | + if grid_mapping: |
| 72 | + grid_mapping_str = 'phenom:grid_mapping = "crs" ;' |
| 73 | + else: |
| 74 | + grid_mapping_str = "" |
| 75 | + |
| 76 | + assert grid_mapping in (None, "latlon", "rotated") |
| 77 | + if grid_mapping is None: |
| 78 | + crs_str = "" |
| 79 | + elif grid_mapping == "latlon": |
| 80 | + crs_str = """ |
| 81 | + int crs ; |
| 82 | + crs:grid_mapping_name = "latitude_longitude" ; |
| 83 | + crs:semi_major_axis = 6371000.0 ; |
| 84 | + crs:inverse_flattening = 1000. ; |
| 85 | +""" |
| 86 | + elif grid_mapping == "rotated": |
| 87 | + crs_str = """ |
| 88 | + int crs ; |
| 89 | + crs:grid_mapping_name = "rotated_latitude_longitude" ; |
| 90 | + crs:grid_north_pole_latitude = 32.5 ; |
| 91 | + crs:grid_north_pole_longitude = 170. ; |
| 92 | +""" |
| 93 | + |
| 94 | + cdl_string = f""" |
| 95 | +netcdf test {{ |
| 96 | + dimensions: |
| 97 | + {var_name} = 2 ; |
| 98 | + variables: |
| 99 | + double {var_name}({var_name}) ; |
| 100 | + {standard_name_str} |
| 101 | + {units_str} |
| 102 | + {long_name_str} |
| 103 | + {axis_str} |
| 104 | + double phenom({var_name}) ; |
| 105 | + phenom:standard_name = "air_temperature" ; |
| 106 | + phenom:units = "K" ; |
| 107 | + {grid_mapping_str} |
| 108 | + {crs_str} |
| 109 | + data: |
| 110 | + {var_name} = 0., 1. ; |
| 111 | +}} |
| 112 | +""" |
| 113 | + return cdl_string |
| 114 | + |
| 115 | + def check_result( |
| 116 | + self, |
| 117 | + cube, |
| 118 | + standard_name, |
| 119 | + long_name, |
| 120 | + units, |
| 121 | + crs=None, |
| 122 | + context_message="", |
| 123 | + ): |
| 124 | + # Check the existence, standard-name, long-name, units and coord-system |
| 125 | + # of the resulting coord. Also that it is always a dim-coord. |
| 126 | + # NOTE: there is no "axis" arg, as this information does *not* appear |
| 127 | + # as a separate property (or attribute) of the resulting coord. |
| 128 | + # However, whether the file variable has an axis attribute *does* |
| 129 | + # affect the results here, in some cases. |
| 130 | + coords = cube.coords() |
| 131 | + # There should be one and only one coord. |
| 132 | + self.assertEqual(1, len(coords)) |
| 133 | + # It should also be a dim-coord |
| 134 | + self.assertEqual(1, len(cube.coords(dim_coords=True))) |
| 135 | + (coord,) = coords |
| 136 | + if self.debug: |
| 137 | + print("") |
| 138 | + print("DEBUG : result coord =", coord) |
| 139 | + print("") |
| 140 | + |
| 141 | + coord_stdname, coord_longname, coord_units, coord_crs = [ |
| 142 | + getattr(coord, name) |
| 143 | + for name in ("standard_name", "long_name", "units", "coord_system") |
| 144 | + ] |
| 145 | + self.assertEqual(standard_name, coord_stdname, context_message) |
| 146 | + self.assertEqual(long_name, coord_longname, context_message) |
| 147 | + self.assertEqual(units, coord_units, context_message) |
| 148 | + assert crs in (None, "latlon", "rotated") |
| 149 | + if crs is None: |
| 150 | + self.assertEqual(None, coord_crs, context_message) |
| 151 | + elif crs == "latlon": |
| 152 | + self.assertIsInstance(coord_crs, GeogCS, context_message) |
| 153 | + elif crs == "rotated": |
| 154 | + self.assertIsInstance(coord_crs, RotatedGeogCS, context_message) |
| 155 | + |
| 156 | + # |
| 157 | + # Testcase routines |
| 158 | + # |
| 159 | + # NOTE: all these testcases have been verified against the older behaviour |
| 160 | + # in v3.0.4, based on Pyke rules. |
| 161 | + # |
| 162 | + |
| 163 | + def test_minimal(self): |
| 164 | + # Nothing but a var-name --> unrecognised dim-coord. |
| 165 | + result = self.run_testcase() |
| 166 | + self.check_result(result, None, None, "unknown") |
| 167 | + |
| 168 | + def test_fullinfo_unrotated(self): |
| 169 | + # Check behaviour with all normal info elements for 'unrotated' case. |
| 170 | + # Includes a grid-mapping, but no axis (should not be needed). |
| 171 | + result = self.run_testcase( |
| 172 | + standard_name=self.unrotated_name, |
| 173 | + units=self.unrotated_units, |
| 174 | + grid_mapping="latlon", |
| 175 | + ) |
| 176 | + self.check_result( |
| 177 | + result, self.unrotated_name, None, "degrees", "latlon" |
| 178 | + ) |
| 179 | + |
| 180 | + def test_fullinfo_rotated(self): |
| 181 | + # Check behaviour with all normal info elements for 'rotated' case. |
| 182 | + # Includes a grid-mapping, but no axis (should not be needed). |
| 183 | + result = self.run_testcase( |
| 184 | + standard_name=self.rotated_name, |
| 185 | + units=self.rotated_units, |
| 186 | + grid_mapping="rotated", |
| 187 | + ) |
| 188 | + self.check_result( |
| 189 | + result, self.rotated_name, None, "degrees", "rotated" |
| 190 | + ) |
| 191 | + |
| 192 | + def test_axis(self): |
| 193 | + # A suitable axis --> unrotated lat/lon coord, but unknown units. |
| 194 | + result = self.run_testcase(axis=self.axis) |
| 195 | + self.check_result(result, self.unrotated_name, None, "unknown") |
| 196 | + |
| 197 | + def test_units_unrotated(self): |
| 198 | + # With a unit like 'degrees_east', we automatically identify this as a |
| 199 | + # latlon coord, *and* convert units to plain 'degrees' on loading. |
| 200 | + result = self.run_testcase(units=self.unrotated_units) |
| 201 | + self.check_result(result, self.unrotated_name, None, "degrees") |
| 202 | + |
| 203 | + def test_units_rotated(self): |
| 204 | + # With no info except a "degrees" unit, we **don't** identify a latlon, |
| 205 | + # i.e. we do not set the standard-name |
| 206 | + result = self.run_testcase(units="degrees") |
| 207 | + self.check_result(result, None, None, "degrees") |
| 208 | + |
| 209 | + def test_units_unrotated_gridmapping(self): |
| 210 | + # With an unrotated unit *AND* a suitable grid-mapping, we identify a |
| 211 | + # rotated latlon coordinate + assign it the coord-system. |
| 212 | + result = self.run_testcase( |
| 213 | + units=self.unrotated_units, grid_mapping="latlon" |
| 214 | + ) |
| 215 | + self.check_result( |
| 216 | + result, self.unrotated_name, None, "degrees", "latlon" |
| 217 | + ) |
| 218 | + |
| 219 | + def test_units_rotated_gridmapping_noname(self): |
| 220 | + # Rotated units and grid-mapping, but *without* the expected name. |
| 221 | + # Does not translate, no coord-system (i.e. grid-mapping is discarded). |
| 222 | + result = self.run_testcase( |
| 223 | + units="degrees", |
| 224 | + grid_mapping="rotated", |
| 225 | + ) |
| 226 | + self.check_result(result, None, None, "degrees", None) |
| 227 | + |
| 228 | + def test_units_rotated_gridmapping_withname(self): |
| 229 | + # With a "degrees" unit, a rotated grid-mapping *AND* a suitable |
| 230 | + # standard-name, it recognises a rotated dimcoord. |
| 231 | + result = self.run_testcase( |
| 232 | + standard_name=self.rotated_name, |
| 233 | + units="degrees", |
| 234 | + grid_mapping="rotated", |
| 235 | + ) |
| 236 | + self.check_result( |
| 237 | + result, self.rotated_name, None, "degrees", "rotated" |
| 238 | + ) |
| 239 | + |
| 240 | + def test_units_rotated_gridmapping_varname(self): |
| 241 | + # Same but with var-name containing the standard-name : in this case we |
| 242 | + # get NO COORDINATE-SYSTEM (which is a bit weird). |
| 243 | + result = self.run_testcase( |
| 244 | + var_name=self.rotated_name, |
| 245 | + units="degrees", |
| 246 | + grid_mapping="rotated", |
| 247 | + ) |
| 248 | + self.check_result(result, self.rotated_name, None, "degrees", None) |
| 249 | + |
| 250 | + def test_varname_unrotated(self): |
| 251 | + # With a recognised name in the var-name, we set standard-name. |
| 252 | + # But units are left undetermined. |
| 253 | + result = self.run_testcase(var_name=self.unrotated_name) |
| 254 | + self.check_result(result, self.unrotated_name, None, "unknown") |
| 255 | + |
| 256 | + def test_varname_rotated(self): |
| 257 | + # With a *rotated* name in the var-name, we set standard-name. |
| 258 | + # But units are left undetermined. |
| 259 | + result = self.run_testcase(var_name=self.rotated_name) |
| 260 | + self.check_result(result, self.rotated_name, None, "unknown") |
| 261 | + |
| 262 | + def test_varname_unrotated_units_rotated(self): |
| 263 | + # With a "degrees" unit and a suitable var-name, we do identify |
| 264 | + # (= set standard-name). |
| 265 | + # N.B. this accepts "degrees" as a generic term, and so does *not* |
| 266 | + # interpret it as a rotated coordinate. |
| 267 | + result = self.run_testcase( |
| 268 | + var_name=self.unrotated_name, units="degrees" |
| 269 | + ) |
| 270 | + self.check_result(result, self.unrotated_name, None, "degrees") |
| 271 | + |
| 272 | + def test_longname(self): |
| 273 | + # A recognised form in long-name is *not* translated into standard-name. |
| 274 | + result = self.run_testcase(long_name=self.unrotated_name) |
| 275 | + self.check_result(result, None, self.unrotated_name, "unknown") |
| 276 | + |
| 277 | + def test_stdname_unrotated(self): |
| 278 | + # Only an (unrotated) standard name : units is not specified |
| 279 | + result = self.run_testcase(standard_name=self.unrotated_name) |
| 280 | + self.check_result(result, self.unrotated_name, None, None) |
| 281 | + |
| 282 | + def test_stdname_rotated(self): |
| 283 | + # Only a (rotated) standard name : units is not specified |
| 284 | + result = self.run_testcase(standard_name=self.rotated_name) |
| 285 | + self.check_result(result, self.rotated_name, None, None) |
| 286 | + |
| 287 | + def test_stdname_unrotated_gridmapping(self): |
| 288 | + # An unrotated standard-name and grid-mapping, translates into a |
| 289 | + # coordinate system. |
| 290 | + result = self.run_testcase( |
| 291 | + standard_name=self.unrotated_name, grid_mapping="latlon" |
| 292 | + ) |
| 293 | + self.check_result( |
| 294 | + result, self.unrotated_name, None, "unknown", "latlon" |
| 295 | + ) |
| 296 | + |
| 297 | + def test_stdname_rotated_gridmapping(self): |
| 298 | + # An *rotated* standard-name and grid-mapping, translates into a |
| 299 | + # coordinate system. |
| 300 | + result = self.run_testcase( |
| 301 | + standard_name=self.rotated_name, grid_mapping="rotated" |
| 302 | + ) |
| 303 | + self.check_result(result, self.rotated_name, None, None, "rotated") |
| 304 | + |
| 305 | + |
| 306 | +class Test__longitude_coords(Mixin_latlon_dimcoords, tests.IrisTest): |
| 307 | + lat_1_or_lon_0 = 0 |
| 308 | + |
| 309 | + @classmethod |
| 310 | + def setUpClass(cls): |
| 311 | + super().setUpClass() |
| 312 | + |
| 313 | + @classmethod |
| 314 | + def tearDownClass(cls): |
| 315 | + super().tearDownClass() |
| 316 | + |
| 317 | + def setUp(self): |
| 318 | + super().setUp() |
| 319 | + |
| 320 | + |
| 321 | +class Test__latitude_coords(Mixin_latlon_dimcoords, tests.IrisTest): |
| 322 | + lat_1_or_lon_0 = 1 |
| 323 | + |
| 324 | + @classmethod |
| 325 | + def setUpClass(cls): |
| 326 | + super().setUpClass() |
| 327 | + |
| 328 | + @classmethod |
| 329 | + def tearDownClass(cls): |
| 330 | + super().tearDownClass() |
| 331 | + |
| 332 | + def setUp(self): |
| 333 | + super().setUp() |
| 334 | + |
| 335 | + |
| 336 | +if __name__ == "__main__": |
| 337 | + tests.main() |
0 commit comments