|
| 1 | +# Copyright iris-grib contributors |
| 2 | +# |
| 3 | +# This file is part of iris-grib 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 | +Tests for specific implementation aspects of the grib loaders. |
| 8 | +Old, and GRIB-1 specific. |
| 9 | +Ported here from 'iris.tests.test_grib_load_translations'. |
| 10 | +
|
| 11 | +""" |
| 12 | + |
| 13 | +# Import iris_grib.tests first so that some things can be initialised before |
| 14 | +# importing anything else |
| 15 | +import iris_grib.tests as tests |
| 16 | + |
| 17 | +import datetime |
| 18 | +from unittest import mock |
| 19 | + |
| 20 | +import cf_units |
| 21 | +import numpy as np |
| 22 | + |
| 23 | +import iris |
| 24 | +import iris.exceptions |
| 25 | + |
| 26 | +import gribapi |
| 27 | +import iris.fileformats |
| 28 | +import iris_grib |
| 29 | + |
| 30 | + |
| 31 | +def _mock_gribapi_fetch(message, key): |
| 32 | + """ |
| 33 | + Fake the gribapi key-fetch. |
| 34 | +
|
| 35 | + Fetch key-value from the fake message (dictionary). |
| 36 | + If the key is not present, raise the diagnostic exception. |
| 37 | +
|
| 38 | + """ |
| 39 | + if key in message: |
| 40 | + return message[key] |
| 41 | + else: |
| 42 | + raise _mock_gribapi.errors.GribInternalError |
| 43 | + |
| 44 | + |
| 45 | +def _mock_gribapi__grib_is_missing(grib_message, keyname): |
| 46 | + """ |
| 47 | + Fake the gribapi key-existence enquiry. |
| 48 | +
|
| 49 | + Return whether the key exists in the fake message (dictionary). |
| 50 | +
|
| 51 | + """ |
| 52 | + return keyname not in grib_message |
| 53 | + |
| 54 | + |
| 55 | +def _mock_gribapi__grib_get_native_type(grib_message, keyname): |
| 56 | + """ |
| 57 | + Fake the gribapi type-discovery operation. |
| 58 | +
|
| 59 | + Return type of key-value in the fake message (dictionary). |
| 60 | + If the key is not present, raise the diagnostic exception. |
| 61 | +
|
| 62 | + """ |
| 63 | + if keyname in grib_message: |
| 64 | + return type(grib_message[keyname]) |
| 65 | + raise _mock_gribapi.errors.GribInternalError(keyname) |
| 66 | + |
| 67 | + |
| 68 | +# Construct a mock object to mimic the gribapi for GribWrapper testing. |
| 69 | +_mock_gribapi = mock.Mock(spec=gribapi) |
| 70 | +_mock_gribapi.errors.GribInternalError = Exception |
| 71 | + |
| 72 | +_mock_gribapi.grib_get_long = mock.Mock(side_effect=_mock_gribapi_fetch) |
| 73 | +_mock_gribapi.grib_get_string = mock.Mock(side_effect=_mock_gribapi_fetch) |
| 74 | +_mock_gribapi.grib_get_double = mock.Mock(side_effect=_mock_gribapi_fetch) |
| 75 | +_mock_gribapi.grib_get_double_array = mock.Mock( |
| 76 | + side_effect=_mock_gribapi_fetch |
| 77 | +) |
| 78 | +_mock_gribapi.grib_is_missing = mock.Mock( |
| 79 | + side_effect=_mock_gribapi__grib_is_missing |
| 80 | +) |
| 81 | +_mock_gribapi.grib_get_native_type = mock.Mock( |
| 82 | + side_effect=_mock_gribapi__grib_get_native_type |
| 83 | +) |
| 84 | + |
| 85 | +# define seconds in an hour, for general test usage |
| 86 | +_hour_secs = 3600.0 |
| 87 | + |
| 88 | + |
| 89 | +class FakeGribMessage(dict): |
| 90 | + """ |
| 91 | + A 'fake grib message' object, for testing GribWrapper construction. |
| 92 | +
|
| 93 | + Behaves as a dictionary, containing key-values for message keys. |
| 94 | +
|
| 95 | + """ |
| 96 | + |
| 97 | + def __init__(self, **kwargs): |
| 98 | + """ |
| 99 | + Create a fake message object. |
| 100 | +
|
| 101 | + General keys can be set/add as required via **kwargs. |
| 102 | + The keys 'edition' and 'time_code' are specially managed. |
| 103 | +
|
| 104 | + """ |
| 105 | + # Start with a bare dictionary |
| 106 | + dict.__init__(self) |
| 107 | + # Extract specially-recognised keys. |
| 108 | + edition = kwargs.pop("edition", 1) |
| 109 | + # This testing is only for old-style Grib-1 code. |
| 110 | + assert edition == 1 |
| 111 | + time_code = kwargs.pop("time_code", None) |
| 112 | + # Set the minimally required keys. |
| 113 | + self._init_minimal_message(edition=edition) |
| 114 | + # Also set a time-code, if given. |
| 115 | + if time_code is not None: |
| 116 | + self.set_timeunit_code(time_code) |
| 117 | + # Finally, add any remaining passed key-values. |
| 118 | + self.update(**kwargs) |
| 119 | + |
| 120 | + def _init_minimal_message(self, edition=1): |
| 121 | + # Set values for all the required keys. |
| 122 | + # 'edition' controls the edition-specific keys. |
| 123 | + self.update( |
| 124 | + { |
| 125 | + "Ni": 1, |
| 126 | + "Nj": 1, |
| 127 | + "numberOfValues": 1, |
| 128 | + "alternativeRowScanning": 0, |
| 129 | + "centre": "ecmf", |
| 130 | + "year": 2007, |
| 131 | + "month": 3, |
| 132 | + "day": 23, |
| 133 | + "hour": 12, |
| 134 | + "minute": 0, |
| 135 | + "indicatorOfUnitOfTimeRange": 1, |
| 136 | + "shapeOfTheEarth": 6, |
| 137 | + "gridType": "rotated_ll", |
| 138 | + "angleOfRotation": 0.0, |
| 139 | + "iDirectionIncrementInDegrees": 0.036, |
| 140 | + "jDirectionIncrementInDegrees": 0.036, |
| 141 | + "iScansNegatively": 0, |
| 142 | + "jScansPositively": 1, |
| 143 | + "longitudeOfFirstGridPointInDegrees": -5.70, |
| 144 | + "latitudeOfFirstGridPointInDegrees": -4.452, |
| 145 | + "jPointsAreConsecutive": 0, |
| 146 | + "values": np.array([[1.0]]), |
| 147 | + "indicatorOfParameter": 9999, |
| 148 | + "parameterNumber": 9999, |
| 149 | + "startStep": 24, |
| 150 | + "timeRangeIndicator": 1, |
| 151 | + "P1": 2, |
| 152 | + "P2": 0, |
| 153 | + # time unit - needed AS WELL as 'indicatorOfUnitOfTimeRange' |
| 154 | + "unitOfTime": 1, |
| 155 | + "table2Version": 9999, |
| 156 | + } |
| 157 | + ) |
| 158 | + # Add edition-dependent settings. |
| 159 | + self["edition"] = edition |
| 160 | + |
| 161 | + def set_timeunit_code(self, timecode): |
| 162 | + # Do timecode setting (somewhat edition-dependent). |
| 163 | + self["indicatorOfUnitOfTimeRange"] = timecode |
| 164 | + # for some odd reason, GRIB1 code uses *both* of these |
| 165 | + # NOTE kludge -- the 2 keys are really the same thing |
| 166 | + self["unitOfTime"] = timecode |
| 167 | + |
| 168 | + |
| 169 | +class TestGribTimecodes(tests.IrisTest): |
| 170 | + def _run_timetests(self, test_set): |
| 171 | + # Check the unit-handling for given units-codes and editions. |
| 172 | + |
| 173 | + # Operates on lists of cases for various time-units and grib-editions. |
| 174 | + # Format: (edition, code, expected-exception, |
| 175 | + # equivalent-seconds, description-string) |
| 176 | + with mock.patch("iris_grib.gribapi", _mock_gribapi): |
| 177 | + for test_controls in test_set: |
| 178 | + ( |
| 179 | + grib_edition, |
| 180 | + timeunit_codenum, |
| 181 | + expected_error, |
| 182 | + timeunit_secs, |
| 183 | + timeunit_str, |
| 184 | + ) = test_controls |
| 185 | + |
| 186 | + # Construct a suitable fake test message. |
| 187 | + message = FakeGribMessage( |
| 188 | + edition=grib_edition, time_code=timeunit_codenum |
| 189 | + ) |
| 190 | + |
| 191 | + if expected_error: |
| 192 | + # Expect GribWrapper construction to fail. |
| 193 | + with self.assertRaises(type(expected_error)) as ar_context: |
| 194 | + _ = iris_grib.GribWrapper(message) |
| 195 | + self.assertEqual( |
| 196 | + ar_context.exception.args, expected_error.args |
| 197 | + ) |
| 198 | + continue |
| 199 | + |
| 200 | + # 'ELSE'... |
| 201 | + # Expect the wrapper construction to work. |
| 202 | + # Make a GribWrapper object and test it. |
| 203 | + wrapped_msg = iris_grib.GribWrapper(message) |
| 204 | + |
| 205 | + # Check the units string. |
| 206 | + forecast_timeunit = wrapped_msg._forecastTimeUnit |
| 207 | + self.assertEqual( |
| 208 | + forecast_timeunit, |
| 209 | + timeunit_str, |
| 210 | + "Bad unit string for edition={ed:01d}, " |
| 211 | + "unitcode={code:01d} : " |
| 212 | + 'expected="{wanted}" GOT="{got}"'.format( |
| 213 | + ed=grib_edition, |
| 214 | + code=timeunit_codenum, |
| 215 | + wanted=timeunit_str, |
| 216 | + got=forecast_timeunit, |
| 217 | + ), |
| 218 | + ) |
| 219 | + |
| 220 | + # Check the data-starttime calculation. |
| 221 | + interval_start_to_end = ( |
| 222 | + wrapped_msg._phenomenonDateTime - |
| 223 | + wrapped_msg._referenceDateTime |
| 224 | + ) |
| 225 | + if grib_edition == 1: |
| 226 | + interval_from_units = wrapped_msg.P1 |
| 227 | + else: |
| 228 | + interval_from_units = wrapped_msg.forecastTime |
| 229 | + interval_from_units *= datetime.timedelta(0, timeunit_secs) |
| 230 | + self.assertEqual( |
| 231 | + interval_start_to_end, |
| 232 | + interval_from_units, |
| 233 | + "Inconsistent start time offset for edition={ed:01d}, " |
| 234 | + "unitcode={code:01d} : " |
| 235 | + 'from-unit="{unit_str}" ' |
| 236 | + 'from-phenom-minus-ref="{e2e_str}"'.format( |
| 237 | + ed=grib_edition, |
| 238 | + code=timeunit_codenum, |
| 239 | + unit_str=interval_from_units, |
| 240 | + e2e_str=interval_start_to_end, |
| 241 | + ), |
| 242 | + ) |
| 243 | + |
| 244 | + # Test groups of testcases for various time-units and grib-editions. |
| 245 | + # Format: (edition, code, expected-exception, |
| 246 | + # equivalent-seconds, description-string) |
| 247 | + def test_timeunits_common(self): |
| 248 | + tests = ( |
| 249 | + (1, 0, None, 60.0, "minutes"), |
| 250 | + (1, 1, None, _hour_secs, "hours"), |
| 251 | + (1, 2, None, 24.0 * _hour_secs, "days"), |
| 252 | + (1, 10, None, 3.0 * _hour_secs, "3 hours"), |
| 253 | + (1, 11, None, 6.0 * _hour_secs, "6 hours"), |
| 254 | + (1, 12, None, 12.0 * _hour_secs, "12 hours"), |
| 255 | + ) |
| 256 | + TestGribTimecodes._run_timetests(self, tests) |
| 257 | + |
| 258 | + @staticmethod |
| 259 | + def _err_bad_timeunit(code): |
| 260 | + return iris.exceptions.NotYetImplementedError( |
| 261 | + "Unhandled time unit for forecast " |
| 262 | + "indicatorOfUnitOfTimeRange : {code}".format(code=code) |
| 263 | + ) |
| 264 | + |
| 265 | + def test_timeunits_grib1_specific(self): |
| 266 | + tests = ( |
| 267 | + (1, 13, None, 0.25 * _hour_secs, "15 minutes"), |
| 268 | + (1, 14, None, 0.5 * _hour_secs, "30 minutes"), |
| 269 | + (1, 254, None, 1.0, "seconds"), |
| 270 | + (1, 111, TestGribTimecodes._err_bad_timeunit(111), 1.0, "??"), |
| 271 | + ) |
| 272 | + TestGribTimecodes._run_timetests(self, tests) |
| 273 | + |
| 274 | + def test_timeunits_calendar(self): |
| 275 | + tests = ( |
| 276 | + (1, 3, TestGribTimecodes._err_bad_timeunit(3), 0.0, "months"), |
| 277 | + (1, 4, TestGribTimecodes._err_bad_timeunit(4), 0.0, "years"), |
| 278 | + (1, 5, TestGribTimecodes._err_bad_timeunit(5), 0.0, "decades"), |
| 279 | + (1, 6, TestGribTimecodes._err_bad_timeunit(6), 0.0, "30 years"), |
| 280 | + (1, 7, TestGribTimecodes._err_bad_timeunit(7), 0.0, "centuries"), |
| 281 | + ) |
| 282 | + TestGribTimecodes._run_timetests(self, tests) |
| 283 | + |
| 284 | + def test_timeunits_invalid(self): |
| 285 | + tests = ( |
| 286 | + (1, 111, TestGribTimecodes._err_bad_timeunit(111), 1.0, "??"), |
| 287 | + ) |
| 288 | + TestGribTimecodes._run_timetests(self, tests) |
| 289 | + |
| 290 | + def test_warn_unknown_pdts(self): |
| 291 | + # Test loading of an unrecognised GRIB Product Definition Template. |
| 292 | + |
| 293 | + # Get a temporary file by name (deleted afterward by context). |
| 294 | + with self.temp_filename() as temp_gribfile_path: |
| 295 | + # Write a test grib message to the temporary file. |
| 296 | + with open(temp_gribfile_path, "wb") as temp_gribfile: |
| 297 | + grib_message = gribapi.grib_new_from_samples("GRIB2") |
| 298 | + # Set the PDT to something unexpected. |
| 299 | + gribapi.grib_set_long( |
| 300 | + grib_message, "productDefinitionTemplateNumber", 5 |
| 301 | + ) |
| 302 | + gribapi.grib_write(grib_message, temp_gribfile) |
| 303 | + |
| 304 | + # Load the message from the file as a cube. |
| 305 | + cube_generator = iris_grib.load_cubes(temp_gribfile_path) |
| 306 | + with self.assertRaises(iris.exceptions.TranslationError) as te: |
| 307 | + _ = next(cube_generator) |
| 308 | + self.assertEqual( |
| 309 | + "Product definition template [5]" " is not supported", |
| 310 | + str(te.exception), |
| 311 | + ) |
| 312 | + |
| 313 | + |
| 314 | +class TestGrib1LoadPhenomenon(tests.IrisTest): |
| 315 | + # Test recognition of grib phenomenon types. |
| 316 | + def mock_grib(self): |
| 317 | + grib = mock.Mock() |
| 318 | + grib.edition = 1 |
| 319 | + grib.startStep = 0 |
| 320 | + grib.phenomenon_points = lambda unit: 3 |
| 321 | + grib._forecastTimeUnit = "hours" |
| 322 | + grib.productDefinitionTemplateNumber = 0 |
| 323 | + # define a level type (NB these 2 are effectively the same) |
| 324 | + grib.levelType = 1 |
| 325 | + grib.typeOfFirstFixedSurface = 1 |
| 326 | + grib.typeOfSecondFixedSurface = 1 |
| 327 | + return grib |
| 328 | + |
| 329 | + def cube_from_message(self, grib): |
| 330 | + # Parameter translation now uses the GribWrapper, so we must convert |
| 331 | + # the Mock-based fake message to a FakeGribMessage. |
| 332 | + with mock.patch("iris_grib.gribapi", _mock_gribapi): |
| 333 | + grib_message = FakeGribMessage(**grib.__dict__) |
| 334 | + wrapped_msg = iris_grib.GribWrapper(grib_message) |
| 335 | + cube, _, _ = iris.fileformats.rules._make_cube( |
| 336 | + wrapped_msg, iris_grib._grib1_load_rules.grib1_convert |
| 337 | + ) |
| 338 | + return cube |
| 339 | + |
| 340 | + def test_grib1_unknownparam(self): |
| 341 | + grib = self.mock_grib() |
| 342 | + grib.table2Version = 0 |
| 343 | + grib.indicatorOfParameter = 9999 |
| 344 | + cube = self.cube_from_message(grib) |
| 345 | + self.assertEqual(cube.standard_name, None) |
| 346 | + self.assertEqual(cube.long_name, None) |
| 347 | + self.assertEqual(cube.units, cf_units.Unit("???")) |
| 348 | + |
| 349 | + def test_grib1_unknown_local_param(self): |
| 350 | + grib = self.mock_grib() |
| 351 | + grib.table2Version = 128 |
| 352 | + grib.indicatorOfParameter = 999 |
| 353 | + cube = self.cube_from_message(grib) |
| 354 | + self.assertEqual(cube.standard_name, None) |
| 355 | + self.assertEqual(cube.long_name, "UNKNOWN LOCAL PARAM 999.128") |
| 356 | + self.assertEqual(cube.units, cf_units.Unit("???")) |
| 357 | + |
| 358 | + def test_grib1_unknown_standard_param(self): |
| 359 | + grib = self.mock_grib() |
| 360 | + grib.table2Version = 1 |
| 361 | + grib.indicatorOfParameter = 975 |
| 362 | + cube = self.cube_from_message(grib) |
| 363 | + self.assertEqual(cube.standard_name, None) |
| 364 | + self.assertEqual(cube.long_name, "UNKNOWN LOCAL PARAM 975.1") |
| 365 | + self.assertEqual(cube.units, cf_units.Unit("???")) |
| 366 | + |
| 367 | + def known_grib1(self, param, standard_str, units_str): |
| 368 | + grib = self.mock_grib() |
| 369 | + grib.table2Version = 1 |
| 370 | + grib.indicatorOfParameter = param |
| 371 | + cube = self.cube_from_message(grib) |
| 372 | + self.assertEqual(cube.standard_name, standard_str) |
| 373 | + self.assertEqual(cube.long_name, None) |
| 374 | + self.assertEqual(cube.units, cf_units.Unit(units_str)) |
| 375 | + |
| 376 | + def test_grib1_known_standard_params(self): |
| 377 | + # at present, there are just a very few of these |
| 378 | + self.known_grib1(11, "air_temperature", "kelvin") |
| 379 | + self.known_grib1(33, "x_wind", "m s-1") |
| 380 | + self.known_grib1(34, "y_wind", "m s-1") |
| 381 | + |
| 382 | + |
| 383 | +if __name__ == "__main__": |
| 384 | + tests.main() |
0 commit comments