diff --git a/iris_grib/_load_convert.py b/iris_grib/_load_convert.py index b499759d4..b85dbd36c 100644 --- a/iris_grib/_load_convert.py +++ b/iris_grib/_load_convert.py @@ -1630,14 +1630,38 @@ def coord_timedelta(coord, value): return coord -def generating_process(section): +def time_coords(section, metadata, rt_coord): + if 'forecastTime' in section.keys(): + forecast_time = section['forecastTime'] + # The gribapi encodes the forecast time as 'startStep' for pdt 4.4x; + # product_definition_template_40 makes use of this function. The + # following will be removed once the suspected bug is fixed. + elif 'startStep' in section.keys(): + forecast_time = section['startStep'] + + # Calculate the forecast period coordinate. + fp_coord = forecast_period_coord(section['indicatorOfUnitOfTimeRange'], + forecast_time) + # Add the forecast period coordinate to the metadata aux coords. + metadata['aux_coords_and_dims'].append((fp_coord, None)) + # Calculate the "other" time coordinate - i.e. whichever of 'time' + # or 'forecast_reference_time' we don't already have. + other_coord = other_time_coord(rt_coord, fp_coord) + # Add the time coordinate to the metadata aux coords. + metadata['aux_coords_and_dims'].append((other_coord, None)) + # Add the reference time coordinate to the metadata aux coords. + metadata['aux_coords_and_dims'].append((rt_coord, None)) + + +def generating_process(section, include_forecast_process=True): if options.warn_on_unsupported: # Reference Code Table 4.3. warnings.warn('Unable to translate type of generating process.') warnings.warn('Unable to translate background generating ' 'process identifier.') - warnings.warn('Unable to translate forecast generating ' - 'process identifier.') + if include_forecast_process: + warnings.warn('Unable to translate forecast generating ' + 'process identifier.') def data_cutoff(hoursAfterDataCutoff, minutesAfterDataCutoff): @@ -1781,28 +1805,7 @@ def product_definition_template_0(section, metadata, rt_coord): data_cutoff(section['hoursAfterDataCutoff'], section['minutesAfterDataCutoff']) - if 'forecastTime' in section.keys(): - forecast_time = section['forecastTime'] - # The gribapi encodes the forecast time as 'startStep' for pdt 4.4x; - # product_definition_template_40 makes use of this function. The - # following will be removed once the suspected bug is fixed. - elif 'startStep' in section.keys(): - forecast_time = section['startStep'] - - # Calculate the forecast period coordinate. - fp_coord = forecast_period_coord(section['indicatorOfUnitOfTimeRange'], - forecast_time) - # Add the forecast period coordinate to the metadata aux coords. - metadata['aux_coords_and_dims'].append((fp_coord, None)) - - # Calculate the "other" time coordinate - i.e. whichever of 'time' - # or 'forecast_reference_time' we don't already have. - other_coord = other_time_coord(rt_coord, fp_coord) - # Add the time coordinate to the metadata aux coords. - metadata['aux_coords_and_dims'].append((other_coord, None)) - - # Add the reference time coordinate to the metadata aux coords. - metadata['aux_coords_and_dims'].append((rt_coord, None)) + time_coords(section, metadata, rt_coord) # Check for vertical coordinates. vertical_coords(section, metadata) @@ -2050,29 +2053,7 @@ def product_definition_template_15(section, metadata, frt_coord): method=cell_method_name)] -def product_definition_template_31(section, metadata, rt_coord): - """ - Translate template representing a satellite product. - - Updates the metadata in-place with the translations. - - Args: - - * section: - Dictionary of coded key/value pairs from section 4 of the message. - - * metadata: - :class:`collections.OrderedDict` of metadata. - - * rt_coord: - The scalar observation time :class:`iris.coords.DimCoord'. - - """ - if options.warn_on_unsupported: - warnings.warn('Unable to translate type of generating process.') - warnings.warn('Unable to translate observation generating ' - 'process identifier.') - +def satellite_common(section, metadata): # Number of contributing spectral bands. NB = section['NB'] @@ -2106,8 +2087,62 @@ def product_definition_template_31(section, metadata, rt_coord): # Add the central wave number coordinate to the metadata aux coords. metadata['aux_coords_and_dims'].append((coord, None)) - # Add the observation time coordinate. - metadata['aux_coords_and_dims'].append((rt_coord, None)) + +def product_definition_template_31(section, metadata, rt_coord): + """ + Translate template representing a satellite product. + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 4 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + * rt_coord: + The scalar observation time :class:`iris.coords.DimCoord'. + + """ + generating_process(section, include_forecast_process=False) + + satellite_common(section, metadata) + + # Add the observation time coordinate. + metadata['aux_coords_and_dims'].append((rt_coord, None)) + + +def product_definition_template_32(section, metadata, rt_coord): + """ + Translate template representing an analysis or forecast at a horizontal + level or in a horizontal layer at a point in time for simulated (synthetic) + satellite data. + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 4 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + * rt_coord: + The scalar observation time :class:`iris.coords.DimCoord'. + + """ + generating_process(section, include_forecast_process=False) + + # Handle the data cutoff. + data_cutoff(section['hoursAfterDataCutoff'], + section['minutesAfterDataCutoff']) + + time_coords(section, metadata, rt_coord) + + satellite_common(section, metadata) def product_definition_template_40(section, metadata, frt_coord): @@ -2193,6 +2228,8 @@ def product_definition_section(section, metadata, discipline, tablesVersion, elif template == 31: # Process satellite product. product_definition_template_31(section, metadata, rt_coord) + elif template == 32: + product_definition_template_32(section, metadata, rt_coord) elif template == 40: product_definition_template_40(section, metadata, rt_coord) else: diff --git a/iris_grib/tests/unit/load_convert/test_generating_process.py b/iris_grib/tests/unit/load_convert/test_generating_process.py index b6fa9d3cd..97fac5ac6 100644 --- a/iris_grib/tests/unit/load_convert/test_generating_process.py +++ b/iris_grib/tests/unit/load_convert/test_generating_process.py @@ -1,4 +1,4 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office +# (C) British Crown Copyright 2014 - 2017, Met Office # # This file is part of iris-grib. # @@ -38,18 +38,30 @@ def test_nowarn(self): generating_process(None) self.assertEqual(self.warn_patch.call_count, 0) - def test_warn(self): + def _check_warnings(self, with_forecast=True): module = 'iris_grib._load_convert' self.patch(module + '.options.warn_on_unsupported', True) - generating_process(None) + call_args = [None] + call_kwargs = {} + expected_fragments = [ + 'Unable to translate type of generating process', + 'Unable to translate background generating process'] + if with_forecast: + expected_fragments.append( + 'Unable to translate forecast generating process') + else: + call_kwargs['include_forecast_process'] = False + generating_process(*call_args, **call_kwargs) got_msgs = [call[0][0] for call in self.warn_patch.call_args_list] - expected_msgs = ['Unable to translate type of generating process', - 'Unable to translate background generating process', - 'Unable to translate forecast generating process'] - for expect_msg in expected_msgs: - matches = [msg for msg in got_msgs if expect_msg in msg] - self.assertEqual(len(matches), 1) - got_msgs.remove(matches[0]) + for got_msg, expected_fragment in zip(sorted(got_msgs), + sorted(expected_fragments)): + self.assertIn(expected_fragment, got_msg) + + def test_warn_full(self): + self._check_warnings() + + def test_warn_no_forecast(self): + self._check_warnings(with_forecast=False) if __name__ == '__main__': diff --git a/iris_grib/tests/unit/load_convert/test_product_definition_template_10.py b/iris_grib/tests/unit/load_convert/test_product_definition_template_10.py index c749476bd..0e4de1af3 100644 --- a/iris_grib/tests/unit/load_convert/test_product_definition_template_10.py +++ b/iris_grib/tests/unit/load_convert/test_product_definition_template_10.py @@ -31,7 +31,7 @@ from iris.coords import DimCoord from iris_grib._load_convert import product_definition_template_10 -from iris.tests.unit.fileformats.grib.load_convert import empty_metadata +from iris_grib.tests.unit.load_convert import empty_metadata class Test(tests.IrisGribTest): diff --git a/iris_grib/tests/unit/load_convert/test_product_definition_template_31.py b/iris_grib/tests/unit/load_convert/test_product_definition_template_31.py index f6611de69..10af448d6 100644 --- a/iris_grib/tests/unit/load_convert/test_product_definition_template_31.py +++ b/iris_grib/tests/unit/load_convert/test_product_definition_template_31.py @@ -1,4 +1,4 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office +# (C) British Crown Copyright 2014 - 2017, Met Office # # This file is part of iris-grib. # @@ -32,6 +32,7 @@ import warnings from iris.coords import AuxCoord +from iris_grib.tests.unit.load_convert import empty_metadata from iris_grib._load_convert import product_definition_template_31 @@ -39,17 +40,13 @@ class Test(tests.IrisGribTest): def setUp(self): self.patch('warnings.warn') - self.metadata = {'factories': [], 'references': [], - 'standard_name': None, - 'long_name': None, 'units': None, 'attributes': None, - 'cell_methods': [], 'dim_coords_and_dims': [], - 'aux_coords_and_dims': []} + self.satellite_common_patch = self.patch( + 'iris_grib._load_convert.satellite_common') + self.generating_process_patch = self.patch( + 'iris_grib._load_convert.generating_process') - def _check(self, request_warning=False, value=10, factor=1): + def test(self): # Prepare the arguments. - def unscale(v, f): - return v / 10.0 ** f - series = mock.sentinel.satelliteSeries number = mock.sentinel.satelliteNumber instrument = mock.sentinel.instrumentType @@ -58,50 +55,19 @@ def unscale(v, f): 'satelliteSeries': series, 'satelliteNumber': number, 'instrumentType': instrument, - 'scaleFactorOfCentralWaveNumber': factor, - 'scaledValueOfCentralWaveNumber': value} - metadata = deepcopy(self.metadata) - this = 'iris_grib._load_convert.options' - with mock.patch(this, warn_on_unsupported=request_warning): - # The call being tested. - product_definition_template_31(section, metadata, rt_coord) - # Check the result. - expected = deepcopy(self.metadata) - coord = AuxCoord(series, long_name='satellite_series') - expected['aux_coords_and_dims'].append((coord, None)) - coord = AuxCoord(number, long_name='satellite_number') - expected['aux_coords_and_dims'].append((coord, None)) - coord = AuxCoord(instrument, long_name='instrument_type') - expected['aux_coords_and_dims'].append((coord, None)) - standard_name = 'sensor_band_central_radiation_wavenumber' - coord = AuxCoord(unscale(value, factor), - standard_name=standard_name, - units='m-1') - expected['aux_coords_and_dims'].append((coord, None)) - expected['aux_coords_and_dims'].append((rt_coord, None)) - self.assertEqual(metadata, expected) - if request_warning: - warn_msgs = [arg[1][0] for arg in warnings.warn.mock_calls] - expected_msgs = ['type of generating process', - 'observation generating process identifier'] - for emsg in expected_msgs: - matches = [wmsg for wmsg in warn_msgs if emsg in wmsg] - self.assertEqual(len(matches), 1) - warn_msgs.remove(matches[0]) - else: - self.assertEqual(len(warnings.warn.mock_calls), 0) - - def test_pdt_no_warn(self): - self._check(request_warning=False) + 'scaleFactorOfCentralWaveNumber': 1, + 'scaledValueOfCentralWaveNumber': 12} - def test_pdt_warn(self): - self._check(request_warning=True) + # Call the function. + metadata = empty_metadata() + product_definition_template_31(section, metadata, rt_coord) - def test_wavelength_array(self): - value = np.array([1, 10, 100, 1000]) - for i in range(value.size): - factor = np.ones(value.shape) * i - self._check(value=value, factor=factor) + # Check that 'satellite_common' was called. + self.assertEqual(self.satellite_common_patch.call_count, 1) + # Check that 'generating_process' was called. + self.assertEqual(self.generating_process_patch.call_count, 1) + # Check that the scalar time coord was added in. + self.assertIn((rt_coord, None), metadata['aux_coords_and_dims']) if __name__ == '__main__': diff --git a/iris_grib/tests/unit/load_convert/test_product_definition_template_32.py b/iris_grib/tests/unit/load_convert/test_product_definition_template_32.py new file mode 100644 index 000000000..e54760a16 --- /dev/null +++ b/iris_grib/tests/unit/load_convert/test_product_definition_template_32.py @@ -0,0 +1,85 @@ +# (C) British Crown Copyright 2017, Met Office +# +# This file is part of iris-grib. +# +# iris-grib is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# iris-grib is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with iris-grib. If not, see . +""" +Tests for `iris_grib._load_convert.product_definition_template_32`. + +""" + +from __future__ import (absolute_import, division, print_function) +from six.moves import (filter, input, map, range, zip) # noqa + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from copy import deepcopy +import mock +import numpy as np +import warnings + +from iris.coords import AuxCoord, DimCoord +from iris_grib.tests.unit.load_convert import empty_metadata + +from iris_grib._load_convert import product_definition_template_32 + +MDI = 0xffffffff + + +class Test(tests.IrisGribTest): + def setUp(self): + self.patch('warnings.warn') + self.generating_process_patch = self.patch( + 'iris_grib._load_convert.generating_process') + self.satellite_common_patch = self.patch( + 'iris_grib._load_convert.satellite_common') + self.time_coords_patch = self.patch( + 'iris_grib._load_convert.time_coords') + self.data_cutoff_patch = self.patch( + 'iris_grib._load_convert.data_cutoff') + + def test(self, value=10, factor=1): + # Prepare the arguments. + series = mock.sentinel.satelliteSeries + number = mock.sentinel.satelliteNumber + instrument = mock.sentinel.instrumentType + rt_coord = mock.sentinel.observation_time + section = {'NB': 1, + 'hoursAfterDataCutoff': None, + 'minutesAfterDataCutoff': None, + 'satelliteSeries': series, + 'satelliteNumber': number, + 'instrumentType': instrument, + 'scaleFactorOfCentralWaveNumber': 1, + 'scaledValueOfCentralWaveNumber': 12, + } + + # Call the function. + metadata = empty_metadata() + product_definition_template_32(section, metadata, rt_coord) + + # Check that 'satellite_common' was called. + self.assertEqual(self.satellite_common_patch.call_count, 1) + # Check that 'generating_process' was called. + self.assertEqual(self.generating_process_patch.call_count, 1) + # Check that 'data_cutoff' was called. + self.assertEqual(self.data_cutoff_patch.call_count, 1) + # Check that 'time_coords' was called. + self.assertEqual(self.time_coords_patch.call_count, 1) + + +if __name__ == '__main__': + tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_satellite_common.py b/iris_grib/tests/unit/load_convert/test_satellite_common.py new file mode 100644 index 000000000..bb82c94a7 --- /dev/null +++ b/iris_grib/tests/unit/load_convert/test_satellite_common.py @@ -0,0 +1,84 @@ +# (C) British Crown Copyright 2014 - 2017, Met Office +# +# This file is part of iris-grib. +# +# iris-grib is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# iris-grib is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with iris-grib. If not, see . +""" +Tests for `iris_grib._load_convert.satellite_common`. + +""" + +from __future__ import (absolute_import, division, print_function) +from six.moves import (filter, input, map, range, zip) # noqa + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from copy import deepcopy +import mock +import numpy as np +import warnings + +from iris.coords import AuxCoord +from iris_grib.tests.unit.load_convert import empty_metadata + +from iris_grib._load_convert import satellite_common + + +class Test(tests.IrisGribTest): + def _check(self, factors=1, values=111): + # Prepare the arguments. + series = mock.sentinel.satelliteSeries + number = mock.sentinel.satelliteNumber + instrument = mock.sentinel.instrumentType + section = {'NB': 1, + 'satelliteSeries': series, + 'satelliteNumber': number, + 'instrumentType': instrument, + 'scaleFactorOfCentralWaveNumber': factors, + 'scaledValueOfCentralWaveNumber': values} + + # Call the function. + metadata = empty_metadata() + satellite_common(section, metadata) + + # Check the result. + expected = empty_metadata() + coord = AuxCoord(series, long_name='satellite_series') + expected['aux_coords_and_dims'].append((coord, None)) + coord = AuxCoord(number, long_name='satellite_number') + expected['aux_coords_and_dims'].append((coord, None)) + coord = AuxCoord(instrument, long_name='instrument_type') + expected['aux_coords_and_dims'].append((coord, None)) + standard_name = 'sensor_band_central_radiation_wavenumber' + coord = AuxCoord(values / (10.0 ** factors), + standard_name=standard_name, + units='m-1') + expected['aux_coords_and_dims'].append((coord, None)) + self.assertEqual(metadata, expected) + + def test_basic(self): + self._check() + + def test_multiple_wavelengths(self): + # Check with multiple values, and several different scaling factors. + values = np.array([1, 11, 123, 1975]) + for i_factor in (-3, -1, 0, 1, 3): + factors = np.ones(values.shape) * i_factor + self._check(values=values, factors=factors) + + +if __name__ == '__main__': + tests.main()