diff --git a/doc/source/multiscene.rst b/doc/source/multiscene.rst index 8a7be6b8aa..35e48cbde1 100644 --- a/doc/source/multiscene.rst +++ b/doc/source/multiscene.rst @@ -85,6 +85,51 @@ iteratively overlays the remaining datasets on top. >>> blended_scene = new_mscn.blend() >>> blended_scene.save_datasets() + +Stacking scenes using weights +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +It is also possible to blend scenes together in a bit more sophisticated manner +using pixel based weighting instead of just stacking the scenes on top of each +other as described above. This can for instance be useful to make a cloud +parameter (cover, height, etc) composite combining cloud parameters derived +from both geostationary and polar orbiting satellite data close in time and +over a given area. This is useful for instance at high latitudes where +geostationary data degrade quickly with latitude and polar data are more +frequent. + +This weighted blending can be accomplished via the use of the builtin +:func:`~functools.partial` function (see `Partial +`_) and the +default :func:`~satpy.multiscene.stack` function. The +:func:`~satpy.multiscene.stack` function can take the optional argument +`weights` (`None` on default) which should be a sequence (of length equal to +the number of scenes being blended) of arrays with pixel weights. + +The code below gives an example of how two cloud scenes can be blended using +the satellite zenith angles to weight which pixels to take from each of the two +scenes. The idea being that the reliability of the cloud parameter is higher +when the satellite zenith angle is small. + + >>> from satpy import Scene, MultiScene, DataQuery + >>> from functools import partial + >>> from satpy.resample import get_area_def + >>> areaid = get_area_def("myarea") + >>> geo_scene = Scene(filenames=glob('/data/to/nwcsaf/geo/files/*nc'), reader='nwcsaf-geo') + >>> geo_scene.load(['ct']) + >>> polar_scene = Scene(filenames=glob('/data/to/nwcsaf/pps/noaa18/files/*nc'), reader='nwcsaf-pps_nc') + >>> polar_scene.load(['cma', 'ct']) + >>> mscn = MultiScene([geo_scene, polar_scene]) + >>> groups = {DataQuery(name='CTY_group'): ['ct']} + >>> mscn.group(groups) + >>> resampled = mscn.resample(areaid, reduce_data=False) + >>> weights = [1./geo_satz, 1./n18_satz] + >>> stack_with_weights = partial(stack, weights=weights) + >>> blended = resampled.blend(blend_function=stack_with_weights) + >>> blended_scene.save_dataset('CTY_group', filename='./blended_stack_weighted_geo_polar.nc') + + + Grouping Similar Datasets ^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/satpy/etc/readers/nwcsaf-pps_nc.yaml b/satpy/etc/readers/nwcsaf-pps_nc.yaml index dd12e1558b..8ae3b4ae7a 100644 --- a/satpy/etc/readers/nwcsaf-pps_nc.yaml +++ b/satpy/etc/readers/nwcsaf-pps_nc.yaml @@ -126,19 +126,19 @@ datasets: name: ct_conditions file_type: nc_nwcsaf_ct coordinates: [lon, lat] - standard_name: cloudtype_conditions + standard_name: ct_conditions ct_quality: name: ct_quality file_type: nc_nwcsaf_ct coordinates: [lon, lat] - standard_name: cloudtype_quality + standard_name: ct_quality ct_status_flag: name: ct_status_flag file_type: nc_nwcsaf_ct coordinates: [lon, lat] - standard_name: cloudtype_status_flag + standard_name: ct_status_flag ct_pal: name: ct_pal diff --git a/satpy/modifiers/angles.py b/satpy/modifiers/angles.py index 24f6976b60..7333cc7fe4 100644 --- a/satpy/modifiers/angles.py +++ b/satpy/modifiers/angles.py @@ -447,6 +447,7 @@ def _get_sensor_angles(data_arr: xr.DataArray) -> tuple[xr.DataArray, xr.DataArr sat_lon, sat_lat, sat_alt = get_satpos(data_arr, preference=preference) area_def = data_arr.attrs["area"] chunks = _geo_chunks_from_data_arr(data_arr) + sata, satz = _get_sensor_angles_from_sat_pos(sat_lon, sat_lat, sat_alt, data_arr.attrs["start_time"], area_def, chunks) diff --git a/satpy/multiscene.py b/satpy/multiscene.py index 2dee0ce146..72b7f95e7f 100644 --- a/satpy/multiscene.py +++ b/satpy/multiscene.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# Copyright (c) 2016-2019 Satpy developers +# Copyright (c) 2016-2023 Satpy developers # # This file is part of satpy. # @@ -20,6 +20,7 @@ import copy import logging import warnings +from datetime import datetime from queue import Queue from threading import Thread @@ -45,14 +46,69 @@ log = logging.getLogger(__name__) -def stack(datasets): - """Overlay series of datasets on top of each other.""" +def stack(datasets, weights=None, combine_times=True): + """Overlay a series of datasets together. + + By default, datasets are stacked on top of each other, so the last one applied is + on top. If a sequence of weights arrays are provided the datasets will + be combined according to those weights. The result will be a composite + dataset where the data in each pixel is coming from the dataset having the + highest weight. + + """ + if weights: + return _stack_weighted(datasets, weights, combine_times) + base = datasets[0].copy() for dataset in datasets[1:]: - base = base.where(dataset.isnull(), dataset) + try: + base = base.where(dataset == dataset.attrs["_FillValue"], dataset) + except KeyError: + base = base.where(dataset.isnull(), dataset) + return base +def _stack_weighted(datasets, weights, combine_times): + """Stack datasets using weights.""" + weights = set_weights_to_zero_where_invalid(datasets, weights) + + indices = da.argmax(da.dstack(weights), axis=-1) + attrs = combine_metadata(*[x.attrs for x in datasets]) + + if combine_times: + if 'start_time' in attrs and 'end_time' in attrs: + attrs['start_time'], attrs['end_time'] = _get_combined_start_end_times(*[x.attrs for x in datasets]) + + dims = datasets[0].dims + weighted_array = xr.DataArray(da.choose(indices, datasets), dims=dims, attrs=attrs) + return weighted_array + + +def set_weights_to_zero_where_invalid(datasets, weights): + """Go through the weights and set to pixel values to zero where corresponding datasets are invalid.""" + for i, dataset in enumerate(datasets): + try: + weights[i] = xr.where(dataset == dataset.attrs["_FillValue"], 0, weights[i]) + except KeyError: + weights[i] = xr.where(dataset.isnull(), 0, weights[i]) + + return weights + + +def _get_combined_start_end_times(*metadata_objects): + """Get the start and end times attributes valid for the entire dataset series.""" + start_time = datetime.now() + end_time = datetime.fromtimestamp(0) + for md_obj in metadata_objects: + if md_obj['start_time'] < start_time: + start_time = md_obj['start_time'] + if md_obj['end_time'] > end_time: + end_time = md_obj['end_time'] + + return start_time, end_time + + def timeseries(datasets): """Expand dataset with and concatenate by time dimension.""" expanded_ds = [] @@ -508,7 +564,7 @@ def _get_single_frame(self, ds, enh_args, fill_value): enh_args = enh_args.copy() # don't change caller's dict! if "decorate" in enh_args: enh_args["decorate"] = self._format_decoration( - ds, enh_args["decorate"]) + ds, enh_args["decorate"]) img = get_enhanced_image(ds, **enh_args) data, mode = img.finalize(fill_value=fill_value) if data.ndim == 3: @@ -632,7 +688,7 @@ def _get_writers_and_frames( info_datasets = [scn.get(dataset_id) for scn in info_scenes] this_fn, shape, this_fill = self._get_animation_info(info_datasets, filename, fill_value=fill_value) data_to_write = self._get_animation_frames( - all_datasets, shape, this_fill, ignore_missing, enh_args) + all_datasets, shape, this_fill, ignore_missing, enh_args) writer = imageio.get_writer(this_fn, **imio_args) frames[dataset_id] = data_to_write @@ -703,8 +759,8 @@ def save_animation(self, filename, datasets=None, fps=10, fill_value=None, raise ImportError("Missing required 'imageio' library") (writers, frames) = self._get_writers_and_frames( - filename, datasets, fill_value, ignore_missing, - enh_args, imio_args={"fps": fps, **kwargs}) + filename, datasets, fill_value, ignore_missing, + enh_args, imio_args={"fps": fps, **kwargs}) client = self._get_client(client=client) # get an ordered list of frames diff --git a/satpy/tests/multiscene_tests/__init__.py b/satpy/tests/multiscene_tests/__init__.py new file mode 100644 index 0000000000..0b191d1436 --- /dev/null +++ b/satpy/tests/multiscene_tests/__init__.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# Copyright (c) 2018-2023 Satpy developers +# +# This file is part of satpy. +# +# satpy is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# satpy 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# satpy. If not, see . +"""Unit tests for Multiscene.""" diff --git a/satpy/tests/multiscene_tests/test_blend.py b/satpy/tests/multiscene_tests/test_blend.py new file mode 100644 index 0000000000..1abf6dd693 --- /dev/null +++ b/satpy/tests/multiscene_tests/test_blend.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2018-2023 Satpy developers +# +# This file is part of satpy. +# +# satpy is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# satpy 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# satpy. If not, see . + +"""Unit tests for blending datasets with the Multiscene object.""" + +from datetime import datetime + +import dask.array as da +import pytest +import xarray as xr +from pyresample.geometry import AreaDefinition + +from satpy import DataQuery +from satpy.multiscene import stack +from satpy.tests.multiscene_tests.test_utils import _create_test_area, _create_test_dataset, _create_test_int8_dataset +from satpy.tests.utils import make_dataid + + +class TestBlendFuncs: + """Test individual functions used for blending.""" + + def setup_method(self): + """Set up test functions.""" + self._line = 2 + self._column = 3 + + @pytest.fixture + def scene1_with_weights(self): + """Create first test scene with a dataset of weights.""" + from satpy import Scene + + area = _create_test_area() + scene = Scene() + dsid1 = make_dataid( + name="geo-ct", + resolution=3000, + modifiers=() + ) + scene[dsid1] = _create_test_int8_dataset(name='geo-ct', area=area, values=1) + scene[dsid1].attrs['platform_name'] = 'Meteosat-11' + scene[dsid1].attrs['sensor'] = set({'seviri'}) + scene[dsid1].attrs['units'] = '1' + scene[dsid1].attrs['long_name'] = 'NWC GEO CT Cloud Type' + scene[dsid1].attrs['orbital_parameters'] = {'satellite_nominal_altitude': 35785863.0, + 'satellite_nominal_longitude': 0.0, + 'satellite_nominal_latitude': 0} + scene[dsid1].attrs['start_time'] = datetime(2023, 1, 16, 11, 9, 17) + scene[dsid1].attrs['end_time'] = datetime(2023, 1, 16, 11, 12, 22) + + wgt1 = _create_test_dataset(name='geo-ct-wgt', area=area, values=0) + + wgt1[self._line, :] = 2 + wgt1[:, self._column] = 2 + + dsid2 = make_dataid( + name="geo-cma", + resolution=3000, + modifiers=() + ) + scene[dsid2] = _create_test_int8_dataset(name='geo-cma', area=area, values=2) + scene[dsid2].attrs['start_time'] = datetime(2023, 1, 16, 11, 9, 17) + scene[dsid2].attrs['end_time'] = datetime(2023, 1, 16, 11, 12, 22) + + wgt2 = _create_test_dataset(name='geo-cma-wgt', area=area, values=0) + + return scene, [wgt1, wgt2] + + @pytest.fixture + def scene2_with_weights(self): + """Create second test scene.""" + from satpy import Scene + + area = _create_test_area() + scene = Scene() + dsid1 = make_dataid( + name="polar-ct", + resolution=1000, + modifiers=() + ) + scene[dsid1] = _create_test_int8_dataset(name='polar-ct', area=area, values=3) + scene[dsid1].attrs['platform_name'] = 'NOAA-18' + scene[dsid1].attrs['sensor'] = set({'avhrr-3'}) + scene[dsid1].attrs['units'] = '1' + scene[dsid1].attrs['long_name'] = 'SAFNWC PPS CT Cloud Type' + scene[dsid1][-1, :] = scene[dsid1].attrs['_FillValue'] + scene[dsid1].attrs['start_time'] = datetime(2023, 1, 16, 11, 12, 57, 500000) + scene[dsid1].attrs['end_time'] = datetime(2023, 1, 16, 11, 28, 1, 900000) + + wgt1 = _create_test_dataset(name='polar-ct-wgt', area=area, values=1) + + dsid2 = make_dataid( + name="polar-cma", + resolution=1000, + modifiers=() + ) + scene[dsid2] = _create_test_int8_dataset(name='polar-cma', area=area, values=4) + scene[dsid2].attrs['start_time'] = datetime(2023, 1, 16, 11, 12, 57, 500000) + scene[dsid2].attrs['end_time'] = datetime(2023, 1, 16, 11, 28, 1, 900000) + + wgt2 = _create_test_dataset(name='polar-cma-wgt', area=area, values=1) + return scene, [wgt1, wgt2] + + @pytest.fixture + def multi_scene_and_weights(self, scene1_with_weights, scene2_with_weights): + """Create small multi-scene for testing.""" + from satpy import MultiScene + scene1, weights1 = scene1_with_weights + scene2, weights2 = scene2_with_weights + + return MultiScene([scene1, scene2]), [weights1, weights2] + + @pytest.fixture + def groups(self): + """Get group definitions for the MultiScene.""" + return { + DataQuery(name='CloudType'): ['geo-ct', 'polar-ct'], + DataQuery(name='CloudMask'): ['geo-cma', 'polar-cma'] + } + + def test_blend_two_scenes_using_stack(self, multi_scene_and_weights, groups, + scene1_with_weights, scene2_with_weights): + """Test blending two scenes by stacking them on top of each other using function 'stack'.""" + multi_scene, weights = multi_scene_and_weights + scene1, weights1 = scene1_with_weights + scene2, weights2 = scene2_with_weights + + multi_scene.group(groups) + + resampled = multi_scene + stacked = resampled.blend(blend_function=stack) + result = stacked['CloudType'].compute() + + expected = scene2['polar-ct'].copy() + expected[-1, :] = scene1['geo-ct'][-1, :] + + xr.testing.assert_equal(result, expected.compute()) + assert result.attrs['platform_name'] == 'Meteosat-11' + assert result.attrs['sensor'] == set({'seviri'}) + assert result.attrs['long_name'] == 'NWC GEO CT Cloud Type' + assert result.attrs['units'] == '1' + assert result.attrs['name'] == 'CloudType' + assert result.attrs['_FillValue'] == 255 + assert result.attrs['valid_range'] == [1, 15] + + assert result.attrs['start_time'] == datetime(2023, 1, 16, 11, 9, 17) + assert result.attrs['end_time'] == datetime(2023, 1, 16, 11, 12, 22) + + def test_blend_two_scenes_using_stack_weighted(self, multi_scene_and_weights, groups, + scene1_with_weights, scene2_with_weights): + """Test stacking two scenes using weights - testing that metadata are combined correctly. + + Here we test that the start and end times can be combined so that they + describe the start and times of the entire data series. + + """ + from functools import partial + + multi_scene, weights = multi_scene_and_weights + scene1, weights1 = scene1_with_weights + scene2, weights2 = scene2_with_weights + + simple_groups = {DataQuery(name='CloudType'): groups[DataQuery(name='CloudType')]} + multi_scene.group(simple_groups) + + weights = [weights[0][0], weights[1][0]] + stack_with_weights = partial(stack, weights=weights) + weighted_blend = multi_scene.blend(blend_function=stack_with_weights) + + expected = scene2['polar-ct'] + expected[self._line, :] = scene1['geo-ct'][self._line, :] + expected[:, self._column] = scene1['geo-ct'][:, self._column] + expected[-1, :] = scene1['geo-ct'][-1, :] + + result = weighted_blend['CloudType'].compute() + xr.testing.assert_equal(result, expected.compute()) + + expected_area = _create_test_area() + assert result.attrs['area'] == expected_area + assert 'sensor' not in result.attrs + assert 'platform_name' not in result.attrs + assert 'long_name' not in result.attrs + assert result.attrs['units'] == '1' + assert result.attrs['name'] == 'CloudType' + assert result.attrs['_FillValue'] == 255 + assert result.attrs['valid_range'] == [1, 15] + + assert result.attrs['start_time'] == datetime(2023, 1, 16, 11, 9, 17) + assert result.attrs['end_time'] == datetime(2023, 1, 16, 11, 28, 1, 900000) + + def test_blend_two_scenes_using_stack_weighted_no_time_combination(self, multi_scene_and_weights, groups, + scene1_with_weights, scene2_with_weights): + """Test stacking two scenes using weights - test that the start and end times are averaged and not combined.""" + from functools import partial + + multi_scene, weights = multi_scene_and_weights + scene1, weights1 = scene1_with_weights + scene2, weights2 = scene2_with_weights + + simple_groups = {DataQuery(name='CloudType'): groups[DataQuery(name='CloudType')]} + multi_scene.group(simple_groups) + + weights = [weights[0][0], weights[1][0]] + stack_with_weights = partial(stack, weights=weights, combine_times=False) + weighted_blend = multi_scene.blend(blend_function=stack_with_weights) + + result = weighted_blend['CloudType'].compute() + + expected_area = _create_test_area() + assert result.attrs['area'] == expected_area + assert 'sensor' not in result.attrs + assert 'platform_name' not in result.attrs + assert 'long_name' not in result.attrs + assert result.attrs['units'] == '1' + assert result.attrs['name'] == 'CloudType' + assert result.attrs['_FillValue'] == 255 + assert result.attrs['valid_range'] == [1, 15] + + assert result.attrs['start_time'] == datetime(2023, 1, 16, 11, 11, 7, 250000) + assert result.attrs['end_time'] == datetime(2023, 1, 16, 11, 20, 11, 950000) + + @pytest.fixture + def datasets_and_weights(self): + """X-Array datasets with area definition plus weights for input to tests.""" + shape = (8, 12) + area = AreaDefinition('test', 'test', 'test', + {'proj': 'geos', 'lon_0': -95.5, 'h': 35786023.0}, + shape[1], shape[0], [-200, -200, 200, 200]) + + ds1 = xr.DataArray(da.ones(shape, chunks=-1), dims=('y', 'x'), + attrs={'start_time': datetime(2018, 1, 1, 0, 0, 0), 'area': area}) + ds2 = xr.DataArray(da.ones(shape, chunks=-1) * 2, dims=('y', 'x'), + attrs={'start_time': datetime(2018, 1, 1, 1, 0, 0), 'area': area}) + ds3 = xr.DataArray(da.ones(shape, chunks=-1) * 3, dims=('y', 'x'), + attrs={'start_time': datetime(2018, 1, 1, 1, 0, 0), 'area': area}) + + ds4 = xr.DataArray(da.zeros(shape, chunks=-1), dims=('y', 'time'), + attrs={'start_time': datetime(2018, 1, 1, 0, 0, 0), 'area': area}) + ds5 = xr.DataArray(da.zeros(shape, chunks=-1), dims=('y', 'time'), + attrs={'start_time': datetime(2018, 1, 1, 1, 0, 0), 'area': area}) + + wgt1 = xr.DataArray(da.ones(shape, chunks=-1), dims=('y', 'x'), + attrs={'start_time': datetime(2018, 1, 1, 0, 0, 0), 'area': area}) + wgt2 = xr.DataArray(da.zeros(shape, chunks=-1), dims=('y', 'x'), + attrs={'start_time': datetime(2018, 1, 1, 0, 0, 0), 'area': area}) + wgt3 = xr.DataArray(da.zeros(shape, chunks=-1), dims=('y', 'x'), + attrs={'start_time': datetime(2018, 1, 1, 0, 0, 0), 'area': area}) + + datastruct = {'shape': shape, + 'area': area, + 'datasets': [ds1, ds2, ds3, ds4, ds5], + 'weights': [wgt1, wgt2, wgt3]} + return datastruct + + @pytest.mark.parametrize(('line', 'column',), + [(2, 3), (4, 5)] + ) + def test_blend_function_stack_weighted(self, datasets_and_weights, line, column): + """Test the 'stack_weighted' function.""" + from functools import partial + + from satpy.dataset import combine_metadata + from satpy.multiscene import stack + + input_data = datasets_and_weights + + input_data['weights'][1][line, :] = 2 + input_data['weights'][2][:, column] = 2 + + stack_with_weights = partial(stack, weights=input_data['weights'], combine_times=False) + blend_result = stack_with_weights(input_data['datasets'][0:3]) + + ds1 = input_data['datasets'][0] + ds2 = input_data['datasets'][1] + ds3 = input_data['datasets'][2] + expected = ds1.copy() + expected[:, column] = ds3[:, column] + expected[line, :] = ds2[line, :] + expected.attrs = combine_metadata(*[x.attrs for x in input_data['datasets'][0:3]]) + + xr.testing.assert_equal(blend_result.compute(), expected.compute()) + + assert expected.attrs == blend_result.attrs + + def test_blend_function_stack(self, datasets_and_weights): + """Test the 'stack' function.""" + from satpy.multiscene import stack + + input_data = datasets_and_weights + + ds1 = input_data['datasets'][0] + ds2 = input_data['datasets'][1] + + res = stack([ds1, ds2]) + expected = ds2.copy() + + xr.testing.assert_equal(res.compute(), expected.compute()) + + def test_timeseries(self, datasets_and_weights): + """Test the 'timeseries' function.""" + from satpy.multiscene import timeseries + + input_data = datasets_and_weights + + ds1 = input_data['datasets'][0] + ds2 = input_data['datasets'][1] + ds4 = input_data['datasets'][2] + ds4 = input_data['datasets'][3] + ds5 = input_data['datasets'][4] + + res = timeseries([ds1, ds2]) + res2 = timeseries([ds4, ds5]) + assert isinstance(res, xr.DataArray) + assert isinstance(res2, xr.DataArray) + assert (2, ds1.shape[0], ds1.shape[1]) == res.shape + assert (ds4.shape[0], ds4.shape[1]+ds5.shape[1]) == res2.shape diff --git a/satpy/tests/multiscene_tests/test_misc.py b/satpy/tests/multiscene_tests/test_misc.py new file mode 100644 index 0000000000..5f8ea4dff9 --- /dev/null +++ b/satpy/tests/multiscene_tests/test_misc.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# Copyright (c) 2018-2023 Satpy developers +# +# This file is part of satpy. +# +# satpy is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# satpy 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# satpy. If not, see . + +"""Unit tests for the Multiscene object.""" + +import unittest +from unittest import mock + +import pytest +import xarray as xr + +from satpy import DataQuery +from satpy.tests.multiscene_tests.test_utils import _create_test_area, _create_test_dataset, _create_test_scenes +from satpy.tests.utils import make_dataid + + +class TestMultiScene(unittest.TestCase): + """Test basic functionality of MultiScene.""" + + def test_init_empty(self): + """Test creating a multiscene with no children.""" + from satpy import MultiScene + MultiScene() + + def test_init_children(self): + """Test creating a multiscene with children.""" + from satpy import MultiScene + scenes = _create_test_scenes() + MultiScene(scenes) + + def test_properties(self): + """Test basic properties/attributes of the MultiScene.""" + from satpy import MultiScene + + area = _create_test_area() + scenes = _create_test_scenes(area=area) + ds1_id = make_dataid(name='ds1') + ds2_id = make_dataid(name='ds2') + ds3_id = make_dataid(name='ds3') + ds4_id = make_dataid(name='ds4') + + # Add a dataset to only one of the Scenes + scenes[1]['ds3'] = _create_test_dataset('ds3') + mscn = MultiScene(scenes) + + self.assertSetEqual(mscn.loaded_dataset_ids, + {ds1_id, ds2_id, ds3_id}) + self.assertSetEqual(mscn.shared_dataset_ids, {ds1_id, ds2_id}) + self.assertTrue(mscn.all_same_area) + + bigger_area = _create_test_area(shape=(20, 40)) + scenes[0]['ds4'] = _create_test_dataset('ds4', shape=(20, 40), + area=bigger_area) + + self.assertSetEqual(mscn.loaded_dataset_ids, + {ds1_id, ds2_id, ds3_id, ds4_id}) + self.assertSetEqual(mscn.shared_dataset_ids, {ds1_id, ds2_id}) + self.assertFalse(mscn.all_same_area) + + def test_from_files(self): + """Test creating a multiscene from multiple files.""" + from satpy import MultiScene + input_files_abi = [ + "OR_ABI-L1b-RadC-M3C01_G16_s20171171502203_e20171171504576_c20171171505018.nc", + "OR_ABI-L1b-RadC-M3C01_G16_s20171171507203_e20171171509576_c20171171510018.nc", + "OR_ABI-L1b-RadC-M3C01_G16_s20171171512203_e20171171514576_c20171171515017.nc", + "OR_ABI-L1b-RadC-M3C01_G16_s20171171517203_e20171171519577_c20171171520019.nc", + "OR_ABI-L1b-RadC-M3C01_G16_s20171171522203_e20171171524576_c20171171525020.nc", + "OR_ABI-L1b-RadC-M3C01_G16_s20171171527203_e20171171529576_c20171171530017.nc", + ] + input_files_glm = [ + "OR_GLM-L2-GLMC-M3_G16_s20171171500000_e20171171501000_c20380190314080.nc", + "OR_GLM-L2-GLMC-M3_G16_s20171171501000_e20171171502000_c20380190314080.nc", + "OR_GLM-L2-GLMC-M3_G16_s20171171502000_e20171171503000_c20380190314080.nc", + "OR_GLM-L2-GLMC-M3_G16_s20171171503000_e20171171504000_c20380190314080.nc", + "OR_GLM-L2-GLMC-M3_G16_s20171171504000_e20171171505000_c20380190314080.nc", + "OR_GLM-L2-GLMC-M3_G16_s20171171505000_e20171171506000_c20380190314080.nc", + "OR_GLM-L2-GLMC-M3_G16_s20171171506000_e20171171507000_c20380190314080.nc", + "OR_GLM-L2-GLMC-M3_G16_s20171171507000_e20171171508000_c20380190314080.nc", + ] + with mock.patch('satpy.multiscene.Scene') as scn_mock: + mscn = MultiScene.from_files( + input_files_abi, + reader='abi_l1b', + scene_kwargs={"reader_kwargs": {}}) + assert len(mscn.scenes) == 6 + calls = [mock.call( + filenames={'abi_l1b': [in_file_abi]}, + reader_kwargs={}) + for in_file_abi in input_files_abi] + scn_mock.assert_has_calls(calls) + + scn_mock.reset_mock() + with pytest.warns(DeprecationWarning): + mscn = MultiScene.from_files( + input_files_abi + input_files_glm, + reader=('abi_l1b', "glm_l2"), + group_keys=["start_time"], + ensure_all_readers=True, + time_threshold=30) + assert len(mscn.scenes) == 2 + calls = [mock.call( + filenames={'abi_l1b': [in_file_abi], 'glm_l2': [in_file_glm]}) + for (in_file_abi, in_file_glm) in + zip(input_files_abi[0:2], + [input_files_glm[2]] + [input_files_glm[7]])] + scn_mock.assert_has_calls(calls) + scn_mock.reset_mock() + mscn = MultiScene.from_files( + input_files_abi + input_files_glm, + reader=('abi_l1b', "glm_l2"), + group_keys=["start_time"], + ensure_all_readers=False, + time_threshold=30) + assert len(mscn.scenes) == 12 + + +class TestMultiSceneGrouping: + """Test dataset grouping in MultiScene.""" + + @pytest.fixture + def scene1(self): + """Create first test scene.""" + from satpy import Scene + scene = Scene() + dsid1 = make_dataid( + name="ds1", + resolution=123, + wavelength=(1, 2, 3), + polarization="H" + ) + scene[dsid1] = _create_test_dataset(name='ds1') + dsid2 = make_dataid( + name="ds2", + resolution=456, + wavelength=(4, 5, 6), + polarization="V" + ) + scene[dsid2] = _create_test_dataset(name='ds2') + return scene + + @pytest.fixture + def scene2(self): + """Create second test scene.""" + from satpy import Scene + scene = Scene() + dsid1 = make_dataid( + name="ds3", + resolution=123.1, + wavelength=(1.1, 2.1, 3.1), + polarization="H" + ) + scene[dsid1] = _create_test_dataset(name='ds3') + dsid2 = make_dataid( + name="ds4", + resolution=456.1, + wavelength=(4.1, 5.1, 6.1), + polarization="V" + ) + scene[dsid2] = _create_test_dataset(name='ds4') + return scene + + @pytest.fixture + def multi_scene(self, scene1, scene2): + """Create small multi scene for testing.""" + from satpy import MultiScene + return MultiScene([scene1, scene2]) + + @pytest.fixture + def groups(self): + """Get group definitions for the MultiScene.""" + return { + DataQuery(name='odd'): ['ds1', 'ds3'], + DataQuery(name='even'): ['ds2', 'ds4'] + } + + def test_multi_scene_grouping(self, multi_scene, groups, scene1): + """Test grouping a MultiScene.""" + multi_scene.group(groups) + shared_ids_exp = {make_dataid(name="odd"), make_dataid(name="even")} + assert multi_scene.shared_dataset_ids == shared_ids_exp + assert DataQuery(name='odd') not in scene1 + xr.testing.assert_allclose(multi_scene.scenes[0]["ds1"], scene1["ds1"]) + + def test_fails_to_add_multiple_datasets_from_the_same_scene_to_a_group(self, multi_scene): + """Test that multiple datasets from the same scene in one group fails.""" + groups = {DataQuery(name='mygroup'): ['ds1', 'ds2']} + multi_scene.group(groups) + with pytest.raises(ValueError): + next(multi_scene.scenes) diff --git a/satpy/tests/test_multiscene.py b/satpy/tests/multiscene_tests/test_save_animation.py similarity index 58% rename from satpy/tests/test_multiscene.py rename to satpy/tests/multiscene_tests/test_save_animation.py index e38998d8e8..e5f29ad573 100644 --- a/satpy/tests/test_multiscene.py +++ b/satpy/tests/multiscene_tests/test_save_animation.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# Copyright (c) 2018 Satpy developers +# Copyright (c) 2018-2023 Satpy developers # # This file is part of satpy. # @@ -15,7 +15,11 @@ # # You should have received a copy of the GNU General Public License along with # satpy. If not, see . -"""Unit tests for multiscene.py.""" +"""Unit tests for saving animations using Multiscene.""" + +# NOTE: +# The following fixtures are not defined in this file, but are used and injected by Pytest: +# - tmp_path import os import shutil @@ -24,273 +28,12 @@ from datetime import datetime from unittest import mock -import pytest -import xarray as xr - -from satpy import DataQuery -from satpy.dataset.dataid import DataID, ModifierTuple, WavelengthRange - -# NOTE: -# The following fixtures are not defined in this file, but are used and injected by Pytest: -# - tmp_path - -DEFAULT_SHAPE = (5, 10) - -local_id_keys_config = {'name': { - 'required': True, -}, - 'wavelength': { - 'type': WavelengthRange, -}, - 'resolution': None, - 'calibration': { - 'enum': [ - 'reflectance', - 'brightness_temperature', - 'radiance', - 'counts' - ] -}, - 'polarization': None, - 'level': None, - 'modifiers': { - 'required': True, - 'default': ModifierTuple(), - 'type': ModifierTuple, -}, -} - - -def make_dataid(**items): - """Make a data id.""" - return DataID(local_id_keys_config, **items) - - -def _fake_get_enhanced_image(img, enhance=None, overlay=None, decorate=None): - from trollimage.xrimage import XRImage - return XRImage(img) - - -def _create_test_area(proj_str=None, shape=DEFAULT_SHAPE, extents=None): - """Create a test area definition.""" - from pyresample.geometry import AreaDefinition - from pyresample.utils import proj4_str_to_dict - if proj_str is None: - proj_str = '+proj=lcc +datum=WGS84 +ellps=WGS84 +lon_0=-95. ' \ - '+lat_0=25 +lat_1=25 +units=m +no_defs' - proj_dict = proj4_str_to_dict(proj_str) - extents = extents or (-1000., -1500., 1000., 1500.) - - return AreaDefinition( - 'test', - 'test', - 'test', - proj_dict, - shape[1], - shape[0], - extents - ) - - -def _create_test_dataset(name, shape=DEFAULT_SHAPE, area=None): - """Create a test DataArray object.""" - import dask.array as da - import numpy as np - import xarray as xr - - return xr.DataArray( - da.zeros(shape, dtype=np.float32, chunks=shape), dims=('y', 'x'), - attrs={'name': name, 'area': area, '_satpy_id_keys': local_id_keys_config}) - - -def _create_test_scenes(num_scenes=2, shape=DEFAULT_SHAPE, area=None): - """Create some test scenes for various test cases.""" - from satpy import Scene - ds1 = _create_test_dataset('ds1', shape=shape, area=area) - ds2 = _create_test_dataset('ds2', shape=shape, area=area) - scenes = [] - for _ in range(num_scenes): - scn = Scene() - scn['ds1'] = ds1.copy() - scn['ds2'] = ds2.copy() - scenes.append(scn) - return scenes - - -class TestMultiScene(unittest.TestCase): - """Test basic functionality of MultiScene.""" - - def test_init_empty(self): - """Test creating a multiscene with no children.""" - from satpy import MultiScene - MultiScene() - - def test_init_children(self): - """Test creating a multiscene with children.""" - from satpy import MultiScene - scenes = _create_test_scenes() - MultiScene(scenes) - - def test_properties(self): - """Test basic properties/attributes of the MultiScene.""" - from satpy import MultiScene - - area = _create_test_area() - scenes = _create_test_scenes(area=area) - ds1_id = make_dataid(name='ds1') - ds2_id = make_dataid(name='ds2') - ds3_id = make_dataid(name='ds3') - ds4_id = make_dataid(name='ds4') - - # Add a dataset to only one of the Scenes - scenes[1]['ds3'] = _create_test_dataset('ds3') - mscn = MultiScene(scenes) - - self.assertSetEqual(mscn.loaded_dataset_ids, - {ds1_id, ds2_id, ds3_id}) - self.assertSetEqual(mscn.shared_dataset_ids, {ds1_id, ds2_id}) - self.assertTrue(mscn.all_same_area) - - bigger_area = _create_test_area(shape=(20, 40)) - scenes[0]['ds4'] = _create_test_dataset('ds4', shape=(20, 40), - area=bigger_area) - - self.assertSetEqual(mscn.loaded_dataset_ids, - {ds1_id, ds2_id, ds3_id, ds4_id}) - self.assertSetEqual(mscn.shared_dataset_ids, {ds1_id, ds2_id}) - self.assertFalse(mscn.all_same_area) - - def test_from_files(self): - """Test creating a multiscene from multiple files.""" - from satpy import MultiScene - input_files_abi = [ - "OR_ABI-L1b-RadC-M3C01_G16_s20171171502203_e20171171504576_c20171171505018.nc", - "OR_ABI-L1b-RadC-M3C01_G16_s20171171507203_e20171171509576_c20171171510018.nc", - "OR_ABI-L1b-RadC-M3C01_G16_s20171171512203_e20171171514576_c20171171515017.nc", - "OR_ABI-L1b-RadC-M3C01_G16_s20171171517203_e20171171519577_c20171171520019.nc", - "OR_ABI-L1b-RadC-M3C01_G16_s20171171522203_e20171171524576_c20171171525020.nc", - "OR_ABI-L1b-RadC-M3C01_G16_s20171171527203_e20171171529576_c20171171530017.nc", - ] - input_files_glm = [ - "OR_GLM-L2-GLMC-M3_G16_s20171171500000_e20171171501000_c20380190314080.nc", - "OR_GLM-L2-GLMC-M3_G16_s20171171501000_e20171171502000_c20380190314080.nc", - "OR_GLM-L2-GLMC-M3_G16_s20171171502000_e20171171503000_c20380190314080.nc", - "OR_GLM-L2-GLMC-M3_G16_s20171171503000_e20171171504000_c20380190314080.nc", - "OR_GLM-L2-GLMC-M3_G16_s20171171504000_e20171171505000_c20380190314080.nc", - "OR_GLM-L2-GLMC-M3_G16_s20171171505000_e20171171506000_c20380190314080.nc", - "OR_GLM-L2-GLMC-M3_G16_s20171171506000_e20171171507000_c20380190314080.nc", - "OR_GLM-L2-GLMC-M3_G16_s20171171507000_e20171171508000_c20380190314080.nc", - ] - with mock.patch('satpy.multiscene.Scene') as scn_mock: - mscn = MultiScene.from_files( - input_files_abi, - reader='abi_l1b', - scene_kwargs={"reader_kwargs": {}}) - assert len(mscn.scenes) == 6 - calls = [mock.call( - filenames={'abi_l1b': [in_file_abi]}, - reader_kwargs={}) - for in_file_abi in input_files_abi] - scn_mock.assert_has_calls(calls) - - scn_mock.reset_mock() - with pytest.warns(DeprecationWarning): - mscn = MultiScene.from_files( - input_files_abi + input_files_glm, - reader=('abi_l1b', "glm_l2"), - group_keys=["start_time"], - ensure_all_readers=True, - time_threshold=30) - assert len(mscn.scenes) == 2 - calls = [mock.call( - filenames={'abi_l1b': [in_file_abi], 'glm_l2': [in_file_glm]}) - for (in_file_abi, in_file_glm) in - zip(input_files_abi[0:2], - [input_files_glm[2]] + [input_files_glm[7]])] - scn_mock.assert_has_calls(calls) - scn_mock.reset_mock() - mscn = MultiScene.from_files( - input_files_abi + input_files_glm, - reader=('abi_l1b', "glm_l2"), - group_keys=["start_time"], - ensure_all_readers=False, - time_threshold=30) - assert len(mscn.scenes) == 12 - - -class TestMultiSceneGrouping: - """Test dataset grouping in MultiScene.""" - - @pytest.fixture - def scene1(self): - """Create first test scene.""" - from satpy import Scene - scene = Scene() - dsid1 = make_dataid( - name="ds1", - resolution=123, - wavelength=(1, 2, 3), - polarization="H" - ) - scene[dsid1] = _create_test_dataset(name='ds1') - dsid2 = make_dataid( - name="ds2", - resolution=456, - wavelength=(4, 5, 6), - polarization="V" - ) - scene[dsid2] = _create_test_dataset(name='ds2') - return scene - - @pytest.fixture - def scene2(self): - """Create second test scene.""" - from satpy import Scene - scene = Scene() - dsid1 = make_dataid( - name="ds3", - resolution=123.1, - wavelength=(1.1, 2.1, 3.1), - polarization="H" - ) - scene[dsid1] = _create_test_dataset(name='ds3') - dsid2 = make_dataid( - name="ds4", - resolution=456.1, - wavelength=(4.1, 5.1, 6.1), - polarization="V" - ) - scene[dsid2] = _create_test_dataset(name='ds4') - return scene - - @pytest.fixture - def multi_scene(self, scene1, scene2): - """Create small multi scene for testing.""" - from satpy import MultiScene - return MultiScene([scene1, scene2]) - - @pytest.fixture - def groups(self): - """Get group definitions for the MultiScene.""" - return { - DataQuery(name='odd'): ['ds1', 'ds3'], - DataQuery(name='even'): ['ds2', 'ds4'] - } - - def test_multi_scene_grouping(self, multi_scene, groups, scene1): - """Test grouping a MultiScene.""" - multi_scene.group(groups) - shared_ids_exp = {make_dataid(name="odd"), make_dataid(name="even")} - assert multi_scene.shared_dataset_ids == shared_ids_exp - assert DataQuery(name='odd') not in scene1 - xr.testing.assert_allclose(multi_scene.scenes[0]["ds1"], scene1["ds1"]) - - def test_fails_to_add_multiple_datasets_from_the_same_scene_to_a_group(self, multi_scene): - """Test that multiple datasets from the same scene in one group fails.""" - groups = {DataQuery(name='mygroup'): ['ds1', 'ds2']} - multi_scene.group(groups) - with pytest.raises(ValueError): - next(multi_scene.scenes) +from satpy.tests.multiscene_tests.test_utils import ( + _create_test_area, + _create_test_dataset, + _create_test_scenes, + _fake_get_enhanced_image, +) class TestMultiSceneSave(unittest.TestCase): @@ -535,7 +278,7 @@ def test_crop(self): x_size // 2, y_size // 2, area_extent, - ) + ) scene1["1"] = DataArray(np.zeros((y_size, x_size))) scene1["2"] = DataArray(np.zeros((y_size, x_size)), dims=('y', 'x')) scene1["3"] = DataArray(np.zeros((y_size, x_size)), dims=('y', 'x'), @@ -556,51 +299,6 @@ def test_crop(self): self.assertTupleEqual(new_scn1['4'].shape, (92, 357)) -class TestBlendFuncs(unittest.TestCase): - """Test individual functions used for blending.""" - - def setUp(self): - """Set up test data.""" - from datetime import datetime - - import dask.array as da - import xarray as xr - from pyresample.geometry import AreaDefinition - area = AreaDefinition('test', 'test', 'test', - {'proj': 'geos', 'lon_0': -95.5, 'h': 35786023.0}, - 2, 2, [-200, -200, 200, 200]) - ds1 = xr.DataArray(da.zeros((2, 2), chunks=-1), dims=('y', 'x'), - attrs={'start_time': datetime(2018, 1, 1, 0, 0, 0), 'area': area}) - self.ds1 = ds1 - ds2 = xr.DataArray(da.zeros((2, 2), chunks=-1), dims=('y', 'x'), - attrs={'start_time': datetime(2018, 1, 1, 1, 0, 0), 'area': area}) - self.ds2 = ds2 - ds3 = xr.DataArray(da.zeros((2, 2), chunks=-1), dims=('y', 'time'), - attrs={'start_time': datetime(2018, 1, 1, 0, 0, 0), 'area': area}) - self.ds3 = ds3 - ds4 = xr.DataArray(da.zeros((2, 2), chunks=-1), dims=('y', 'time'), - attrs={'start_time': datetime(2018, 1, 1, 1, 0, 0), 'area': area}) - self.ds4 = ds4 - - def test_stack(self): - """Test the 'stack' function.""" - from satpy.multiscene import stack - res = stack([self.ds1, self.ds2]) - self.assertTupleEqual(self.ds1.shape, res.shape) - - def test_timeseries(self): - """Test the 'timeseries' function.""" - import xarray as xr - - from satpy.multiscene import timeseries - res = timeseries([self.ds1, self.ds2]) - res2 = timeseries([self.ds3, self.ds4]) - self.assertIsInstance(res, xr.DataArray) - self.assertIsInstance(res2, xr.DataArray) - self.assertTupleEqual((2, self.ds1.shape[0], self.ds1.shape[1]), res.shape) - self.assertTupleEqual((self.ds3.shape[0], self.ds3.shape[1]+self.ds4.shape[1]), res2.shape) - - @mock.patch('satpy.multiscene.get_enhanced_image') def test_save_mp4(smg, tmp_path): """Save a series of fake scenes to an mp4 video.""" @@ -660,11 +358,11 @@ def test_save_mp4(smg, tmp_path): with mock.patch('satpy.multiscene.imageio.get_writer') as get_writer: get_writer.return_value = writer_mock mscn.save_animation( - fn, client=False, - enh_args={"decorate": { - "decorate": [{ - "text": { - "txt": + fn, client=False, + enh_args={"decorate": { + "decorate": [{ + "text": { + "txt": "Test {start_time:%Y-%m-%d %H:%M} - " "{end_time:%Y-%m-%d %H:%M}"}}]}}) assert writer_mock.append_data.call_count == 2 + 2 diff --git a/satpy/tests/multiscene_tests/test_utils.py b/satpy/tests/multiscene_tests/test_utils.py new file mode 100644 index 0000000000..963f4b8f85 --- /dev/null +++ b/satpy/tests/multiscene_tests/test_utils.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# Copyright (c) 2018-2023 Satpy developers +# +# This file is part of satpy. +# +# satpy is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# satpy 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# satpy. If not, see . +"""Utilties to assist testing the Multiscene functionality. + +Creating fake test data for use in the other Multiscene test modules. +""" + + +import dask.array as da +import numpy as np +import xarray as xr +from pyresample.geometry import AreaDefinition + +from satpy.dataset.dataid import ModifierTuple, WavelengthRange + +DEFAULT_SHAPE = (5, 10) + +local_id_keys_config = {'name': { + 'required': True, +}, + 'wavelength': { + 'type': WavelengthRange, +}, + 'resolution': None, + 'calibration': { + 'enum': [ + 'reflectance', + 'brightness_temperature', + 'radiance', + 'counts' + ] +}, + 'polarization': None, + 'level': None, + 'modifiers': { + 'required': True, + 'default': ModifierTuple(), + 'type': ModifierTuple, +}, +} + + +def _fake_get_enhanced_image(img, enhance=None, overlay=None, decorate=None): + from trollimage.xrimage import XRImage + return XRImage(img) + + +def _create_test_area(proj_str=None, shape=DEFAULT_SHAPE, extents=None): + """Create a test area definition.""" + from pyresample.utils import proj4_str_to_dict + if proj_str is None: + proj_str = '+proj=lcc +datum=WGS84 +ellps=WGS84 +lon_0=-95. ' \ + '+lat_0=25 +lat_1=25 +units=m +no_defs' + proj_dict = proj4_str_to_dict(proj_str) + extents = extents or (-1000., -1500., 1000., 1500.) + + return AreaDefinition( + 'test', + 'test', + 'test', + proj_dict, + shape[1], + shape[0], + extents + ) + + +def _create_test_int8_dataset(name, shape=DEFAULT_SHAPE, area=None, values=None): + """Create a test DataArray object.""" + return xr.DataArray( + da.ones(shape, dtype=np.uint8, chunks=shape) * values, dims=('y', 'x'), + attrs={'_FillValue': 255, + 'valid_range': [1, 15], + 'name': name, 'area': area, '_satpy_id_keys': local_id_keys_config}) + + +def _create_test_dataset(name, shape=DEFAULT_SHAPE, area=None, values=None): + """Create a test DataArray object.""" + if values: + return xr.DataArray( + da.ones(shape, dtype=np.float32, chunks=shape) * values, dims=('y', 'x'), + attrs={'name': name, 'area': area, '_satpy_id_keys': local_id_keys_config}) + + return xr.DataArray( + da.zeros(shape, dtype=np.float32, chunks=shape), dims=('y', 'x'), + attrs={'name': name, 'area': area, '_satpy_id_keys': local_id_keys_config}) + + +def _create_test_scenes(num_scenes=2, shape=DEFAULT_SHAPE, area=None): + """Create some test scenes for various test cases.""" + from satpy import Scene + ds1 = _create_test_dataset('ds1', shape=shape, area=area) + ds2 = _create_test_dataset('ds2', shape=shape, area=area) + scenes = [] + for _ in range(num_scenes): + scn = Scene() + scn['ds1'] = ds1.copy() + scn['ds2'] = ds2.copy() + scenes.append(scn) + return scenes diff --git a/setup.py b/setup.py index 93b966103d..e2b33ced9e 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# Copyright (c) 2009-2020 Satpy developers +# Copyright (c) 2009-2023 Satpy developers # # This file is part of satpy. #