Skip to content

Commit 76b580d

Browse files
authored
Nc load latlon fix (#4470)
* Tests, and partial fix, for latlon identification problems. * Updated to align with tests developed against Iris 3.0.4, from the branch 'nc_load_latlon_further_RETRO'.
1 parent f4a9e93 commit 76b580d

File tree

3 files changed

+349
-12
lines changed

3 files changed

+349
-12
lines changed

lib/iris/fileformats/_nc_load_rules/actions.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -184,16 +184,19 @@ def action_provides_coordinate(engine, dimcoord_fact):
184184

185185
# Identify the "type" of a coordinate variable
186186
coord_type = None
187-
# NOTE: must test for rotated cases *first*, as 'is_longitude' and
188-
# 'is_latitude' functions also accept rotated cases.
189-
if hh.is_rotated_latitude(engine, var_name):
190-
coord_type = "rotated_latitude"
191-
elif hh.is_rotated_longitude(engine, var_name):
192-
coord_type = "rotated_longitude"
193-
elif hh.is_latitude(engine, var_name):
194-
coord_type = "latitude"
187+
188+
if hh.is_latitude(engine, var_name):
189+
# N.B. result of 'is_rotated_lat/lon' checks are valid ONLY when the
190+
# relevant 'is_lat/lon' is also True.
191+
if hh.is_rotated_latitude(engine, var_name):
192+
coord_type = "rotated_latitude"
193+
else:
194+
coord_type = "latitude"
195195
elif hh.is_longitude(engine, var_name):
196-
coord_type = "longitude"
196+
if hh.is_rotated_longitude(engine, var_name):
197+
coord_type = "rotated_longitude"
198+
else:
199+
coord_type = "longitude"
197200
elif hh.is_time(engine, var_name):
198201
coord_type = "time"
199202
elif hh.is_time_period(engine, var_name):

lib/iris/tests/unit/fileformats/nc_load_rules/actions/__init__.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,6 @@
66
"""
77
Unit tests for the module :mod:`iris.fileformats._nc_load_rules.actions`.
88
9-
This module provides the engine.activate() call used in the function
10-
`iris.fileformats.netcdf._load_cube`.
11-
129
"""
1310
from pathlib import Path
1411
import shutil
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
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

Comments
 (0)