From 78894c3ec4268cb6b78b5177495ffce84a798046 Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Mon, 4 May 2026 11:51:48 +0000 Subject: [PATCH 01/37] Make sensor attribute a set --- satpy/composites/aux_data.py | 2 +- satpy/composites/config_loader.py | 4 ++-- satpy/composites/core.py | 19 +++++------------ satpy/enhancements/enhancer.py | 23 +++++++++----------- satpy/modifiers/_crefl_utils.py | 6 ++++-- satpy/modifiers/atmosphere.py | 11 ++++++++-- satpy/modifiers/spectral.py | 7 ++++-- satpy/readers/abi_l1b.py | 2 +- satpy/readers/core/abi.py | 2 +- satpy/readers/core/file_handlers.py | 2 +- satpy/scene.py | 9 ++------ satpy/utils.py | 33 +++++++++++++++++++++++++++++ satpy/writers/core/base.py | 6 ++++-- satpy/writers/mitiff.py | 10 ++++----- 14 files changed, 82 insertions(+), 54 deletions(-) diff --git a/satpy/composites/aux_data.py b/satpy/composites/aux_data.py index 3179f826a5..3463c7368d 100644 --- a/satpy/composites/aux_data.py +++ b/satpy/composites/aux_data.py @@ -156,7 +156,7 @@ def __call__(self, *args, **kwargs): if self.area is None: raise AttributeError("Area definition needs to be configured") img.attrs["area"] = self.area - img.attrs["sensor"] = None + img.attrs["sensor"] = set() img.attrs["mode"] = "".join(img.bands.data) img.attrs.pop("modifiers", None) img.attrs.pop("calibration", None) diff --git a/satpy/composites/config_loader.py b/satpy/composites/config_loader.py index e5165ada01..6617cfc665 100644 --- a/satpy/composites/config_loader.py +++ b/satpy/composites/config_loader.py @@ -31,7 +31,7 @@ from satpy import DataID, DataQuery from satpy._config import config_search_paths, get_entry_points_config_dirs, glob_config from satpy.dataset.dataid import minimal_default_keys_config -from satpy.utils import recursive_dict_update +from satpy.utils import normalize_sensor_name, recursive_dict_update logger = logging.getLogger(__name__) @@ -268,7 +268,7 @@ def load_compositor_configs_for_sensor(sensor_name: str) -> tuple[dict[str, dict DataID key -> key properties """ - config_filename = sensor_name + ".yaml" + config_filename = normalize_sensor_name(sensor_name) + ".yaml" logger.debug("Looking for composites config file %s", config_filename) paths = get_entry_points_config_dirs("satpy.composites") composite_configs = config_search_paths( diff --git a/satpy/composites/core.py b/satpy/composites/core.py index 1b124eb88a..b568bc8819 100644 --- a/satpy/composites/core.py +++ b/satpy/composites/core.py @@ -28,7 +28,7 @@ from satpy.dataset import DataID, combine_metadata from satpy.dataset.dataid import minimal_default_keys_config -from satpy.utils import unify_chunks +from satpy.utils import get_sensors_from_attrs, unify_chunks LOG = logging.getLogger(__name__) @@ -433,20 +433,11 @@ def _concat_datasets(self, projectables, mode): return data - def _get_sensors(self, projectables): - sensor = set() + def _get_sensors(self, projectables) -> set[str]: + sensors = set() for projectable in projectables: - current_sensor = projectable.attrs.get("sensor", None) - if current_sensor: - if isinstance(current_sensor, (str, bytes)): - sensor.add(current_sensor) - else: - sensor |= current_sensor - if len(sensor) == 0: - sensor = None - elif len(sensor) == 1: - sensor = list(sensor)[0] - return sensor + sensors.update(get_sensors_from_attrs(projectable.attrs)) + return sensors def __call__( self, diff --git a/satpy/enhancements/enhancer.py b/satpy/enhancements/enhancer.py index b196c63e28..7a217bc82e 100644 --- a/satpy/enhancements/enhancer.py +++ b/satpy/enhancements/enhancer.py @@ -24,7 +24,7 @@ from satpy._config import config_search_paths, get_entry_points_config_dirs from satpy.decision_tree import DecisionTree -from satpy.utils import get_logger, recursive_dict_update +from satpy.utils import get_logger, get_sensors_from_attrs, normalize_sensor_name, recursive_dict_update LOG = get_logger(__name__) @@ -122,26 +122,23 @@ def __init__(self, enhancement_config_file=None): self.sensor_enhancement_configs = [] - def get_sensor_enhancement_config(self, sensor): + def get_sensor_enhancement_config(self, sensors: set[str]): """Get the sensor-specific config.""" - if isinstance(sensor, str): - # one single sensor - sensor = [sensor] - paths = get_entry_points_config_dirs("satpy.enhancements") - for sensor_name in sensor: - config_fn = os.path.join("enhancements", sensor_name + ".yaml") + for sensor_name in sensors: + basename = normalize_sensor_name(sensor_name) + ".yaml" + config_fn = os.path.join("enhancements", basename) config_files = config_search_paths(config_fn, search_dirs=paths) # Note: Enhancement configuration files can't overwrite individual # options, only entire sections are overwritten for config_file in config_files: yield config_file - def add_sensor_enhancements(self, sensor): + def add_sensor_enhancements(self, sensors: set[str]): """Add sensor-specific enhancements.""" # XXX: Should we just load all enhancements from the base directory? new_configs = [] - for config_file in self.get_sensor_enhancement_config(sensor): + for config_file in self.get_sensor_enhancement_config(sensors): if config_file not in self.sensor_enhancement_configs: self.sensor_enhancement_configs.append(config_file) new_configs.append(config_file) @@ -209,9 +206,9 @@ def get_enhanced_image(dataset, enhance=None, overlay=None, decorate=None, if enhancer is None or enhancer.enhancement_tree is None: LOG.debug("No enhancement being applied to dataset") else: - if dataset.attrs.get("sensor", None): - enhancer.add_sensor_enhancements(dataset.attrs["sensor"]) - + sensors = get_sensors_from_attrs(dataset.attrs) + if sensors: + enhancer.add_sensor_enhancements(sensors) enhancer.apply(img, **dataset.attrs) if overlay is not None: diff --git a/satpy/modifiers/_crefl_utils.py b/satpy/modifiers/_crefl_utils.py index b8a1d52a4b..b638bfbf2f 100644 --- a/satpy/modifiers/_crefl_utils.py +++ b/satpy/modifiers/_crefl_utils.py @@ -70,6 +70,7 @@ import xarray as xr from satpy.dataset.dataid import WavelengthRange +from satpy.utils import get_one_sensor_from_attrs, normalize_sensor_name LOG = logging.getLogger(__name__) @@ -282,7 +283,8 @@ def run_crefl(refl, :param avg_elevation: average elevation (usually pre-calculated and stored in CMGDEM.hdf) """ - runner_cls = _runner_class_for_sensor(refl.attrs["sensor"]) + sensor = get_one_sensor_from_attrs(refl.attrs) + runner_cls = _runner_class_for_sensor(sensor) runner = runner_cls(refl) corr_refl = runner(sensor_azimuth, sensor_zenith, solar_azimuth, solar_zenith, avg_elevation) return corr_refl @@ -384,7 +386,7 @@ def _run_crefl(self, mus, muv, phi, solar_zenith, sensor_zenith, height, coeffs) def _runner_class_for_sensor(sensor_name: str) -> Type[_CREFLRunner]: try: - return _SENSOR_TO_RUNNER[sensor_name] + return _SENSOR_TO_RUNNER[normalize_sensor_name(sensor_name)] except KeyError: raise NotImplementedError(f"Don't know how to apply CREFL to data from sensor {sensor_name}.") diff --git a/satpy/modifiers/atmosphere.py b/satpy/modifiers/atmosphere.py index c7144c27ca..1ed3f5d026 100644 --- a/satpy/modifiers/atmosphere.py +++ b/satpy/modifiers/atmosphere.py @@ -26,6 +26,7 @@ from satpy.modifiers import ModifierBase from satpy.modifiers._crefl import ReflectanceCorrector # noqa from satpy.modifiers.angles import compute_relative_azimuth, get_angles, get_satellite_zenith_angle +from satpy.utils import get_one_sensor_from_attrs, get_pyspectral_sensor_name logger = logging.getLogger(__name__) @@ -104,7 +105,10 @@ def __call__(self, projectables, optional_datasets=None, **info): logger.info("Removing Rayleigh scattering with atmosphere '%s' and " "aerosol type '%s' for '%s'", atmosphere, aerosol_type, vis.attrs["name"]) - corrector = Rayleigh(vis.attrs["platform_name"], vis.attrs["sensor"], + sensor = get_pyspectral_sensor_name( + get_one_sensor_from_attrs(vis.attrs) + ) + corrector = Rayleigh(vis.attrs["platform_name"], sensor, atmosphere=atmosphere, aerosol_type=aerosol_type) @@ -158,8 +162,11 @@ def __call__(self, projectables, optional_datasets=None, **info): satz = satz.data # get dask array underneath logger.info("Correction for limb cooling") + sensor = get_pyspectral_sensor_name( + get_one_sensor_from_attrs(band.attrs) + ) corrector = AtmosphericalCorrection(band.attrs["platform_name"], - band.attrs["sensor"]) + sensor) atm_corr = da.map_blocks(_call_mapped_correction, satz, band.data, corrector=corrector, diff --git a/satpy/modifiers/spectral.py b/satpy/modifiers/spectral.py index 402b5606d4..9e55972e0b 100644 --- a/satpy/modifiers/spectral.py +++ b/satpy/modifiers/spectral.py @@ -22,6 +22,7 @@ import xarray as xr from satpy.modifiers import ModifierBase +from satpy.utils import get_one_sensor_from_attrs, get_pyspectral_sensor_name try: from pyspectral.near_infrared_reflectance import Calculator @@ -131,8 +132,10 @@ def _init_reflectance_calculator(self, metadata): if not Calculator: logger.info("Couldn't load pyspectral") raise ImportError("No module named pyspectral.near_infrared_reflectance") - - reflectance_3x_calculator = Calculator(metadata["platform_name"], metadata["sensor"], metadata["name"], + sensor = get_pyspectral_sensor_name( + get_one_sensor_from_attrs(metadata) + ) + reflectance_3x_calculator = Calculator(metadata["platform_name"], sensor, metadata["name"], sunz_threshold=self.sun_zenith_threshold, masking_limit=self.masking_limit) return reflectance_3x_calculator diff --git a/satpy/readers/abi_l1b.py b/satpy/readers/abi_l1b.py index 48e82f6968..b144529ebe 100644 --- a/satpy/readers/abi_l1b.py +++ b/satpy/readers/abi_l1b.py @@ -73,7 +73,7 @@ def get_dataset(self, key, info): def _adjust_attrs(self, data, key): data.attrs.update({"platform_name": self.platform_name, - "sensor": self.sensor}) + "sensor": {self.sensor}}) # Add orbital parameters projection = self.nc["goes_imager_projection"] data.attrs["orbital_parameters"] = { diff --git a/satpy/readers/core/abi.py b/satpy/readers/core/abi.py index e50cd616c4..d51764dc38 100644 --- a/satpy/readers/core/abi.py +++ b/satpy/readers/core/abi.py @@ -111,7 +111,7 @@ def _rename_dims(nc): @property def sensor(self): """Get sensor name for current file handler.""" - return "abi" + return "ABI" def __getitem__(self, item): """Wrap `self.nc[item]` for better floating point precision. diff --git a/satpy/readers/core/file_handlers.py b/satpy/readers/core/file_handlers.py index ac6626959c..6917988aa3 100644 --- a/satpy/readers/core/file_handlers.py +++ b/satpy/readers/core/file_handlers.py @@ -155,7 +155,7 @@ def end_time(self): return self.filename_info.get("end_time", self.start_time) @property - def sensor_names(self): + def sensor_names(self) -> set: """List of sensors represented in this file.""" raise NotImplementedError diff --git a/satpy/scene.py b/satpy/scene.py index 8693cc77b4..afcdb5dbae 100644 --- a/satpy/scene.py +++ b/satpy/scene.py @@ -36,7 +36,7 @@ from satpy.dependency_tree import DependencyTree from satpy.node import CompositorNode, MissingDependencies, ReaderNode from satpy.readers.core.loading import load_readers -from satpy.utils import convert_remote_files_to_fsspec, get_storage_options_from_reader_kwargs +from satpy.utils import convert_remote_files_to_fsspec, get_sensors_from_attrs, get_storage_options_from_reader_kwargs LOG = logging.getLogger(__name__) @@ -197,12 +197,7 @@ def sensor_names(self) -> set[str]: def _contained_sensor_names(self) -> set[str]: sensor_names = set() for data_arr in self.values(): - if "sensor" not in data_arr.attrs: - continue - if isinstance(data_arr.attrs["sensor"], str): - sensor_names.add(data_arr.attrs["sensor"]) - elif isinstance(data_arr.attrs["sensor"], set): - sensor_names.update(data_arr.attrs["sensor"]) + sensor_names.update(get_sensors_from_attrs(data_arr.attrs)) return sensor_names @property diff --git a/satpy/utils.py b/satpy/utils.py index bdf9d77e4c..1173d0718a 100644 --- a/satpy/utils.py +++ b/satpy/utils.py @@ -940,3 +940,36 @@ def flatten_dict(d, parent_key="", sep="_"): else: items.append((new_key, v)) return dict(items) + + +def get_sensors_from_attrs(attrs: dict[str,Any]) -> set[str]: + """Get sensor names from dataset attributes.""" + return attrs.get("sensor", set()) + + +def normalize_sensor_name(sensor: str) -> str: + """Normalize sensor name for internal usage.""" + return sensor.replace("-", "").replace(" ", "_").replace("/", "-").lower() + + +def get_one_sensor_from_attrs(attrs: dict[str,Any]) -> str: + """Get a single sensor name from dataset attributes.""" + sensors = get_sensors_from_attrs(attrs) + if not sensors: + raise KeyError("No 'sensor' dataset attribute") + if len(sensors) > 1: + logger.warning(f"More than one sensor in dataset attributes, will use the first value: {sensors}") + return list(sensors)[0] + + +def get_pyspectral_sensor_name(sensor: str) -> str: + """Get sensor name expected by pyspectral.""" + return normalize_sensor_name(sensor) + + +def serialize_sensors(sensors: set[str]) -> str: + """Serialize a set of sensors.""" + return "-".join( + sensor.replace("-", "").replace(" ", "").replace("/", "").lower() + for sensor in sorted(sensors) + ) diff --git a/satpy/writers/core/base.py b/satpy/writers/core/base.py index e0a53d0f7e..f8c1c1616e 100644 --- a/satpy/writers/core/base.py +++ b/satpy/writers/core/base.py @@ -16,6 +16,7 @@ """Shared objects and base classes for writers.""" from __future__ import annotations +import contextlib import logging import os import typing @@ -23,6 +24,7 @@ from satpy.aux_download import DataDownloadMixin from satpy.plugin_base import Plugin +from satpy.utils import serialize_sensors from satpy.writers.core.compute import compute_writer_results, split_results if typing.TYPE_CHECKING: @@ -136,8 +138,8 @@ def create_filename_parser(self, base_dir): @staticmethod def _prepare_metadata_for_filename_formatting(attrs): - if isinstance(attrs.get("sensor"), set): - attrs["sensor"] = "-".join(sorted(attrs["sensor"])) + with contextlib.suppress(KeyError): + attrs["sensor"] = serialize_sensors(attrs["sensor"]) def get_filename(self, **kwargs): """Create a filename where output data will be saved. diff --git a/satpy/writers/mitiff.py b/satpy/writers/mitiff.py index 7788d4b78a..0ed78e1a6a 100644 --- a/satpy/writers/mitiff.py +++ b/satpy/writers/mitiff.py @@ -28,6 +28,7 @@ from satpy.dataset import DataID, DataQuery from satpy.enhancements.enhancer import get_enhanced_image +from satpy.utils import get_one_sensor_from_attrs from satpy.writers.core.image import ImageWriter if typing.TYPE_CHECKING: @@ -53,12 +54,9 @@ def _adjust_kwargs(dataset, kwargs): if "start_time" not in kwargs: kwargs["start_time"] = dataset.attrs["start_time"] if "sensor" not in kwargs: - kwargs["sensor"] = dataset.attrs["sensor"] - # Sensor attrs could be set. MITIFFs needing to handle sensor can only have one sensor - # Assume the first value of set as the sensor. - if isinstance(kwargs["sensor"], set): - LOG.warning("Sensor is set, will use the first value: %s", kwargs["sensor"]) - kwargs["sensor"] = (list(kwargs["sensor"]))[0] + # MITIFFs needing to handle sensor can only have one sensor + # Assume the first value of set as the sensor. + kwargs["sensor"] = get_one_sensor_from_attrs(dataset.attrs) class MITIFFWriter(ImageWriter): From dac04571a88774c8311dd8116540173d0be74afb Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Wed, 6 May 2026 08:10:15 +0000 Subject: [PATCH 02/37] Use instrument instead of sensor attribute --- satpy/composites/aux_data.py | 2 +- satpy/composites/config_loader.py | 4 +-- satpy/composites/core.py | 6 ++--- satpy/composites/fill.py | 4 +-- satpy/composites/glm.py | 2 +- satpy/dependency_tree.py | 2 +- satpy/enhancements/enhancer.py | 10 ++++---- satpy/modifiers/_crefl_utils.py | 9 ++++--- satpy/modifiers/atmosphere.py | 10 ++++---- satpy/modifiers/spectral.py | 6 ++--- satpy/scene.py | 8 ++++-- satpy/utils.py | 42 +++++++++++++++---------------- satpy/writers/core/base.py | 4 +-- satpy/writers/mitiff.py | 4 +-- 14 files changed, 59 insertions(+), 54 deletions(-) diff --git a/satpy/composites/aux_data.py b/satpy/composites/aux_data.py index 3463c7368d..d5d74afb45 100644 --- a/satpy/composites/aux_data.py +++ b/satpy/composites/aux_data.py @@ -156,7 +156,7 @@ def __call__(self, *args, **kwargs): if self.area is None: raise AttributeError("Area definition needs to be configured") img.attrs["area"] = self.area - img.attrs["sensor"] = set() + img.attrs["instruments"] = set() img.attrs["mode"] = "".join(img.bands.data) img.attrs.pop("modifiers", None) img.attrs.pop("calibration", None) diff --git a/satpy/composites/config_loader.py b/satpy/composites/config_loader.py index 6617cfc665..96bdc61b72 100644 --- a/satpy/composites/config_loader.py +++ b/satpy/composites/config_loader.py @@ -31,7 +31,7 @@ from satpy import DataID, DataQuery from satpy._config import config_search_paths, get_entry_points_config_dirs, glob_config from satpy.dataset.dataid import minimal_default_keys_config -from satpy.utils import normalize_sensor_name, recursive_dict_update +from satpy.utils import normalize_instrument_name, recursive_dict_update logger = logging.getLogger(__name__) @@ -268,7 +268,7 @@ def load_compositor_configs_for_sensor(sensor_name: str) -> tuple[dict[str, dict DataID key -> key properties """ - config_filename = normalize_sensor_name(sensor_name) + ".yaml" + config_filename = normalize_instrument_name(sensor_name) + ".yaml" logger.debug("Looking for composites config file %s", config_filename) paths = get_entry_points_config_dirs("satpy.composites") composite_configs = config_search_paths( diff --git a/satpy/composites/core.py b/satpy/composites/core.py index b568bc8819..5f7c772a86 100644 --- a/satpy/composites/core.py +++ b/satpy/composites/core.py @@ -28,7 +28,7 @@ from satpy.dataset import DataID, combine_metadata from satpy.dataset.dataid import minimal_default_keys_config -from satpy.utils import get_sensors_from_attrs, unify_chunks +from satpy.utils import get_instruments_from_attrs, unify_chunks LOG = logging.getLogger(__name__) @@ -436,7 +436,7 @@ def _concat_datasets(self, projectables, mode): def _get_sensors(self, projectables) -> set[str]: sensors = set() for projectable in projectables: - sensors.update(get_sensors_from_attrs(projectable.attrs)) + sensors.update(get_instruments_from_attrs(projectable.attrs)) return sensors def __call__( @@ -503,7 +503,7 @@ def _get_updated_attrs(self, datasets, attrs, mode): new_attrs.update(self.attrs) if resolution is not None: new_attrs["resolution"] = resolution - new_attrs["sensor"] = self._get_sensors(datasets) + new_attrs["instruments"] = self._get_sensors(datasets) new_attrs["mode"] = mode return new_attrs diff --git a/satpy/composites/fill.py b/satpy/composites/fill.py index bd5b51577e..3b47f975d4 100644 --- a/satpy/composites/fill.py +++ b/satpy/composites/fill.py @@ -406,9 +406,9 @@ def _combine_metadata_with_mode_and_sensor(self, # 'mode' is no longer valid after we've remove the 'A' # let the base class __call__ determine mode attrs.pop("mode", None) - if attrs.get("sensor") is None: + if attrs.get("instruments") is None: # sensor can be a set - attrs["sensor"] = self._get_sensors([foreground, background]) + attrs["instruments"] = self._get_sensors([foreground, background]) return attrs @staticmethod diff --git a/satpy/composites/glm.py b/satpy/composites/glm.py index 866e952698..ceeef76e04 100644 --- a/satpy/composites/glm.py +++ b/satpy/composites/glm.py @@ -97,7 +97,7 @@ def _update_attrs(self, new_data, background_layer, highlight_layer): new_data.attrs["units"] = 1 new_sensors = self._get_sensors((highlight_layer, background_layer)) new_data.attrs.update({ - "sensor": new_sensors, + "instruments": new_sensors, }) def __call__(self, projectables, optional_datasets=None, **attrs): diff --git a/satpy/dependency_tree.py b/satpy/dependency_tree.py index 97777a71e9..59da567c96 100644 --- a/satpy/dependency_tree.py +++ b/satpy/dependency_tree.py @@ -514,7 +514,7 @@ def get_modifier(self, comp_id): mloader, moptions = modifiers[modifier] moptions = moptions.copy() moptions.update(comp_id.to_dict()) - moptions["sensor"] = sensor_name + moptions["instrument"] = sensor_name compositors[comp_id] = mloader(_satpy_id=comp_id, **moptions) return compositors[comp_id] diff --git a/satpy/enhancements/enhancer.py b/satpy/enhancements/enhancer.py index 7a217bc82e..2bf48bf688 100644 --- a/satpy/enhancements/enhancer.py +++ b/satpy/enhancements/enhancer.py @@ -24,7 +24,7 @@ from satpy._config import config_search_paths, get_entry_points_config_dirs from satpy.decision_tree import DecisionTree -from satpy.utils import get_logger, get_sensors_from_attrs, normalize_sensor_name, recursive_dict_update +from satpy.utils import get_instruments_from_attrs, get_logger, normalize_instrument_name, recursive_dict_update LOG = get_logger(__name__) @@ -38,12 +38,12 @@ def __init__(self, *decision_dicts, **kwargs): ("name", "reader", "platform_name", - "sensor", + "instruments", "standard_name", "units", )) self.prefix = kwargs.pop("config_section", "enhancements") - multival_keys = kwargs.pop("multival_keys", ["sensor"]) + multival_keys = kwargs.pop("multival_keys", ["instruments"]) super(EnhancementDecisionTree, self).__init__( decision_dicts, match_keys, multival_keys) @@ -126,7 +126,7 @@ def get_sensor_enhancement_config(self, sensors: set[str]): """Get the sensor-specific config.""" paths = get_entry_points_config_dirs("satpy.enhancements") for sensor_name in sensors: - basename = normalize_sensor_name(sensor_name) + ".yaml" + basename = normalize_instrument_name(sensor_name) + ".yaml" config_fn = os.path.join("enhancements", basename) config_files = config_search_paths(config_fn, search_dirs=paths) # Note: Enhancement configuration files can't overwrite individual @@ -206,7 +206,7 @@ def get_enhanced_image(dataset, enhance=None, overlay=None, decorate=None, if enhancer is None or enhancer.enhancement_tree is None: LOG.debug("No enhancement being applied to dataset") else: - sensors = get_sensors_from_attrs(dataset.attrs) + sensors = get_instruments_from_attrs(dataset.attrs) if sensors: enhancer.add_sensor_enhancements(sensors) enhancer.apply(img, **dataset.attrs) diff --git a/satpy/modifiers/_crefl_utils.py b/satpy/modifiers/_crefl_utils.py index b638bfbf2f..1d3f4eb712 100644 --- a/satpy/modifiers/_crefl_utils.py +++ b/satpy/modifiers/_crefl_utils.py @@ -70,7 +70,7 @@ import xarray as xr from satpy.dataset.dataid import WavelengthRange -from satpy.utils import get_one_sensor_from_attrs, normalize_sensor_name +from satpy.utils import get_one_instrument_from_attrs, normalize_instrument_name LOG = logging.getLogger(__name__) @@ -283,7 +283,7 @@ def run_crefl(refl, :param avg_elevation: average elevation (usually pre-calculated and stored in CMGDEM.hdf) """ - sensor = get_one_sensor_from_attrs(refl.attrs) + sensor = get_one_instrument_from_attrs(refl.attrs) runner_cls = _runner_class_for_sensor(sensor) runner = runner_cls(refl) corr_refl = runner(sensor_azimuth, sensor_zenith, solar_azimuth, solar_zenith, avg_elevation) @@ -350,8 +350,9 @@ def _run_crefl(self, mus, muv, phi, solar_zenith, sensor_zenith, height, coeffs) class _VIIRSMODISCREFLRunner(_CREFLRunner): def _run_crefl(self, mus, muv, phi, solar_zenith, sensor_zenith, height, coeffs): + instrument = get_one_instrument_from_attrs(self._refl.attrs) return da.map_blocks(_run_crefl, self._refl.data, mus.data, muv.data, phi.data, - height, self._refl.attrs.get("sensor"), *coeffs, + height, instrument, *coeffs, meta=np.ndarray((), dtype=self._refl.dtype), chunks=self._refl.chunks, dtype=self._refl.dtype, ) @@ -386,7 +387,7 @@ def _run_crefl(self, mus, muv, phi, solar_zenith, sensor_zenith, height, coeffs) def _runner_class_for_sensor(sensor_name: str) -> Type[_CREFLRunner]: try: - return _SENSOR_TO_RUNNER[normalize_sensor_name(sensor_name)] + return _SENSOR_TO_RUNNER[normalize_instrument_name(sensor_name)] except KeyError: raise NotImplementedError(f"Don't know how to apply CREFL to data from sensor {sensor_name}.") diff --git a/satpy/modifiers/atmosphere.py b/satpy/modifiers/atmosphere.py index 1ed3f5d026..dbb8870da7 100644 --- a/satpy/modifiers/atmosphere.py +++ b/satpy/modifiers/atmosphere.py @@ -26,7 +26,7 @@ from satpy.modifiers import ModifierBase from satpy.modifiers._crefl import ReflectanceCorrector # noqa from satpy.modifiers.angles import compute_relative_azimuth, get_angles, get_satellite_zenith_angle -from satpy.utils import get_one_sensor_from_attrs, get_pyspectral_sensor_name +from satpy.utils import get_one_instrument_from_attrs, get_pyspectral_instrument_name logger = logging.getLogger(__name__) @@ -105,8 +105,8 @@ def __call__(self, projectables, optional_datasets=None, **info): logger.info("Removing Rayleigh scattering with atmosphere '%s' and " "aerosol type '%s' for '%s'", atmosphere, aerosol_type, vis.attrs["name"]) - sensor = get_pyspectral_sensor_name( - get_one_sensor_from_attrs(vis.attrs) + sensor = get_pyspectral_instrument_name( + get_one_instrument_from_attrs(vis.attrs) ) corrector = Rayleigh(vis.attrs["platform_name"], sensor, atmosphere=atmosphere, @@ -162,8 +162,8 @@ def __call__(self, projectables, optional_datasets=None, **info): satz = satz.data # get dask array underneath logger.info("Correction for limb cooling") - sensor = get_pyspectral_sensor_name( - get_one_sensor_from_attrs(band.attrs) + sensor = get_pyspectral_instrument_name( + get_one_instrument_from_attrs(band.attrs) ) corrector = AtmosphericalCorrection(band.attrs["platform_name"], sensor) diff --git a/satpy/modifiers/spectral.py b/satpy/modifiers/spectral.py index 9e55972e0b..67562f19e9 100644 --- a/satpy/modifiers/spectral.py +++ b/satpy/modifiers/spectral.py @@ -22,7 +22,7 @@ import xarray as xr from satpy.modifiers import ModifierBase -from satpy.utils import get_one_sensor_from_attrs, get_pyspectral_sensor_name +from satpy.utils import get_one_instrument_from_attrs, get_pyspectral_instrument_name try: from pyspectral.near_infrared_reflectance import Calculator @@ -132,8 +132,8 @@ def _init_reflectance_calculator(self, metadata): if not Calculator: logger.info("Couldn't load pyspectral") raise ImportError("No module named pyspectral.near_infrared_reflectance") - sensor = get_pyspectral_sensor_name( - get_one_sensor_from_attrs(metadata) + sensor = get_pyspectral_instrument_name( + get_one_instrument_from_attrs(metadata) ) reflectance_3x_calculator = Calculator(metadata["platform_name"], sensor, metadata["name"], sunz_threshold=self.sun_zenith_threshold, diff --git a/satpy/scene.py b/satpy/scene.py index afcdb5dbae..30452ee989 100644 --- a/satpy/scene.py +++ b/satpy/scene.py @@ -36,7 +36,11 @@ from satpy.dependency_tree import DependencyTree from satpy.node import CompositorNode, MissingDependencies, ReaderNode from satpy.readers.core.loading import load_readers -from satpy.utils import convert_remote_files_to_fsspec, get_sensors_from_attrs, get_storage_options_from_reader_kwargs +from satpy.utils import ( + convert_remote_files_to_fsspec, + get_instruments_from_attrs, + get_storage_options_from_reader_kwargs, +) LOG = logging.getLogger(__name__) @@ -197,7 +201,7 @@ def sensor_names(self) -> set[str]: def _contained_sensor_names(self) -> set[str]: sensor_names = set() for data_arr in self.values(): - sensor_names.update(get_sensors_from_attrs(data_arr.attrs)) + sensor_names.update(get_instruments_from_attrs(data_arr.attrs)) return sensor_names @property diff --git a/satpy/utils.py b/satpy/utils.py index 1173d0718a..81f55f7cc7 100644 --- a/satpy/utils.py +++ b/satpy/utils.py @@ -942,34 +942,34 @@ def flatten_dict(d, parent_key="", sep="_"): return dict(items) -def get_sensors_from_attrs(attrs: dict[str,Any]) -> set[str]: - """Get sensor names from dataset attributes.""" - return attrs.get("sensor", set()) +def get_instruments_from_attrs(attrs: dict[str,Any]) -> set[str]: + """Get instrument names from dataset attributes.""" + return attrs.get("instruments", set()) -def normalize_sensor_name(sensor: str) -> str: - """Normalize sensor name for internal usage.""" - return sensor.replace("-", "").replace(" ", "_").replace("/", "-").lower() +def normalize_instrument_name(instrument: str) -> str: + """Normalize instrument name for internal usage.""" + return instrument.replace("-", "").replace(" ", "_").replace("/", "-").lower() -def get_one_sensor_from_attrs(attrs: dict[str,Any]) -> str: - """Get a single sensor name from dataset attributes.""" - sensors = get_sensors_from_attrs(attrs) - if not sensors: - raise KeyError("No 'sensor' dataset attribute") - if len(sensors) > 1: - logger.warning(f"More than one sensor in dataset attributes, will use the first value: {sensors}") - return list(sensors)[0] +def get_one_instrument_from_attrs(attrs: dict[str,Any]) -> str: + """Get a single instrument name from dataset attributes.""" + instruments = get_instruments_from_attrs(attrs) + if not instruments: + raise KeyError("No 'instruments' dataset attribute") + if len(instruments) > 1: + logger.warning(f"More than one instrument in dataset attributes, will use the first value: {instruments}") + return list(instruments)[0] -def get_pyspectral_sensor_name(sensor: str) -> str: - """Get sensor name expected by pyspectral.""" - return normalize_sensor_name(sensor) +def get_pyspectral_instrument_name(instrument: str) -> str: + """Get instrument name expected by pyspectral.""" + return normalize_instrument_name(instrument) -def serialize_sensors(sensors: set[str]) -> str: - """Serialize a set of sensors.""" +def serialize_instruments(instruments: set[str]) -> str: + """Serialize a set of instruments.""" return "-".join( - sensor.replace("-", "").replace(" ", "").replace("/", "").lower() - for sensor in sorted(sensors) + instr.replace("-", "").replace(" ", "").replace("/", "").lower() + for instr in sorted(instruments) ) diff --git a/satpy/writers/core/base.py b/satpy/writers/core/base.py index f8c1c1616e..056b7732b8 100644 --- a/satpy/writers/core/base.py +++ b/satpy/writers/core/base.py @@ -24,7 +24,7 @@ from satpy.aux_download import DataDownloadMixin from satpy.plugin_base import Plugin -from satpy.utils import serialize_sensors +from satpy.utils import serialize_instruments from satpy.writers.core.compute import compute_writer_results, split_results if typing.TYPE_CHECKING: @@ -139,7 +139,7 @@ def create_filename_parser(self, base_dir): @staticmethod def _prepare_metadata_for_filename_formatting(attrs): with contextlib.suppress(KeyError): - attrs["sensor"] = serialize_sensors(attrs["sensor"]) + attrs["instruments"] = serialize_instruments(attrs["instruments"]) def get_filename(self, **kwargs): """Create a filename where output data will be saved. diff --git a/satpy/writers/mitiff.py b/satpy/writers/mitiff.py index 0ed78e1a6a..b8a38c955d 100644 --- a/satpy/writers/mitiff.py +++ b/satpy/writers/mitiff.py @@ -28,7 +28,7 @@ from satpy.dataset import DataID, DataQuery from satpy.enhancements.enhancer import get_enhanced_image -from satpy.utils import get_one_sensor_from_attrs +from satpy.utils import get_one_instrument_from_attrs from satpy.writers.core.image import ImageWriter if typing.TYPE_CHECKING: @@ -56,7 +56,7 @@ def _adjust_kwargs(dataset, kwargs): if "sensor" not in kwargs: # MITIFFs needing to handle sensor can only have one sensor # Assume the first value of set as the sensor. - kwargs["sensor"] = get_one_sensor_from_attrs(dataset.attrs) + kwargs["sensor"] = get_one_instrument_from_attrs(dataset.attrs) class MITIFFWriter(ImageWriter): From 8ffb67196c0e91b00652a46a1cbee554e34ee830 Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Wed, 6 May 2026 10:58:39 +0000 Subject: [PATCH 03/37] Add instrument attribute setter --- satpy/_config.py | 1 + satpy/composites/aux_data.py | 3 ++- satpy/composites/core.py | 4 ++-- satpy/composites/fill.py | 6 ++++-- satpy/enhancements/enhancer.py | 13 ++++++++++--- satpy/utils.py | 16 +++++++++++++++- satpy/writers/core/base.py | 6 ++++-- 7 files changed, 38 insertions(+), 11 deletions(-) diff --git a/satpy/_config.py b/satpy/_config.py index 47012742d6..0b2e6a57c0 100644 --- a/satpy/_config.py +++ b/satpy/_config.py @@ -54,6 +54,7 @@ "readers": { "clip_negative_radiances": False, }, + "instruments_key": "sensor" } # Satpy main configuration object diff --git a/satpy/composites/aux_data.py b/satpy/composites/aux_data.py index d5d74afb45..406a451295 100644 --- a/satpy/composites/aux_data.py +++ b/satpy/composites/aux_data.py @@ -23,6 +23,7 @@ import satpy from satpy.aux_download import DataDownloadMixin +from satpy.utils import set_instruments_attr from .core import GenericCompositor @@ -156,7 +157,7 @@ def __call__(self, *args, **kwargs): if self.area is None: raise AttributeError("Area definition needs to be configured") img.attrs["area"] = self.area - img.attrs["instruments"] = set() + set_instruments_attr(img.attrs, set()) img.attrs["mode"] = "".join(img.bands.data) img.attrs.pop("modifiers", None) img.attrs.pop("calibration", None) diff --git a/satpy/composites/core.py b/satpy/composites/core.py index 5f7c772a86..85b31d1869 100644 --- a/satpy/composites/core.py +++ b/satpy/composites/core.py @@ -28,7 +28,7 @@ from satpy.dataset import DataID, combine_metadata from satpy.dataset.dataid import minimal_default_keys_config -from satpy.utils import get_instruments_from_attrs, unify_chunks +from satpy.utils import get_instruments_from_attrs, set_instruments_attr, unify_chunks LOG = logging.getLogger(__name__) @@ -503,7 +503,7 @@ def _get_updated_attrs(self, datasets, attrs, mode): new_attrs.update(self.attrs) if resolution is not None: new_attrs["resolution"] = resolution - new_attrs["instruments"] = self._get_sensors(datasets) + set_instruments_attr(new_attrs, self._get_sensors(datasets)) new_attrs["mode"] = mode return new_attrs diff --git a/satpy/composites/fill.py b/satpy/composites/fill.py index 3b47f975d4..994d6437f1 100644 --- a/satpy/composites/fill.py +++ b/satpy/composites/fill.py @@ -26,6 +26,7 @@ import xarray as xr from satpy.dataset import combine_metadata +from satpy.utils import get_instruments_from_attrs, set_instruments_attr from .core import ( GenericCompositor, @@ -406,9 +407,10 @@ def _combine_metadata_with_mode_and_sensor(self, # 'mode' is no longer valid after we've remove the 'A' # let the base class __call__ determine mode attrs.pop("mode", None) - if attrs.get("instruments") is None: + if not get_instruments_from_attrs(attrs): # sensor can be a set - attrs["instruments"] = self._get_sensors([foreground, background]) + instruments = self._get_sensors([foreground, background]) + set_instruments_attr(attrs, instruments) return attrs @staticmethod diff --git a/satpy/enhancements/enhancer.py b/satpy/enhancements/enhancer.py index 2bf48bf688..291c12c166 100644 --- a/satpy/enhancements/enhancer.py +++ b/satpy/enhancements/enhancer.py @@ -24,7 +24,13 @@ from satpy._config import config_search_paths, get_entry_points_config_dirs from satpy.decision_tree import DecisionTree -from satpy.utils import get_instruments_from_attrs, get_logger, normalize_instrument_name, recursive_dict_update +from satpy.utils import ( + get_instruments_from_attrs, + get_instruments_key, + get_logger, + normalize_instrument_name, + recursive_dict_update, +) LOG = get_logger(__name__) @@ -34,16 +40,17 @@ class EnhancementDecisionTree(DecisionTree): def __init__(self, *decision_dicts, **kwargs): """Init the decision tree.""" + instr_key = get_instruments_key() match_keys = kwargs.pop("match_keys", ("name", "reader", "platform_name", - "instruments", + instr_key, "standard_name", "units", )) self.prefix = kwargs.pop("config_section", "enhancements") - multival_keys = kwargs.pop("multival_keys", ["instruments"]) + multival_keys = kwargs.pop("multival_keys", [instr_key]) super(EnhancementDecisionTree, self).__init__( decision_dicts, match_keys, multival_keys) diff --git a/satpy/utils.py b/satpy/utils.py index 81f55f7cc7..d1f9b31d7e 100644 --- a/satpy/utils.py +++ b/satpy/utils.py @@ -39,6 +39,8 @@ from numpy.typing import ArrayLike, DTypeLike from yaml import BaseLoader, UnsafeLoader +import satpy + _is_logging_on = False TRACE_LEVEL = 5 @@ -944,7 +946,8 @@ def flatten_dict(d, parent_key="", sep="_"): def get_instruments_from_attrs(attrs: dict[str,Any]) -> set[str]: """Get instrument names from dataset attributes.""" - return attrs.get("instruments", set()) + key = get_instruments_key() + return attrs.get(key, set()) def normalize_instrument_name(instrument: str) -> str: @@ -973,3 +976,14 @@ def serialize_instruments(instruments: set[str]) -> str: instr.replace("-", "").replace(" ", "").replace("/", "").lower() for instr in sorted(instruments) ) + + +def set_instruments_attr(attrs: dict[str,Any], instruments: set[str]|str) -> None: + """Set 'instruments' dataset atrribute.""" + key = get_instruments_key() + attrs[key] = instruments + + +def get_instruments_key(): + """Get key for instruments in dataset attributes.""" + return satpy.config.get("instruments_key") diff --git a/satpy/writers/core/base.py b/satpy/writers/core/base.py index 056b7732b8..c2f5f69f3b 100644 --- a/satpy/writers/core/base.py +++ b/satpy/writers/core/base.py @@ -24,7 +24,7 @@ from satpy.aux_download import DataDownloadMixin from satpy.plugin_base import Plugin -from satpy.utils import serialize_instruments +from satpy.utils import get_instruments_from_attrs, serialize_instruments, set_instruments_attr from satpy.writers.core.compute import compute_writer_results, split_results if typing.TYPE_CHECKING: @@ -139,7 +139,9 @@ def create_filename_parser(self, base_dir): @staticmethod def _prepare_metadata_for_filename_formatting(attrs): with contextlib.suppress(KeyError): - attrs["instruments"] = serialize_instruments(attrs["instruments"]) + instruments = get_instruments_from_attrs(attrs) + serialized = serialize_instruments(instruments) + set_instruments_attr(attrs, serialized) def get_filename(self, **kwargs): """Create a filename where output data will be saved. From 2d730ea73fd586217f6e322c88251dbc9d337f21 Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Wed, 6 May 2026 11:03:05 +0000 Subject: [PATCH 04/37] Reset ABI name --- satpy/readers/core/abi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/satpy/readers/core/abi.py b/satpy/readers/core/abi.py index d51764dc38..e50cd616c4 100644 --- a/satpy/readers/core/abi.py +++ b/satpy/readers/core/abi.py @@ -111,7 +111,7 @@ def _rename_dims(nc): @property def sensor(self): """Get sensor name for current file handler.""" - return "ABI" + return "abi" def __getitem__(self, item): """Wrap `self.nc[item]` for better floating point precision. From 180a040d1add950511a052d4d95ed7a40a45f4bb Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Wed, 6 May 2026 12:04:14 +0000 Subject: [PATCH 05/37] Convert string type sensors to set for now --- satpy/utils.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/satpy/utils.py b/satpy/utils.py index d1f9b31d7e..c3a745d86b 100644 --- a/satpy/utils.py +++ b/satpy/utils.py @@ -947,7 +947,13 @@ def flatten_dict(d, parent_key="", sep="_"): def get_instruments_from_attrs(attrs: dict[str,Any]) -> set[str]: """Get instrument names from dataset attributes.""" key = get_instruments_key() - return attrs.get(key, set()) + try: + instruments = attrs[key] + if isinstance(instruments, str): + return set([instruments]) + return instruments + except KeyError: + return set() def normalize_instrument_name(instrument: str) -> str: From 46b08c29df381d506f44f70ce40e9911fecb9537 Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Wed, 6 May 2026 12:04:43 +0000 Subject: [PATCH 06/37] Fix tests --- satpy/tests/compositor_tests/test_aux_data.py | 4 ++-- satpy/tests/compositor_tests/test_core.py | 10 +++++----- satpy/tests/reader_tests/test_abi_l1b.py | 2 +- satpy/tests/scene_tests/test_load.py | 20 +++++++++---------- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/satpy/tests/compositor_tests/test_aux_data.py b/satpy/tests/compositor_tests/test_aux_data.py index 255231703e..d9e3657c66 100644 --- a/satpy/tests/compositor_tests/test_aux_data.py +++ b/satpy/tests/compositor_tests/test_aux_data.py @@ -83,7 +83,7 @@ def load(self, arg): filenames=[IMAGE_FILENAME]) register.assert_not_called() retrieve.assert_not_called() - assert res.attrs["sensor"] is None + assert res.attrs["sensor"] == set() assert "modifiers" not in res.attrs assert "calibration" not in res.attrs @@ -95,7 +95,7 @@ def load(self, arg): res = comp() Scene.assert_called_once_with(reader="generic_image", filenames=[os.path.join("data_dir", "foo.tif")]) - assert res.attrs["sensor"] is None + assert res.attrs["sensor"] == set() assert "modifiers" not in res.attrs assert "calibration" not in res.attrs diff --git a/satpy/tests/compositor_tests/test_core.py b/satpy/tests/compositor_tests/test_core.py index 315431aa4c..55f699aa42 100644 --- a/satpy/tests/compositor_tests/test_core.py +++ b/satpy/tests/compositor_tests/test_core.py @@ -277,13 +277,13 @@ def test_concat_datasets(self): def test_get_sensors(self): """Test getting sensors from the dataset attributes.""" res = self.comp._get_sensors([self.all_valid]) - assert res is None + assert res == set() dset1 = self.all_valid dset1.attrs["sensor"] = "foo" res = self.comp._get_sensors([dset1]) - assert res == "foo" + assert res == {"foo"} dset2 = self.first_invalid - dset2.attrs["sensor"] = "bar" + dset2.attrs["sensor"] = {"bar"} res = self.comp._get_sensors([dset1, dset2]) assert "foo" in res assert "bar" in res @@ -327,12 +327,12 @@ def test_call(self): """Test calling generic compositor.""" # Multiple datasets with extra attributes all_valid = self.all_valid - all_valid.attrs["sensor"] = "foo" + all_valid.attrs["sensor"] = {"foo"} attrs = {"foo": "bar", "resolution": 333} self.comp.attrs["resolution"] = None res = self.comp([self.all_valid, self.first_invalid], **attrs) # Verify attributes - assert res.attrs.get("sensor") == "foo" + assert res.attrs.get("sensor") == {"foo"} assert "foo" in res.attrs assert res.attrs.get("foo") == "bar" assert "units" not in res.attrs diff --git a/satpy/tests/reader_tests/test_abi_l1b.py b/satpy/tests/reader_tests/test_abi_l1b.py index bcb6d35041..855265dd21 100644 --- a/satpy/tests/reader_tests/test_abi_l1b.py +++ b/satpy/tests/reader_tests/test_abi_l1b.py @@ -371,7 +371,7 @@ def test_get_dataset(self, c01_data_arr): "scan_mode": "M4", "scene_abbr": "C", "scene_id": None, - "sensor": "abi", + "sensor": {"abi"}, "timeline_ID": None, "suffix": "suffix", "units": "W m-2 um-1 sr-1", diff --git a/satpy/tests/scene_tests/test_load.py b/satpy/tests/scene_tests/test_load.py index f3e862a46c..f39b416c8b 100644 --- a/satpy/tests/scene_tests/test_load.py +++ b/satpy/tests/scene_tests/test_load.py @@ -141,7 +141,7 @@ def test_available_when_sensor_none_in_preloaded_dataarrays(self): doesn't break available composite IDs. """ - scene = _scene_with_data_array_none_sensor() + scene = _scene_with_data_array_empty_sensor() available_comp_ids = scene.available_composite_ids() assert make_cid(name="static_image") in available_comp_ids @@ -645,31 +645,31 @@ def test_load_too_many(self): assert len(avail_comps) == 1 pytest.raises(KeyError, scene.load, [0.21]) - def test_load_when_sensor_none_in_preloaded_dataarrays(self): - """Test Scene loading when existing loaded arrays have sensor set to None. + def test_load_when_sensor_empty_in_preloaded_dataarrays(self): + """Test Scene loading when existing loaded arrays have empty sensor set. Some readers or composites (ex. static images) don't have a sensor and - developers choose to set it to `None`. This test makes sure this + developers choose to set it to `set()`. This test makes sure this doesn't break loading. """ - scene = _scene_with_data_array_none_sensor() + scene = _scene_with_data_array_empty_sensor() scene.load(["static_image"]) assert "static_image" in scene assert "my_data" in scene -def _scene_with_data_array_none_sensor(): +def _scene_with_data_array_empty_sensor(): scene = Scene(filenames=["fake1_1.txt"], reader="fake1") - scene["my_data"] = _data_array_none_sensor("my_data") + scene["my_data"] = _data_array_with_empty_sensor("my_data") return scene -def _data_array_none_sensor(name: str) -> xr.DataArray: - """Create a DataArray with sensor set to ``None``.""" +def _data_array_with_empty_sensor(name: str) -> xr.DataArray: + """Create a DataArray with empty sensor set.""" return xr.DataArray( da.zeros((2, 2)), attrs={ "name": name, - "sensor": None, + "sensor": set(), }) From fe1d232708a987fd92c00b1ffb241837b859d86d Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Wed, 6 May 2026 14:41:03 +0000 Subject: [PATCH 07/37] Update docstring --- satpy/utils.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/satpy/utils.py b/satpy/utils.py index c3a745d86b..96fc65869d 100644 --- a/satpy/utils.py +++ b/satpy/utils.py @@ -945,7 +945,12 @@ def flatten_dict(d, parent_key="", sep="_"): def get_instruments_from_attrs(attrs: dict[str,Any]) -> set[str]: - """Get instrument names from dataset attributes.""" + """Get instrument names from dataset attributes. + + String type attributes are converted to set. This can be + removed once all file handlers provide instruments as a + set. + """ key = get_instruments_key() try: instruments = attrs[key] From 32ee183d63e51f3f543994ad3e8fd2e962b9d8fd Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Wed, 6 May 2026 14:41:10 +0000 Subject: [PATCH 08/37] Restore accidental string replace --- satpy/dependency_tree.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/satpy/dependency_tree.py b/satpy/dependency_tree.py index 59da567c96..97777a71e9 100644 --- a/satpy/dependency_tree.py +++ b/satpy/dependency_tree.py @@ -514,7 +514,7 @@ def get_modifier(self, comp_id): mloader, moptions = modifiers[modifier] moptions = moptions.copy() moptions.update(comp_id.to_dict()) - moptions["instrument"] = sensor_name + moptions["sensor"] = sensor_name compositors[comp_id] = mloader(_satpy_id=comp_id, **moptions) return compositors[comp_id] From bb04e29e28d35dbab461fa33c77733f170a603a7 Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Mon, 4 May 2026 11:51:48 +0000 Subject: [PATCH 09/37] Make sensor attribute a set --- satpy/composites/aux_data.py | 2 +- satpy/composites/config_loader.py | 4 ++-- satpy/composites/core.py | 19 +++++------------ satpy/enhancements/enhancer.py | 23 +++++++++----------- satpy/modifiers/_crefl_utils.py | 6 ++++-- satpy/modifiers/atmosphere.py | 11 ++++++++-- satpy/modifiers/spectral.py | 7 ++++-- satpy/readers/abi_l1b.py | 2 +- satpy/readers/core/abi.py | 2 +- satpy/readers/core/file_handlers.py | 2 +- satpy/scene.py | 9 ++------ satpy/utils.py | 33 +++++++++++++++++++++++++++++ satpy/writers/core/base.py | 6 ++++-- satpy/writers/mitiff.py | 10 ++++----- 14 files changed, 82 insertions(+), 54 deletions(-) diff --git a/satpy/composites/aux_data.py b/satpy/composites/aux_data.py index 3179f826a5..3463c7368d 100644 --- a/satpy/composites/aux_data.py +++ b/satpy/composites/aux_data.py @@ -156,7 +156,7 @@ def __call__(self, *args, **kwargs): if self.area is None: raise AttributeError("Area definition needs to be configured") img.attrs["area"] = self.area - img.attrs["sensor"] = None + img.attrs["sensor"] = set() img.attrs["mode"] = "".join(img.bands.data) img.attrs.pop("modifiers", None) img.attrs.pop("calibration", None) diff --git a/satpy/composites/config_loader.py b/satpy/composites/config_loader.py index e5165ada01..6617cfc665 100644 --- a/satpy/composites/config_loader.py +++ b/satpy/composites/config_loader.py @@ -31,7 +31,7 @@ from satpy import DataID, DataQuery from satpy._config import config_search_paths, get_entry_points_config_dirs, glob_config from satpy.dataset.dataid import minimal_default_keys_config -from satpy.utils import recursive_dict_update +from satpy.utils import normalize_sensor_name, recursive_dict_update logger = logging.getLogger(__name__) @@ -268,7 +268,7 @@ def load_compositor_configs_for_sensor(sensor_name: str) -> tuple[dict[str, dict DataID key -> key properties """ - config_filename = sensor_name + ".yaml" + config_filename = normalize_sensor_name(sensor_name) + ".yaml" logger.debug("Looking for composites config file %s", config_filename) paths = get_entry_points_config_dirs("satpy.composites") composite_configs = config_search_paths( diff --git a/satpy/composites/core.py b/satpy/composites/core.py index 1b124eb88a..b568bc8819 100644 --- a/satpy/composites/core.py +++ b/satpy/composites/core.py @@ -28,7 +28,7 @@ from satpy.dataset import DataID, combine_metadata from satpy.dataset.dataid import minimal_default_keys_config -from satpy.utils import unify_chunks +from satpy.utils import get_sensors_from_attrs, unify_chunks LOG = logging.getLogger(__name__) @@ -433,20 +433,11 @@ def _concat_datasets(self, projectables, mode): return data - def _get_sensors(self, projectables): - sensor = set() + def _get_sensors(self, projectables) -> set[str]: + sensors = set() for projectable in projectables: - current_sensor = projectable.attrs.get("sensor", None) - if current_sensor: - if isinstance(current_sensor, (str, bytes)): - sensor.add(current_sensor) - else: - sensor |= current_sensor - if len(sensor) == 0: - sensor = None - elif len(sensor) == 1: - sensor = list(sensor)[0] - return sensor + sensors.update(get_sensors_from_attrs(projectable.attrs)) + return sensors def __call__( self, diff --git a/satpy/enhancements/enhancer.py b/satpy/enhancements/enhancer.py index b196c63e28..7a217bc82e 100644 --- a/satpy/enhancements/enhancer.py +++ b/satpy/enhancements/enhancer.py @@ -24,7 +24,7 @@ from satpy._config import config_search_paths, get_entry_points_config_dirs from satpy.decision_tree import DecisionTree -from satpy.utils import get_logger, recursive_dict_update +from satpy.utils import get_logger, get_sensors_from_attrs, normalize_sensor_name, recursive_dict_update LOG = get_logger(__name__) @@ -122,26 +122,23 @@ def __init__(self, enhancement_config_file=None): self.sensor_enhancement_configs = [] - def get_sensor_enhancement_config(self, sensor): + def get_sensor_enhancement_config(self, sensors: set[str]): """Get the sensor-specific config.""" - if isinstance(sensor, str): - # one single sensor - sensor = [sensor] - paths = get_entry_points_config_dirs("satpy.enhancements") - for sensor_name in sensor: - config_fn = os.path.join("enhancements", sensor_name + ".yaml") + for sensor_name in sensors: + basename = normalize_sensor_name(sensor_name) + ".yaml" + config_fn = os.path.join("enhancements", basename) config_files = config_search_paths(config_fn, search_dirs=paths) # Note: Enhancement configuration files can't overwrite individual # options, only entire sections are overwritten for config_file in config_files: yield config_file - def add_sensor_enhancements(self, sensor): + def add_sensor_enhancements(self, sensors: set[str]): """Add sensor-specific enhancements.""" # XXX: Should we just load all enhancements from the base directory? new_configs = [] - for config_file in self.get_sensor_enhancement_config(sensor): + for config_file in self.get_sensor_enhancement_config(sensors): if config_file not in self.sensor_enhancement_configs: self.sensor_enhancement_configs.append(config_file) new_configs.append(config_file) @@ -209,9 +206,9 @@ def get_enhanced_image(dataset, enhance=None, overlay=None, decorate=None, if enhancer is None or enhancer.enhancement_tree is None: LOG.debug("No enhancement being applied to dataset") else: - if dataset.attrs.get("sensor", None): - enhancer.add_sensor_enhancements(dataset.attrs["sensor"]) - + sensors = get_sensors_from_attrs(dataset.attrs) + if sensors: + enhancer.add_sensor_enhancements(sensors) enhancer.apply(img, **dataset.attrs) if overlay is not None: diff --git a/satpy/modifiers/_crefl_utils.py b/satpy/modifiers/_crefl_utils.py index 7e6ca0c50a..ca072f93f4 100644 --- a/satpy/modifiers/_crefl_utils.py +++ b/satpy/modifiers/_crefl_utils.py @@ -70,6 +70,7 @@ import xarray as xr from satpy.dataset.dataid import WavelengthRange +from satpy.utils import get_one_sensor_from_attrs, normalize_sensor_name LOG = logging.getLogger(__name__) @@ -282,7 +283,8 @@ def run_crefl(refl, :param avg_elevation: average elevation (usually pre-calculated and stored in CMGDEM.hdf) """ - runner_cls = _runner_class_for_sensor(refl.attrs["sensor"]) + sensor = get_one_sensor_from_attrs(refl.attrs) + runner_cls = _runner_class_for_sensor(sensor) runner = runner_cls(refl) corr_refl = runner(sensor_azimuth, sensor_zenith, solar_azimuth, solar_zenith, avg_elevation) return corr_refl @@ -384,7 +386,7 @@ def _run_crefl(self, mus, muv, phi, solar_zenith, sensor_zenith, height, coeffs) def _runner_class_for_sensor(sensor_name: str) -> Type[_CREFLRunner]: try: - return _SENSOR_TO_RUNNER[sensor_name] + return _SENSOR_TO_RUNNER[normalize_sensor_name(sensor_name)] except KeyError: raise NotImplementedError(f"Don't know how to apply CREFL to data from sensor {sensor_name}.") diff --git a/satpy/modifiers/atmosphere.py b/satpy/modifiers/atmosphere.py index c7144c27ca..1ed3f5d026 100644 --- a/satpy/modifiers/atmosphere.py +++ b/satpy/modifiers/atmosphere.py @@ -26,6 +26,7 @@ from satpy.modifiers import ModifierBase from satpy.modifiers._crefl import ReflectanceCorrector # noqa from satpy.modifiers.angles import compute_relative_azimuth, get_angles, get_satellite_zenith_angle +from satpy.utils import get_one_sensor_from_attrs, get_pyspectral_sensor_name logger = logging.getLogger(__name__) @@ -104,7 +105,10 @@ def __call__(self, projectables, optional_datasets=None, **info): logger.info("Removing Rayleigh scattering with atmosphere '%s' and " "aerosol type '%s' for '%s'", atmosphere, aerosol_type, vis.attrs["name"]) - corrector = Rayleigh(vis.attrs["platform_name"], vis.attrs["sensor"], + sensor = get_pyspectral_sensor_name( + get_one_sensor_from_attrs(vis.attrs) + ) + corrector = Rayleigh(vis.attrs["platform_name"], sensor, atmosphere=atmosphere, aerosol_type=aerosol_type) @@ -158,8 +162,11 @@ def __call__(self, projectables, optional_datasets=None, **info): satz = satz.data # get dask array underneath logger.info("Correction for limb cooling") + sensor = get_pyspectral_sensor_name( + get_one_sensor_from_attrs(band.attrs) + ) corrector = AtmosphericalCorrection(band.attrs["platform_name"], - band.attrs["sensor"]) + sensor) atm_corr = da.map_blocks(_call_mapped_correction, satz, band.data, corrector=corrector, diff --git a/satpy/modifiers/spectral.py b/satpy/modifiers/spectral.py index 402b5606d4..9e55972e0b 100644 --- a/satpy/modifiers/spectral.py +++ b/satpy/modifiers/spectral.py @@ -22,6 +22,7 @@ import xarray as xr from satpy.modifiers import ModifierBase +from satpy.utils import get_one_sensor_from_attrs, get_pyspectral_sensor_name try: from pyspectral.near_infrared_reflectance import Calculator @@ -131,8 +132,10 @@ def _init_reflectance_calculator(self, metadata): if not Calculator: logger.info("Couldn't load pyspectral") raise ImportError("No module named pyspectral.near_infrared_reflectance") - - reflectance_3x_calculator = Calculator(metadata["platform_name"], metadata["sensor"], metadata["name"], + sensor = get_pyspectral_sensor_name( + get_one_sensor_from_attrs(metadata) + ) + reflectance_3x_calculator = Calculator(metadata["platform_name"], sensor, metadata["name"], sunz_threshold=self.sun_zenith_threshold, masking_limit=self.masking_limit) return reflectance_3x_calculator diff --git a/satpy/readers/abi_l1b.py b/satpy/readers/abi_l1b.py index 48e82f6968..b144529ebe 100644 --- a/satpy/readers/abi_l1b.py +++ b/satpy/readers/abi_l1b.py @@ -73,7 +73,7 @@ def get_dataset(self, key, info): def _adjust_attrs(self, data, key): data.attrs.update({"platform_name": self.platform_name, - "sensor": self.sensor}) + "sensor": {self.sensor}}) # Add orbital parameters projection = self.nc["goes_imager_projection"] data.attrs["orbital_parameters"] = { diff --git a/satpy/readers/core/abi.py b/satpy/readers/core/abi.py index e50cd616c4..d51764dc38 100644 --- a/satpy/readers/core/abi.py +++ b/satpy/readers/core/abi.py @@ -111,7 +111,7 @@ def _rename_dims(nc): @property def sensor(self): """Get sensor name for current file handler.""" - return "abi" + return "ABI" def __getitem__(self, item): """Wrap `self.nc[item]` for better floating point precision. diff --git a/satpy/readers/core/file_handlers.py b/satpy/readers/core/file_handlers.py index ccf4f60bc5..54b57ebf1d 100644 --- a/satpy/readers/core/file_handlers.py +++ b/satpy/readers/core/file_handlers.py @@ -156,7 +156,7 @@ def end_time(self): return self.filename_info.get("end_time", self.start_time) @property - def sensor_names(self): + def sensor_names(self) -> set: """List of sensors represented in this file.""" raise NotImplementedError diff --git a/satpy/scene.py b/satpy/scene.py index 8693cc77b4..afcdb5dbae 100644 --- a/satpy/scene.py +++ b/satpy/scene.py @@ -36,7 +36,7 @@ from satpy.dependency_tree import DependencyTree from satpy.node import CompositorNode, MissingDependencies, ReaderNode from satpy.readers.core.loading import load_readers -from satpy.utils import convert_remote_files_to_fsspec, get_storage_options_from_reader_kwargs +from satpy.utils import convert_remote_files_to_fsspec, get_sensors_from_attrs, get_storage_options_from_reader_kwargs LOG = logging.getLogger(__name__) @@ -197,12 +197,7 @@ def sensor_names(self) -> set[str]: def _contained_sensor_names(self) -> set[str]: sensor_names = set() for data_arr in self.values(): - if "sensor" not in data_arr.attrs: - continue - if isinstance(data_arr.attrs["sensor"], str): - sensor_names.add(data_arr.attrs["sensor"]) - elif isinstance(data_arr.attrs["sensor"], set): - sensor_names.update(data_arr.attrs["sensor"]) + sensor_names.update(get_sensors_from_attrs(data_arr.attrs)) return sensor_names @property diff --git a/satpy/utils.py b/satpy/utils.py index bdf9d77e4c..1173d0718a 100644 --- a/satpy/utils.py +++ b/satpy/utils.py @@ -940,3 +940,36 @@ def flatten_dict(d, parent_key="", sep="_"): else: items.append((new_key, v)) return dict(items) + + +def get_sensors_from_attrs(attrs: dict[str,Any]) -> set[str]: + """Get sensor names from dataset attributes.""" + return attrs.get("sensor", set()) + + +def normalize_sensor_name(sensor: str) -> str: + """Normalize sensor name for internal usage.""" + return sensor.replace("-", "").replace(" ", "_").replace("/", "-").lower() + + +def get_one_sensor_from_attrs(attrs: dict[str,Any]) -> str: + """Get a single sensor name from dataset attributes.""" + sensors = get_sensors_from_attrs(attrs) + if not sensors: + raise KeyError("No 'sensor' dataset attribute") + if len(sensors) > 1: + logger.warning(f"More than one sensor in dataset attributes, will use the first value: {sensors}") + return list(sensors)[0] + + +def get_pyspectral_sensor_name(sensor: str) -> str: + """Get sensor name expected by pyspectral.""" + return normalize_sensor_name(sensor) + + +def serialize_sensors(sensors: set[str]) -> str: + """Serialize a set of sensors.""" + return "-".join( + sensor.replace("-", "").replace(" ", "").replace("/", "").lower() + for sensor in sorted(sensors) + ) diff --git a/satpy/writers/core/base.py b/satpy/writers/core/base.py index e0a53d0f7e..f8c1c1616e 100644 --- a/satpy/writers/core/base.py +++ b/satpy/writers/core/base.py @@ -16,6 +16,7 @@ """Shared objects and base classes for writers.""" from __future__ import annotations +import contextlib import logging import os import typing @@ -23,6 +24,7 @@ from satpy.aux_download import DataDownloadMixin from satpy.plugin_base import Plugin +from satpy.utils import serialize_sensors from satpy.writers.core.compute import compute_writer_results, split_results if typing.TYPE_CHECKING: @@ -136,8 +138,8 @@ def create_filename_parser(self, base_dir): @staticmethod def _prepare_metadata_for_filename_formatting(attrs): - if isinstance(attrs.get("sensor"), set): - attrs["sensor"] = "-".join(sorted(attrs["sensor"])) + with contextlib.suppress(KeyError): + attrs["sensor"] = serialize_sensors(attrs["sensor"]) def get_filename(self, **kwargs): """Create a filename where output data will be saved. diff --git a/satpy/writers/mitiff.py b/satpy/writers/mitiff.py index 7788d4b78a..0ed78e1a6a 100644 --- a/satpy/writers/mitiff.py +++ b/satpy/writers/mitiff.py @@ -28,6 +28,7 @@ from satpy.dataset import DataID, DataQuery from satpy.enhancements.enhancer import get_enhanced_image +from satpy.utils import get_one_sensor_from_attrs from satpy.writers.core.image import ImageWriter if typing.TYPE_CHECKING: @@ -53,12 +54,9 @@ def _adjust_kwargs(dataset, kwargs): if "start_time" not in kwargs: kwargs["start_time"] = dataset.attrs["start_time"] if "sensor" not in kwargs: - kwargs["sensor"] = dataset.attrs["sensor"] - # Sensor attrs could be set. MITIFFs needing to handle sensor can only have one sensor - # Assume the first value of set as the sensor. - if isinstance(kwargs["sensor"], set): - LOG.warning("Sensor is set, will use the first value: %s", kwargs["sensor"]) - kwargs["sensor"] = (list(kwargs["sensor"]))[0] + # MITIFFs needing to handle sensor can only have one sensor + # Assume the first value of set as the sensor. + kwargs["sensor"] = get_one_sensor_from_attrs(dataset.attrs) class MITIFFWriter(ImageWriter): From fbb55cf450df149dde5775058b23abb5d01f6a9e Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Wed, 6 May 2026 08:10:15 +0000 Subject: [PATCH 10/37] Use instrument instead of sensor attribute --- satpy/composites/aux_data.py | 2 +- satpy/composites/config_loader.py | 4 +-- satpy/composites/core.py | 6 ++--- satpy/composites/fill.py | 4 +-- satpy/composites/glm.py | 2 +- satpy/dependency_tree.py | 2 +- satpy/enhancements/enhancer.py | 10 ++++---- satpy/modifiers/_crefl_utils.py | 9 ++++--- satpy/modifiers/atmosphere.py | 10 ++++---- satpy/modifiers/spectral.py | 6 ++--- satpy/scene.py | 8 ++++-- satpy/utils.py | 42 +++++++++++++++---------------- satpy/writers/core/base.py | 4 +-- satpy/writers/mitiff.py | 4 +-- 14 files changed, 59 insertions(+), 54 deletions(-) diff --git a/satpy/composites/aux_data.py b/satpy/composites/aux_data.py index 3463c7368d..d5d74afb45 100644 --- a/satpy/composites/aux_data.py +++ b/satpy/composites/aux_data.py @@ -156,7 +156,7 @@ def __call__(self, *args, **kwargs): if self.area is None: raise AttributeError("Area definition needs to be configured") img.attrs["area"] = self.area - img.attrs["sensor"] = set() + img.attrs["instruments"] = set() img.attrs["mode"] = "".join(img.bands.data) img.attrs.pop("modifiers", None) img.attrs.pop("calibration", None) diff --git a/satpy/composites/config_loader.py b/satpy/composites/config_loader.py index 6617cfc665..96bdc61b72 100644 --- a/satpy/composites/config_loader.py +++ b/satpy/composites/config_loader.py @@ -31,7 +31,7 @@ from satpy import DataID, DataQuery from satpy._config import config_search_paths, get_entry_points_config_dirs, glob_config from satpy.dataset.dataid import minimal_default_keys_config -from satpy.utils import normalize_sensor_name, recursive_dict_update +from satpy.utils import normalize_instrument_name, recursive_dict_update logger = logging.getLogger(__name__) @@ -268,7 +268,7 @@ def load_compositor_configs_for_sensor(sensor_name: str) -> tuple[dict[str, dict DataID key -> key properties """ - config_filename = normalize_sensor_name(sensor_name) + ".yaml" + config_filename = normalize_instrument_name(sensor_name) + ".yaml" logger.debug("Looking for composites config file %s", config_filename) paths = get_entry_points_config_dirs("satpy.composites") composite_configs = config_search_paths( diff --git a/satpy/composites/core.py b/satpy/composites/core.py index b568bc8819..5f7c772a86 100644 --- a/satpy/composites/core.py +++ b/satpy/composites/core.py @@ -28,7 +28,7 @@ from satpy.dataset import DataID, combine_metadata from satpy.dataset.dataid import minimal_default_keys_config -from satpy.utils import get_sensors_from_attrs, unify_chunks +from satpy.utils import get_instruments_from_attrs, unify_chunks LOG = logging.getLogger(__name__) @@ -436,7 +436,7 @@ def _concat_datasets(self, projectables, mode): def _get_sensors(self, projectables) -> set[str]: sensors = set() for projectable in projectables: - sensors.update(get_sensors_from_attrs(projectable.attrs)) + sensors.update(get_instruments_from_attrs(projectable.attrs)) return sensors def __call__( @@ -503,7 +503,7 @@ def _get_updated_attrs(self, datasets, attrs, mode): new_attrs.update(self.attrs) if resolution is not None: new_attrs["resolution"] = resolution - new_attrs["sensor"] = self._get_sensors(datasets) + new_attrs["instruments"] = self._get_sensors(datasets) new_attrs["mode"] = mode return new_attrs diff --git a/satpy/composites/fill.py b/satpy/composites/fill.py index bd5b51577e..3b47f975d4 100644 --- a/satpy/composites/fill.py +++ b/satpy/composites/fill.py @@ -406,9 +406,9 @@ def _combine_metadata_with_mode_and_sensor(self, # 'mode' is no longer valid after we've remove the 'A' # let the base class __call__ determine mode attrs.pop("mode", None) - if attrs.get("sensor") is None: + if attrs.get("instruments") is None: # sensor can be a set - attrs["sensor"] = self._get_sensors([foreground, background]) + attrs["instruments"] = self._get_sensors([foreground, background]) return attrs @staticmethod diff --git a/satpy/composites/glm.py b/satpy/composites/glm.py index 866e952698..ceeef76e04 100644 --- a/satpy/composites/glm.py +++ b/satpy/composites/glm.py @@ -97,7 +97,7 @@ def _update_attrs(self, new_data, background_layer, highlight_layer): new_data.attrs["units"] = 1 new_sensors = self._get_sensors((highlight_layer, background_layer)) new_data.attrs.update({ - "sensor": new_sensors, + "instruments": new_sensors, }) def __call__(self, projectables, optional_datasets=None, **attrs): diff --git a/satpy/dependency_tree.py b/satpy/dependency_tree.py index 97777a71e9..59da567c96 100644 --- a/satpy/dependency_tree.py +++ b/satpy/dependency_tree.py @@ -514,7 +514,7 @@ def get_modifier(self, comp_id): mloader, moptions = modifiers[modifier] moptions = moptions.copy() moptions.update(comp_id.to_dict()) - moptions["sensor"] = sensor_name + moptions["instrument"] = sensor_name compositors[comp_id] = mloader(_satpy_id=comp_id, **moptions) return compositors[comp_id] diff --git a/satpy/enhancements/enhancer.py b/satpy/enhancements/enhancer.py index 7a217bc82e..2bf48bf688 100644 --- a/satpy/enhancements/enhancer.py +++ b/satpy/enhancements/enhancer.py @@ -24,7 +24,7 @@ from satpy._config import config_search_paths, get_entry_points_config_dirs from satpy.decision_tree import DecisionTree -from satpy.utils import get_logger, get_sensors_from_attrs, normalize_sensor_name, recursive_dict_update +from satpy.utils import get_instruments_from_attrs, get_logger, normalize_instrument_name, recursive_dict_update LOG = get_logger(__name__) @@ -38,12 +38,12 @@ def __init__(self, *decision_dicts, **kwargs): ("name", "reader", "platform_name", - "sensor", + "instruments", "standard_name", "units", )) self.prefix = kwargs.pop("config_section", "enhancements") - multival_keys = kwargs.pop("multival_keys", ["sensor"]) + multival_keys = kwargs.pop("multival_keys", ["instruments"]) super(EnhancementDecisionTree, self).__init__( decision_dicts, match_keys, multival_keys) @@ -126,7 +126,7 @@ def get_sensor_enhancement_config(self, sensors: set[str]): """Get the sensor-specific config.""" paths = get_entry_points_config_dirs("satpy.enhancements") for sensor_name in sensors: - basename = normalize_sensor_name(sensor_name) + ".yaml" + basename = normalize_instrument_name(sensor_name) + ".yaml" config_fn = os.path.join("enhancements", basename) config_files = config_search_paths(config_fn, search_dirs=paths) # Note: Enhancement configuration files can't overwrite individual @@ -206,7 +206,7 @@ def get_enhanced_image(dataset, enhance=None, overlay=None, decorate=None, if enhancer is None or enhancer.enhancement_tree is None: LOG.debug("No enhancement being applied to dataset") else: - sensors = get_sensors_from_attrs(dataset.attrs) + sensors = get_instruments_from_attrs(dataset.attrs) if sensors: enhancer.add_sensor_enhancements(sensors) enhancer.apply(img, **dataset.attrs) diff --git a/satpy/modifiers/_crefl_utils.py b/satpy/modifiers/_crefl_utils.py index ca072f93f4..c0d3a1e5c0 100644 --- a/satpy/modifiers/_crefl_utils.py +++ b/satpy/modifiers/_crefl_utils.py @@ -70,7 +70,7 @@ import xarray as xr from satpy.dataset.dataid import WavelengthRange -from satpy.utils import get_one_sensor_from_attrs, normalize_sensor_name +from satpy.utils import get_one_instrument_from_attrs, normalize_instrument_name LOG = logging.getLogger(__name__) @@ -283,7 +283,7 @@ def run_crefl(refl, :param avg_elevation: average elevation (usually pre-calculated and stored in CMGDEM.hdf) """ - sensor = get_one_sensor_from_attrs(refl.attrs) + sensor = get_one_instrument_from_attrs(refl.attrs) runner_cls = _runner_class_for_sensor(sensor) runner = runner_cls(refl) corr_refl = runner(sensor_azimuth, sensor_zenith, solar_azimuth, solar_zenith, avg_elevation) @@ -350,8 +350,9 @@ def _run_crefl(self, mus, muv, phi, solar_zenith, sensor_zenith, height, coeffs) class _VIIRSMODISCREFLRunner(_CREFLRunner): def _run_crefl(self, mus, muv, phi, solar_zenith, sensor_zenith, height, coeffs): + instrument = get_one_instrument_from_attrs(self._refl.attrs) return da.map_blocks(_run_crefl, self._refl.data, mus.data, muv.data, phi.data, - height, self._refl.attrs.get("sensor"), *coeffs, + height, instrument, *coeffs, meta=np.ndarray((), dtype=self._refl.dtype), chunks=self._refl.chunks, dtype=self._refl.dtype, ) @@ -386,7 +387,7 @@ def _run_crefl(self, mus, muv, phi, solar_zenith, sensor_zenith, height, coeffs) def _runner_class_for_sensor(sensor_name: str) -> Type[_CREFLRunner]: try: - return _SENSOR_TO_RUNNER[normalize_sensor_name(sensor_name)] + return _SENSOR_TO_RUNNER[normalize_instrument_name(sensor_name)] except KeyError: raise NotImplementedError(f"Don't know how to apply CREFL to data from sensor {sensor_name}.") diff --git a/satpy/modifiers/atmosphere.py b/satpy/modifiers/atmosphere.py index 1ed3f5d026..dbb8870da7 100644 --- a/satpy/modifiers/atmosphere.py +++ b/satpy/modifiers/atmosphere.py @@ -26,7 +26,7 @@ from satpy.modifiers import ModifierBase from satpy.modifiers._crefl import ReflectanceCorrector # noqa from satpy.modifiers.angles import compute_relative_azimuth, get_angles, get_satellite_zenith_angle -from satpy.utils import get_one_sensor_from_attrs, get_pyspectral_sensor_name +from satpy.utils import get_one_instrument_from_attrs, get_pyspectral_instrument_name logger = logging.getLogger(__name__) @@ -105,8 +105,8 @@ def __call__(self, projectables, optional_datasets=None, **info): logger.info("Removing Rayleigh scattering with atmosphere '%s' and " "aerosol type '%s' for '%s'", atmosphere, aerosol_type, vis.attrs["name"]) - sensor = get_pyspectral_sensor_name( - get_one_sensor_from_attrs(vis.attrs) + sensor = get_pyspectral_instrument_name( + get_one_instrument_from_attrs(vis.attrs) ) corrector = Rayleigh(vis.attrs["platform_name"], sensor, atmosphere=atmosphere, @@ -162,8 +162,8 @@ def __call__(self, projectables, optional_datasets=None, **info): satz = satz.data # get dask array underneath logger.info("Correction for limb cooling") - sensor = get_pyspectral_sensor_name( - get_one_sensor_from_attrs(band.attrs) + sensor = get_pyspectral_instrument_name( + get_one_instrument_from_attrs(band.attrs) ) corrector = AtmosphericalCorrection(band.attrs["platform_name"], sensor) diff --git a/satpy/modifiers/spectral.py b/satpy/modifiers/spectral.py index 9e55972e0b..67562f19e9 100644 --- a/satpy/modifiers/spectral.py +++ b/satpy/modifiers/spectral.py @@ -22,7 +22,7 @@ import xarray as xr from satpy.modifiers import ModifierBase -from satpy.utils import get_one_sensor_from_attrs, get_pyspectral_sensor_name +from satpy.utils import get_one_instrument_from_attrs, get_pyspectral_instrument_name try: from pyspectral.near_infrared_reflectance import Calculator @@ -132,8 +132,8 @@ def _init_reflectance_calculator(self, metadata): if not Calculator: logger.info("Couldn't load pyspectral") raise ImportError("No module named pyspectral.near_infrared_reflectance") - sensor = get_pyspectral_sensor_name( - get_one_sensor_from_attrs(metadata) + sensor = get_pyspectral_instrument_name( + get_one_instrument_from_attrs(metadata) ) reflectance_3x_calculator = Calculator(metadata["platform_name"], sensor, metadata["name"], sunz_threshold=self.sun_zenith_threshold, diff --git a/satpy/scene.py b/satpy/scene.py index afcdb5dbae..30452ee989 100644 --- a/satpy/scene.py +++ b/satpy/scene.py @@ -36,7 +36,11 @@ from satpy.dependency_tree import DependencyTree from satpy.node import CompositorNode, MissingDependencies, ReaderNode from satpy.readers.core.loading import load_readers -from satpy.utils import convert_remote_files_to_fsspec, get_sensors_from_attrs, get_storage_options_from_reader_kwargs +from satpy.utils import ( + convert_remote_files_to_fsspec, + get_instruments_from_attrs, + get_storage_options_from_reader_kwargs, +) LOG = logging.getLogger(__name__) @@ -197,7 +201,7 @@ def sensor_names(self) -> set[str]: def _contained_sensor_names(self) -> set[str]: sensor_names = set() for data_arr in self.values(): - sensor_names.update(get_sensors_from_attrs(data_arr.attrs)) + sensor_names.update(get_instruments_from_attrs(data_arr.attrs)) return sensor_names @property diff --git a/satpy/utils.py b/satpy/utils.py index 1173d0718a..81f55f7cc7 100644 --- a/satpy/utils.py +++ b/satpy/utils.py @@ -942,34 +942,34 @@ def flatten_dict(d, parent_key="", sep="_"): return dict(items) -def get_sensors_from_attrs(attrs: dict[str,Any]) -> set[str]: - """Get sensor names from dataset attributes.""" - return attrs.get("sensor", set()) +def get_instruments_from_attrs(attrs: dict[str,Any]) -> set[str]: + """Get instrument names from dataset attributes.""" + return attrs.get("instruments", set()) -def normalize_sensor_name(sensor: str) -> str: - """Normalize sensor name for internal usage.""" - return sensor.replace("-", "").replace(" ", "_").replace("/", "-").lower() +def normalize_instrument_name(instrument: str) -> str: + """Normalize instrument name for internal usage.""" + return instrument.replace("-", "").replace(" ", "_").replace("/", "-").lower() -def get_one_sensor_from_attrs(attrs: dict[str,Any]) -> str: - """Get a single sensor name from dataset attributes.""" - sensors = get_sensors_from_attrs(attrs) - if not sensors: - raise KeyError("No 'sensor' dataset attribute") - if len(sensors) > 1: - logger.warning(f"More than one sensor in dataset attributes, will use the first value: {sensors}") - return list(sensors)[0] +def get_one_instrument_from_attrs(attrs: dict[str,Any]) -> str: + """Get a single instrument name from dataset attributes.""" + instruments = get_instruments_from_attrs(attrs) + if not instruments: + raise KeyError("No 'instruments' dataset attribute") + if len(instruments) > 1: + logger.warning(f"More than one instrument in dataset attributes, will use the first value: {instruments}") + return list(instruments)[0] -def get_pyspectral_sensor_name(sensor: str) -> str: - """Get sensor name expected by pyspectral.""" - return normalize_sensor_name(sensor) +def get_pyspectral_instrument_name(instrument: str) -> str: + """Get instrument name expected by pyspectral.""" + return normalize_instrument_name(instrument) -def serialize_sensors(sensors: set[str]) -> str: - """Serialize a set of sensors.""" +def serialize_instruments(instruments: set[str]) -> str: + """Serialize a set of instruments.""" return "-".join( - sensor.replace("-", "").replace(" ", "").replace("/", "").lower() - for sensor in sorted(sensors) + instr.replace("-", "").replace(" ", "").replace("/", "").lower() + for instr in sorted(instruments) ) diff --git a/satpy/writers/core/base.py b/satpy/writers/core/base.py index f8c1c1616e..056b7732b8 100644 --- a/satpy/writers/core/base.py +++ b/satpy/writers/core/base.py @@ -24,7 +24,7 @@ from satpy.aux_download import DataDownloadMixin from satpy.plugin_base import Plugin -from satpy.utils import serialize_sensors +from satpy.utils import serialize_instruments from satpy.writers.core.compute import compute_writer_results, split_results if typing.TYPE_CHECKING: @@ -139,7 +139,7 @@ def create_filename_parser(self, base_dir): @staticmethod def _prepare_metadata_for_filename_formatting(attrs): with contextlib.suppress(KeyError): - attrs["sensor"] = serialize_sensors(attrs["sensor"]) + attrs["instruments"] = serialize_instruments(attrs["instruments"]) def get_filename(self, **kwargs): """Create a filename where output data will be saved. diff --git a/satpy/writers/mitiff.py b/satpy/writers/mitiff.py index 0ed78e1a6a..b8a38c955d 100644 --- a/satpy/writers/mitiff.py +++ b/satpy/writers/mitiff.py @@ -28,7 +28,7 @@ from satpy.dataset import DataID, DataQuery from satpy.enhancements.enhancer import get_enhanced_image -from satpy.utils import get_one_sensor_from_attrs +from satpy.utils import get_one_instrument_from_attrs from satpy.writers.core.image import ImageWriter if typing.TYPE_CHECKING: @@ -56,7 +56,7 @@ def _adjust_kwargs(dataset, kwargs): if "sensor" not in kwargs: # MITIFFs needing to handle sensor can only have one sensor # Assume the first value of set as the sensor. - kwargs["sensor"] = get_one_sensor_from_attrs(dataset.attrs) + kwargs["sensor"] = get_one_instrument_from_attrs(dataset.attrs) class MITIFFWriter(ImageWriter): From 9633197b1fce4d3bcc9f93de78dd9f0f9cb4f829 Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Wed, 6 May 2026 10:58:39 +0000 Subject: [PATCH 11/37] Add instrument attribute setter --- satpy/_config.py | 1 + satpy/composites/aux_data.py | 3 ++- satpy/composites/core.py | 4 ++-- satpy/composites/fill.py | 6 ++++-- satpy/enhancements/enhancer.py | 13 ++++++++++--- satpy/utils.py | 16 +++++++++++++++- satpy/writers/core/base.py | 6 ++++-- 7 files changed, 38 insertions(+), 11 deletions(-) diff --git a/satpy/_config.py b/satpy/_config.py index 47012742d6..0b2e6a57c0 100644 --- a/satpy/_config.py +++ b/satpy/_config.py @@ -54,6 +54,7 @@ "readers": { "clip_negative_radiances": False, }, + "instruments_key": "sensor" } # Satpy main configuration object diff --git a/satpy/composites/aux_data.py b/satpy/composites/aux_data.py index d5d74afb45..406a451295 100644 --- a/satpy/composites/aux_data.py +++ b/satpy/composites/aux_data.py @@ -23,6 +23,7 @@ import satpy from satpy.aux_download import DataDownloadMixin +from satpy.utils import set_instruments_attr from .core import GenericCompositor @@ -156,7 +157,7 @@ def __call__(self, *args, **kwargs): if self.area is None: raise AttributeError("Area definition needs to be configured") img.attrs["area"] = self.area - img.attrs["instruments"] = set() + set_instruments_attr(img.attrs, set()) img.attrs["mode"] = "".join(img.bands.data) img.attrs.pop("modifiers", None) img.attrs.pop("calibration", None) diff --git a/satpy/composites/core.py b/satpy/composites/core.py index 5f7c772a86..85b31d1869 100644 --- a/satpy/composites/core.py +++ b/satpy/composites/core.py @@ -28,7 +28,7 @@ from satpy.dataset import DataID, combine_metadata from satpy.dataset.dataid import minimal_default_keys_config -from satpy.utils import get_instruments_from_attrs, unify_chunks +from satpy.utils import get_instruments_from_attrs, set_instruments_attr, unify_chunks LOG = logging.getLogger(__name__) @@ -503,7 +503,7 @@ def _get_updated_attrs(self, datasets, attrs, mode): new_attrs.update(self.attrs) if resolution is not None: new_attrs["resolution"] = resolution - new_attrs["instruments"] = self._get_sensors(datasets) + set_instruments_attr(new_attrs, self._get_sensors(datasets)) new_attrs["mode"] = mode return new_attrs diff --git a/satpy/composites/fill.py b/satpy/composites/fill.py index 3b47f975d4..994d6437f1 100644 --- a/satpy/composites/fill.py +++ b/satpy/composites/fill.py @@ -26,6 +26,7 @@ import xarray as xr from satpy.dataset import combine_metadata +from satpy.utils import get_instruments_from_attrs, set_instruments_attr from .core import ( GenericCompositor, @@ -406,9 +407,10 @@ def _combine_metadata_with_mode_and_sensor(self, # 'mode' is no longer valid after we've remove the 'A' # let the base class __call__ determine mode attrs.pop("mode", None) - if attrs.get("instruments") is None: + if not get_instruments_from_attrs(attrs): # sensor can be a set - attrs["instruments"] = self._get_sensors([foreground, background]) + instruments = self._get_sensors([foreground, background]) + set_instruments_attr(attrs, instruments) return attrs @staticmethod diff --git a/satpy/enhancements/enhancer.py b/satpy/enhancements/enhancer.py index 2bf48bf688..291c12c166 100644 --- a/satpy/enhancements/enhancer.py +++ b/satpy/enhancements/enhancer.py @@ -24,7 +24,13 @@ from satpy._config import config_search_paths, get_entry_points_config_dirs from satpy.decision_tree import DecisionTree -from satpy.utils import get_instruments_from_attrs, get_logger, normalize_instrument_name, recursive_dict_update +from satpy.utils import ( + get_instruments_from_attrs, + get_instruments_key, + get_logger, + normalize_instrument_name, + recursive_dict_update, +) LOG = get_logger(__name__) @@ -34,16 +40,17 @@ class EnhancementDecisionTree(DecisionTree): def __init__(self, *decision_dicts, **kwargs): """Init the decision tree.""" + instr_key = get_instruments_key() match_keys = kwargs.pop("match_keys", ("name", "reader", "platform_name", - "instruments", + instr_key, "standard_name", "units", )) self.prefix = kwargs.pop("config_section", "enhancements") - multival_keys = kwargs.pop("multival_keys", ["instruments"]) + multival_keys = kwargs.pop("multival_keys", [instr_key]) super(EnhancementDecisionTree, self).__init__( decision_dicts, match_keys, multival_keys) diff --git a/satpy/utils.py b/satpy/utils.py index 81f55f7cc7..d1f9b31d7e 100644 --- a/satpy/utils.py +++ b/satpy/utils.py @@ -39,6 +39,8 @@ from numpy.typing import ArrayLike, DTypeLike from yaml import BaseLoader, UnsafeLoader +import satpy + _is_logging_on = False TRACE_LEVEL = 5 @@ -944,7 +946,8 @@ def flatten_dict(d, parent_key="", sep="_"): def get_instruments_from_attrs(attrs: dict[str,Any]) -> set[str]: """Get instrument names from dataset attributes.""" - return attrs.get("instruments", set()) + key = get_instruments_key() + return attrs.get(key, set()) def normalize_instrument_name(instrument: str) -> str: @@ -973,3 +976,14 @@ def serialize_instruments(instruments: set[str]) -> str: instr.replace("-", "").replace(" ", "").replace("/", "").lower() for instr in sorted(instruments) ) + + +def set_instruments_attr(attrs: dict[str,Any], instruments: set[str]|str) -> None: + """Set 'instruments' dataset atrribute.""" + key = get_instruments_key() + attrs[key] = instruments + + +def get_instruments_key(): + """Get key for instruments in dataset attributes.""" + return satpy.config.get("instruments_key") diff --git a/satpy/writers/core/base.py b/satpy/writers/core/base.py index 056b7732b8..c2f5f69f3b 100644 --- a/satpy/writers/core/base.py +++ b/satpy/writers/core/base.py @@ -24,7 +24,7 @@ from satpy.aux_download import DataDownloadMixin from satpy.plugin_base import Plugin -from satpy.utils import serialize_instruments +from satpy.utils import get_instruments_from_attrs, serialize_instruments, set_instruments_attr from satpy.writers.core.compute import compute_writer_results, split_results if typing.TYPE_CHECKING: @@ -139,7 +139,9 @@ def create_filename_parser(self, base_dir): @staticmethod def _prepare_metadata_for_filename_formatting(attrs): with contextlib.suppress(KeyError): - attrs["instruments"] = serialize_instruments(attrs["instruments"]) + instruments = get_instruments_from_attrs(attrs) + serialized = serialize_instruments(instruments) + set_instruments_attr(attrs, serialized) def get_filename(self, **kwargs): """Create a filename where output data will be saved. From 1d362c7fceec86b30f6f04599e005e29e805a865 Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Wed, 6 May 2026 11:03:05 +0000 Subject: [PATCH 12/37] Reset ABI name --- satpy/readers/core/abi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/satpy/readers/core/abi.py b/satpy/readers/core/abi.py index d51764dc38..e50cd616c4 100644 --- a/satpy/readers/core/abi.py +++ b/satpy/readers/core/abi.py @@ -111,7 +111,7 @@ def _rename_dims(nc): @property def sensor(self): """Get sensor name for current file handler.""" - return "ABI" + return "abi" def __getitem__(self, item): """Wrap `self.nc[item]` for better floating point precision. From ae1d8e712b0a958ca86d552a544f5e55e302429b Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Wed, 6 May 2026 12:04:14 +0000 Subject: [PATCH 13/37] Convert string type sensors to set for now --- satpy/utils.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/satpy/utils.py b/satpy/utils.py index d1f9b31d7e..c3a745d86b 100644 --- a/satpy/utils.py +++ b/satpy/utils.py @@ -947,7 +947,13 @@ def flatten_dict(d, parent_key="", sep="_"): def get_instruments_from_attrs(attrs: dict[str,Any]) -> set[str]: """Get instrument names from dataset attributes.""" key = get_instruments_key() - return attrs.get(key, set()) + try: + instruments = attrs[key] + if isinstance(instruments, str): + return set([instruments]) + return instruments + except KeyError: + return set() def normalize_instrument_name(instrument: str) -> str: From d95a3f3492116fcac882c35a870d780abc81c3ed Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Wed, 6 May 2026 12:04:43 +0000 Subject: [PATCH 14/37] Fix tests --- satpy/tests/compositor_tests/test_aux_data.py | 4 ++-- satpy/tests/compositor_tests/test_core.py | 10 +++++----- satpy/tests/reader_tests/test_abi_l1b.py | 2 +- satpy/tests/scene_tests/test_load.py | 20 +++++++++---------- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/satpy/tests/compositor_tests/test_aux_data.py b/satpy/tests/compositor_tests/test_aux_data.py index 255231703e..d9e3657c66 100644 --- a/satpy/tests/compositor_tests/test_aux_data.py +++ b/satpy/tests/compositor_tests/test_aux_data.py @@ -83,7 +83,7 @@ def load(self, arg): filenames=[IMAGE_FILENAME]) register.assert_not_called() retrieve.assert_not_called() - assert res.attrs["sensor"] is None + assert res.attrs["sensor"] == set() assert "modifiers" not in res.attrs assert "calibration" not in res.attrs @@ -95,7 +95,7 @@ def load(self, arg): res = comp() Scene.assert_called_once_with(reader="generic_image", filenames=[os.path.join("data_dir", "foo.tif")]) - assert res.attrs["sensor"] is None + assert res.attrs["sensor"] == set() assert "modifiers" not in res.attrs assert "calibration" not in res.attrs diff --git a/satpy/tests/compositor_tests/test_core.py b/satpy/tests/compositor_tests/test_core.py index 315431aa4c..55f699aa42 100644 --- a/satpy/tests/compositor_tests/test_core.py +++ b/satpy/tests/compositor_tests/test_core.py @@ -277,13 +277,13 @@ def test_concat_datasets(self): def test_get_sensors(self): """Test getting sensors from the dataset attributes.""" res = self.comp._get_sensors([self.all_valid]) - assert res is None + assert res == set() dset1 = self.all_valid dset1.attrs["sensor"] = "foo" res = self.comp._get_sensors([dset1]) - assert res == "foo" + assert res == {"foo"} dset2 = self.first_invalid - dset2.attrs["sensor"] = "bar" + dset2.attrs["sensor"] = {"bar"} res = self.comp._get_sensors([dset1, dset2]) assert "foo" in res assert "bar" in res @@ -327,12 +327,12 @@ def test_call(self): """Test calling generic compositor.""" # Multiple datasets with extra attributes all_valid = self.all_valid - all_valid.attrs["sensor"] = "foo" + all_valid.attrs["sensor"] = {"foo"} attrs = {"foo": "bar", "resolution": 333} self.comp.attrs["resolution"] = None res = self.comp([self.all_valid, self.first_invalid], **attrs) # Verify attributes - assert res.attrs.get("sensor") == "foo" + assert res.attrs.get("sensor") == {"foo"} assert "foo" in res.attrs assert res.attrs.get("foo") == "bar" assert "units" not in res.attrs diff --git a/satpy/tests/reader_tests/test_abi_l1b.py b/satpy/tests/reader_tests/test_abi_l1b.py index bcb6d35041..855265dd21 100644 --- a/satpy/tests/reader_tests/test_abi_l1b.py +++ b/satpy/tests/reader_tests/test_abi_l1b.py @@ -371,7 +371,7 @@ def test_get_dataset(self, c01_data_arr): "scan_mode": "M4", "scene_abbr": "C", "scene_id": None, - "sensor": "abi", + "sensor": {"abi"}, "timeline_ID": None, "suffix": "suffix", "units": "W m-2 um-1 sr-1", diff --git a/satpy/tests/scene_tests/test_load.py b/satpy/tests/scene_tests/test_load.py index f3e862a46c..f39b416c8b 100644 --- a/satpy/tests/scene_tests/test_load.py +++ b/satpy/tests/scene_tests/test_load.py @@ -141,7 +141,7 @@ def test_available_when_sensor_none_in_preloaded_dataarrays(self): doesn't break available composite IDs. """ - scene = _scene_with_data_array_none_sensor() + scene = _scene_with_data_array_empty_sensor() available_comp_ids = scene.available_composite_ids() assert make_cid(name="static_image") in available_comp_ids @@ -645,31 +645,31 @@ def test_load_too_many(self): assert len(avail_comps) == 1 pytest.raises(KeyError, scene.load, [0.21]) - def test_load_when_sensor_none_in_preloaded_dataarrays(self): - """Test Scene loading when existing loaded arrays have sensor set to None. + def test_load_when_sensor_empty_in_preloaded_dataarrays(self): + """Test Scene loading when existing loaded arrays have empty sensor set. Some readers or composites (ex. static images) don't have a sensor and - developers choose to set it to `None`. This test makes sure this + developers choose to set it to `set()`. This test makes sure this doesn't break loading. """ - scene = _scene_with_data_array_none_sensor() + scene = _scene_with_data_array_empty_sensor() scene.load(["static_image"]) assert "static_image" in scene assert "my_data" in scene -def _scene_with_data_array_none_sensor(): +def _scene_with_data_array_empty_sensor(): scene = Scene(filenames=["fake1_1.txt"], reader="fake1") - scene["my_data"] = _data_array_none_sensor("my_data") + scene["my_data"] = _data_array_with_empty_sensor("my_data") return scene -def _data_array_none_sensor(name: str) -> xr.DataArray: - """Create a DataArray with sensor set to ``None``.""" +def _data_array_with_empty_sensor(name: str) -> xr.DataArray: + """Create a DataArray with empty sensor set.""" return xr.DataArray( da.zeros((2, 2)), attrs={ "name": name, - "sensor": None, + "sensor": set(), }) From 2107f44cb32206b7622db402bb3deaa99dd79095 Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Wed, 6 May 2026 14:41:03 +0000 Subject: [PATCH 15/37] Update docstring --- satpy/utils.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/satpy/utils.py b/satpy/utils.py index c3a745d86b..96fc65869d 100644 --- a/satpy/utils.py +++ b/satpy/utils.py @@ -945,7 +945,12 @@ def flatten_dict(d, parent_key="", sep="_"): def get_instruments_from_attrs(attrs: dict[str,Any]) -> set[str]: - """Get instrument names from dataset attributes.""" + """Get instrument names from dataset attributes. + + String type attributes are converted to set. This can be + removed once all file handlers provide instruments as a + set. + """ key = get_instruments_key() try: instruments = attrs[key] From 78e0de97b39bcf1e9efc152069289def6f74fc4b Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Wed, 6 May 2026 14:41:10 +0000 Subject: [PATCH 16/37] Restore accidental string replace --- satpy/dependency_tree.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/satpy/dependency_tree.py b/satpy/dependency_tree.py index 59da567c96..97777a71e9 100644 --- a/satpy/dependency_tree.py +++ b/satpy/dependency_tree.py @@ -514,7 +514,7 @@ def get_modifier(self, comp_id): mloader, moptions = modifiers[modifier] moptions = moptions.copy() moptions.update(comp_id.to_dict()) - moptions["instrument"] = sensor_name + moptions["sensor"] = sensor_name compositors[comp_id] = mloader(_satpy_id=comp_id, **moptions) return compositors[comp_id] From 32a8e024b7508ab44ab557782f04cbc4dc282c3b Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Thu, 7 May 2026 09:39:50 +0000 Subject: [PATCH 17/37] Replace direct attribute access with setter --- satpy/composites/glm.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/satpy/composites/glm.py b/satpy/composites/glm.py index ceeef76e04..e5d1000269 100644 --- a/satpy/composites/glm.py +++ b/satpy/composites/glm.py @@ -22,6 +22,7 @@ import xarray as xr from satpy.enhancements.enhancer import get_enhanced_image +from satpy.utils import set_instruments_attr from .core import GenericCompositor @@ -96,9 +97,7 @@ def _update_attrs(self, new_data, background_layer, highlight_layer): new_data.attrs = background_layer.attrs.copy() new_data.attrs["units"] = 1 new_sensors = self._get_sensors((highlight_layer, background_layer)) - new_data.attrs.update({ - "instruments": new_sensors, - }) + set_instruments_attr(new_data.attrs, new_sensors) def __call__(self, projectables, optional_datasets=None, **attrs): """Create RGBA image with highlighted pixels.""" From e4a53b6f167cc6649333821d36be440c64e64724 Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Thu, 7 May 2026 10:18:50 +0000 Subject: [PATCH 18/37] Restore enhancer keyword arguments --- satpy/enhancements/enhancer.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/satpy/enhancements/enhancer.py b/satpy/enhancements/enhancer.py index 291c12c166..b0680c9bfb 100644 --- a/satpy/enhancements/enhancer.py +++ b/satpy/enhancements/enhancer.py @@ -26,7 +26,6 @@ from satpy.decision_tree import DecisionTree from satpy.utils import ( get_instruments_from_attrs, - get_instruments_key, get_logger, normalize_instrument_name, recursive_dict_update, @@ -40,17 +39,16 @@ class EnhancementDecisionTree(DecisionTree): def __init__(self, *decision_dicts, **kwargs): """Init the decision tree.""" - instr_key = get_instruments_key() match_keys = kwargs.pop("match_keys", ("name", "reader", "platform_name", - instr_key, + "sensor", "standard_name", "units", )) self.prefix = kwargs.pop("config_section", "enhancements") - multival_keys = kwargs.pop("multival_keys", [instr_key]) + multival_keys = kwargs.pop("multival_keys", ["sensor"]) super(EnhancementDecisionTree, self).__init__( decision_dicts, match_keys, multival_keys) From db7731ddedf59ca72822c16f15e607af1ccabc5f Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Thu, 7 May 2026 12:01:08 +0000 Subject: [PATCH 19/37] Add deprecation warnings for sensor attribute --- satpy/utils.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/satpy/utils.py b/satpy/utils.py index 96fc65869d..f051d7d7fe 100644 --- a/satpy/utils.py +++ b/satpy/utils.py @@ -951,14 +951,26 @@ def get_instruments_from_attrs(attrs: dict[str,Any]) -> set[str]: removed once all file handlers provide instruments as a set. """ - key = get_instruments_key() - try: - instruments = attrs[key] - if isinstance(instruments, str): - return set([instruments]) - return instruments - except KeyError: - return set() + legacy = attrs.get("sensor", set()) + instruments = attrs.get("instruments", legacy) + if legacy: + warnings.warn( + "Satpy will ignore the 'sensor' attribute as of v1.1. " + "Use the 'instruments' attribute instead.", + DeprecationWarning, + stacklevel=2 + ) + if isinstance(instruments, str): + warnings.warn( + "Converting 'instruments' attribute from string to set. " + "This will result in an error in v1.1, when Satpy will require " + "set type instruments attributes.", + DeprecationWarning, + stacklevel=2 + ) + instruments = set([instruments]) + return instruments + def normalize_instrument_name(instrument: str) -> str: @@ -970,7 +982,7 @@ def get_one_instrument_from_attrs(attrs: dict[str,Any]) -> str: """Get a single instrument name from dataset attributes.""" instruments = get_instruments_from_attrs(attrs) if not instruments: - raise KeyError("No 'instruments' dataset attribute") + raise KeyError("No 'instruments' in dataset attribute") if len(instruments) > 1: logger.warning(f"More than one instrument in dataset attributes, will use the first value: {instruments}") return list(instruments)[0] From 8d50a8d9525969727f22a758982464c94937f72c Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Thu, 7 May 2026 12:02:00 +0000 Subject: [PATCH 20/37] Add tests for instrument utilities --- satpy/tests/test_utils.py | 69 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/satpy/tests/test_utils.py b/satpy/tests/test_utils.py index fbb917b948..2c69479a56 100644 --- a/satpy/tests/test_utils.py +++ b/satpy/tests/test_utils.py @@ -28,14 +28,20 @@ import pytest import xarray as xr +import satpy from satpy.utils import ( angle2xyz, datetime64_to_pydatetime, + get_instruments_from_attrs, get_legacy_chunk_size, + get_one_instrument_from_attrs, get_satpos, import_error_helper, lonlat2xyz, + normalize_instrument_name, proj_units_to_meters, + serialize_instruments, + set_instruments_attr, xyz2angle, xyz2lonlat, ) @@ -662,3 +668,66 @@ def test_flatten_dict(): "b_d_e": 1, "b_d_f_g": [1, 2]} assert flatten_dict(d) == expected + + +class TestInstrumentsUtils: + """Test instruments attribute utilities.""" + + @pytest.mark.parametrize( + ("attrs", "expected"), + [ + ({"instruments": {"myinstr"}}, {"myinstr"}), + ({}, set()), + ] + ) + def test_get_instruments_from_attrs(self, attrs, expected): + """Test getting instruments from dataset attributes.""" + assert get_instruments_from_attrs(attrs) == expected + + @pytest.mark.parametrize( + ("attrs", "expected"), + [ + ({"sensor": "myinstr"}, {"myinstr"}), + ({"sensor": {"myinstr"}}, {"myinstr"}), + ({"instruments": "myinstr"}, {"myinstr"}), + ] + ) + def test_get_instruments_from_attrs_with_warning(self, attrs, expected): + """Test deprecation warnings when getting instruments.""" + with pytest.warns(DeprecationWarning, match="v1.1"): + assert get_instruments_from_attrs(attrs) == expected + + def test_normalize_instrument_name(self): + """Test instrument name normalization.""" + instr = "My Instrument-123/1" + expected = "my_instrument123-1" + assert normalize_instrument_name(instr) == expected + + def test_serialize_instruments(self): + """Test instrument set serialization.""" + instruments = {"My Instrument-123/1", "ABI"} + expected = "abi-myinstrument1231" + assert serialize_instruments(instruments) == expected + + def test_set_instruments_attr(self): + """Test setting instruments attribute.""" + attrs = {"instruments": {"myinstrument"}} + new_instruments = {"i1", "i2"} + with satpy.config.set(instruments_key="instruments"): + set_instruments_attr(attrs, new_instruments) + assert attrs["instruments"] == new_instruments + + def test_get_one_instrument_from_attrs(self): + """Test getting a single instrument from dataset attributes.""" + attrs = {"instruments": {"i1"}} + with satpy.config.set(instruments_key="instruments"): + assert get_one_instrument_from_attrs(attrs) == "i1" + + def test_get_one_instrument_from_attrs_with_warning(self, caplog): + """Test warnings when getting a single instrument.""" + attrs = {"instruments": {"i1", "i2"}} + with satpy.config.set(instruments_key="instruments"): + get_one_instrument_from_attrs(attrs) + assert "More than one" in caplog.text + with pytest.raises(KeyError): + get_one_instrument_from_attrs({}) From f0970e39eec8d1b2c288fccc605f8bf6eabdadf1 Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Fri, 8 May 2026 10:13:15 +0000 Subject: [PATCH 21/37] Move instrument helpers to their own module --- satpy/_instruments.py | 91 +++++++++++++++++++++++++++++++ satpy/composites/aux_data.py | 4 +- satpy/composites/config_loader.py | 5 +- satpy/composites/core.py | 7 ++- satpy/composites/fill.py | 6 +- satpy/composites/glm.py | 4 +- satpy/enhancements/enhancer.py | 7 +-- satpy/modifiers/_crefl_utils.py | 8 +-- satpy/modifiers/atmosphere.py | 10 ++-- satpy/modifiers/spectral.py | 6 +- satpy/scene.py | 4 +- satpy/tests/test_instruments.py | 81 +++++++++++++++++++++++++++ satpy/tests/test_utils.py | 69 ----------------------- satpy/utils.py | 70 ------------------------ satpy/writers/core/base.py | 8 +-- satpy/writers/mitiff.py | 4 +- 16 files changed, 209 insertions(+), 175 deletions(-) create mode 100644 satpy/_instruments.py create mode 100644 satpy/tests/test_instruments.py diff --git a/satpy/_instruments.py b/satpy/_instruments.py new file mode 100644 index 0000000000..3c846c2869 --- /dev/null +++ b/satpy/_instruments.py @@ -0,0 +1,91 @@ +# Copyright (c) 2026 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 . +"""Helpers for accessing and modifying instrument attributes.""" + +import logging +import warnings +from typing import Any + +import satpy + +logger = logging.getLogger(__name__) + +def get_instruments_from_attrs(attrs: dict[str,Any]) -> set[str]: + """Get instrument names from dataset attributes. + + String type attributes are converted to set. This can be + removed once all file handlers provide instruments as a + set. + """ + legacy = attrs.get("sensor", set()) + instruments = attrs.get("instruments", legacy) + if legacy: + warnings.warn( + "Satpy will ignore the 'sensor' attribute as of v1.1. " + "Use the 'instruments' attribute instead.", + DeprecationWarning, + stacklevel=2 + ) + if isinstance(instruments, str): + warnings.warn( + "Converting 'instruments' attribute from string to set. " + "This will result in an error in v1.1, when Satpy will require " + "set type instruments attributes.", + DeprecationWarning, + stacklevel=2 + ) + instruments = set([instruments]) + return instruments + + + +def normalize_instrument_name(instrument: str) -> str: + """Normalize instrument name for internal usage.""" + return instrument.replace("-", "").replace(" ", "_").replace("/", "-").lower() + + +def get_one_instrument_from_attrs(attrs: dict[str,Any]) -> str: + """Get a single instrument name from dataset attributes.""" + instruments = get_instruments_from_attrs(attrs) + if not instruments: + raise KeyError("No 'instruments' in dataset attribute") + if len(instruments) > 1: + logger.warning(f"More than one instrument in dataset attributes, will use the first value: {instruments}") + return list(instruments)[0] + + +def get_pyspectral_instrument_name(instrument: str) -> str: + """Get instrument name expected by pyspectral.""" + return normalize_instrument_name(instrument) + + +def serialize_instruments(instruments: set[str]) -> str: + """Serialize a set of instruments.""" + return "-".join( + instr.replace("-", "").replace(" ", "").replace("/", "").lower() + for instr in sorted(instruments) + ) + + +def set_instruments_attr(attrs: dict[str,Any], instruments: set[str]|str) -> None: + """Set 'instruments' dataset atrribute.""" + key = get_instruments_key() + attrs[key] = instruments + + +def get_instruments_key(): + """Get key for instruments in dataset attributes.""" + return satpy.config.get("instruments_key") diff --git a/satpy/composites/aux_data.py b/satpy/composites/aux_data.py index 406a451295..6b1724241f 100644 --- a/satpy/composites/aux_data.py +++ b/satpy/composites/aux_data.py @@ -22,8 +22,8 @@ import os import satpy +import satpy._instruments as instru from satpy.aux_download import DataDownloadMixin -from satpy.utils import set_instruments_attr from .core import GenericCompositor @@ -157,7 +157,7 @@ def __call__(self, *args, **kwargs): if self.area is None: raise AttributeError("Area definition needs to be configured") img.attrs["area"] = self.area - set_instruments_attr(img.attrs, set()) + instru.set_instruments_attr(img.attrs, set()) img.attrs["mode"] = "".join(img.bands.data) img.attrs.pop("modifiers", None) img.attrs.pop("calibration", None) diff --git a/satpy/composites/config_loader.py b/satpy/composites/config_loader.py index 96bdc61b72..e8a1ca192d 100644 --- a/satpy/composites/config_loader.py +++ b/satpy/composites/config_loader.py @@ -28,10 +28,11 @@ from yaml import UnsafeLoader import satpy +import satpy._instruments as instru from satpy import DataID, DataQuery from satpy._config import config_search_paths, get_entry_points_config_dirs, glob_config from satpy.dataset.dataid import minimal_default_keys_config -from satpy.utils import normalize_instrument_name, recursive_dict_update +from satpy.utils import recursive_dict_update logger = logging.getLogger(__name__) @@ -268,7 +269,7 @@ def load_compositor_configs_for_sensor(sensor_name: str) -> tuple[dict[str, dict DataID key -> key properties """ - config_filename = normalize_instrument_name(sensor_name) + ".yaml" + config_filename = instru.normalize_instrument_name(sensor_name) + ".yaml" logger.debug("Looking for composites config file %s", config_filename) paths = get_entry_points_config_dirs("satpy.composites") composite_configs = config_search_paths( diff --git a/satpy/composites/core.py b/satpy/composites/core.py index 85b31d1869..c44c289cd9 100644 --- a/satpy/composites/core.py +++ b/satpy/composites/core.py @@ -26,9 +26,10 @@ import numpy as np import xarray as xr +import satpy._instruments as instru from satpy.dataset import DataID, combine_metadata from satpy.dataset.dataid import minimal_default_keys_config -from satpy.utils import get_instruments_from_attrs, set_instruments_attr, unify_chunks +from satpy.utils import unify_chunks LOG = logging.getLogger(__name__) @@ -436,7 +437,7 @@ def _concat_datasets(self, projectables, mode): def _get_sensors(self, projectables) -> set[str]: sensors = set() for projectable in projectables: - sensors.update(get_instruments_from_attrs(projectable.attrs)) + sensors.update(instru.get_instruments_from_attrs(projectable.attrs)) return sensors def __call__( @@ -503,7 +504,7 @@ def _get_updated_attrs(self, datasets, attrs, mode): new_attrs.update(self.attrs) if resolution is not None: new_attrs["resolution"] = resolution - set_instruments_attr(new_attrs, self._get_sensors(datasets)) + instru.set_instruments_attr(new_attrs, self._get_sensors(datasets)) new_attrs["mode"] = mode return new_attrs diff --git a/satpy/composites/fill.py b/satpy/composites/fill.py index 994d6437f1..b10c80ab43 100644 --- a/satpy/composites/fill.py +++ b/satpy/composites/fill.py @@ -25,8 +25,8 @@ import numpy as np import xarray as xr +import satpy._instruments as instru from satpy.dataset import combine_metadata -from satpy.utils import get_instruments_from_attrs, set_instruments_attr from .core import ( GenericCompositor, @@ -407,10 +407,10 @@ def _combine_metadata_with_mode_and_sensor(self, # 'mode' is no longer valid after we've remove the 'A' # let the base class __call__ determine mode attrs.pop("mode", None) - if not get_instruments_from_attrs(attrs): + if not instru.get_instruments_from_attrs(attrs): # sensor can be a set instruments = self._get_sensors([foreground, background]) - set_instruments_attr(attrs, instruments) + instru.set_instruments_attr(attrs, instruments) return attrs @staticmethod diff --git a/satpy/composites/glm.py b/satpy/composites/glm.py index e5d1000269..6c5ff5f26f 100644 --- a/satpy/composites/glm.py +++ b/satpy/composites/glm.py @@ -21,8 +21,8 @@ import xarray as xr +import satpy._instruments as instru from satpy.enhancements.enhancer import get_enhanced_image -from satpy.utils import set_instruments_attr from .core import GenericCompositor @@ -97,7 +97,7 @@ def _update_attrs(self, new_data, background_layer, highlight_layer): new_data.attrs = background_layer.attrs.copy() new_data.attrs["units"] = 1 new_sensors = self._get_sensors((highlight_layer, background_layer)) - set_instruments_attr(new_data.attrs, new_sensors) + instru.set_instruments_attr(new_data.attrs, new_sensors) def __call__(self, projectables, optional_datasets=None, **attrs): """Create RGBA image with highlighted pixels.""" diff --git a/satpy/enhancements/enhancer.py b/satpy/enhancements/enhancer.py index b0680c9bfb..a26085c289 100644 --- a/satpy/enhancements/enhancer.py +++ b/satpy/enhancements/enhancer.py @@ -22,12 +22,11 @@ import yaml from yaml import UnsafeLoader +import satpy._instruments as instru from satpy._config import config_search_paths, get_entry_points_config_dirs from satpy.decision_tree import DecisionTree from satpy.utils import ( - get_instruments_from_attrs, get_logger, - normalize_instrument_name, recursive_dict_update, ) @@ -131,7 +130,7 @@ def get_sensor_enhancement_config(self, sensors: set[str]): """Get the sensor-specific config.""" paths = get_entry_points_config_dirs("satpy.enhancements") for sensor_name in sensors: - basename = normalize_instrument_name(sensor_name) + ".yaml" + basename = instru.normalize_instrument_name(sensor_name) + ".yaml" config_fn = os.path.join("enhancements", basename) config_files = config_search_paths(config_fn, search_dirs=paths) # Note: Enhancement configuration files can't overwrite individual @@ -211,7 +210,7 @@ def get_enhanced_image(dataset, enhance=None, overlay=None, decorate=None, if enhancer is None or enhancer.enhancement_tree is None: LOG.debug("No enhancement being applied to dataset") else: - sensors = get_instruments_from_attrs(dataset.attrs) + sensors = instru.get_instruments_from_attrs(dataset.attrs) if sensors: enhancer.add_sensor_enhancements(sensors) enhancer.apply(img, **dataset.attrs) diff --git a/satpy/modifiers/_crefl_utils.py b/satpy/modifiers/_crefl_utils.py index c0d3a1e5c0..5a9c3796a0 100644 --- a/satpy/modifiers/_crefl_utils.py +++ b/satpy/modifiers/_crefl_utils.py @@ -69,8 +69,8 @@ import numpy as np import xarray as xr +import satpy._instruments as instru from satpy.dataset.dataid import WavelengthRange -from satpy.utils import get_one_instrument_from_attrs, normalize_instrument_name LOG = logging.getLogger(__name__) @@ -283,7 +283,7 @@ def run_crefl(refl, :param avg_elevation: average elevation (usually pre-calculated and stored in CMGDEM.hdf) """ - sensor = get_one_instrument_from_attrs(refl.attrs) + sensor = instru.get_one_instrument_from_attrs(refl.attrs) runner_cls = _runner_class_for_sensor(sensor) runner = runner_cls(refl) corr_refl = runner(sensor_azimuth, sensor_zenith, solar_azimuth, solar_zenith, avg_elevation) @@ -350,7 +350,7 @@ def _run_crefl(self, mus, muv, phi, solar_zenith, sensor_zenith, height, coeffs) class _VIIRSMODISCREFLRunner(_CREFLRunner): def _run_crefl(self, mus, muv, phi, solar_zenith, sensor_zenith, height, coeffs): - instrument = get_one_instrument_from_attrs(self._refl.attrs) + instrument = instru.get_one_instrument_from_attrs(self._refl.attrs) return da.map_blocks(_run_crefl, self._refl.data, mus.data, muv.data, phi.data, height, instrument, *coeffs, meta=np.ndarray((), dtype=self._refl.dtype), @@ -387,7 +387,7 @@ def _run_crefl(self, mus, muv, phi, solar_zenith, sensor_zenith, height, coeffs) def _runner_class_for_sensor(sensor_name: str) -> Type[_CREFLRunner]: try: - return _SENSOR_TO_RUNNER[normalize_instrument_name(sensor_name)] + return _SENSOR_TO_RUNNER[instru.normalize_instrument_name(sensor_name)] except KeyError: raise NotImplementedError(f"Don't know how to apply CREFL to data from sensor {sensor_name}.") diff --git a/satpy/modifiers/atmosphere.py b/satpy/modifiers/atmosphere.py index dbb8870da7..de68dbd976 100644 --- a/satpy/modifiers/atmosphere.py +++ b/satpy/modifiers/atmosphere.py @@ -23,10 +23,10 @@ import numpy as np import xarray as xr +import satpy._instruments as instru from satpy.modifiers import ModifierBase from satpy.modifiers._crefl import ReflectanceCorrector # noqa from satpy.modifiers.angles import compute_relative_azimuth, get_angles, get_satellite_zenith_angle -from satpy.utils import get_one_instrument_from_attrs, get_pyspectral_instrument_name logger = logging.getLogger(__name__) @@ -105,8 +105,8 @@ def __call__(self, projectables, optional_datasets=None, **info): logger.info("Removing Rayleigh scattering with atmosphere '%s' and " "aerosol type '%s' for '%s'", atmosphere, aerosol_type, vis.attrs["name"]) - sensor = get_pyspectral_instrument_name( - get_one_instrument_from_attrs(vis.attrs) + sensor = instru.get_pyspectral_instrument_name( + instru.get_one_instrument_from_attrs(vis.attrs) ) corrector = Rayleigh(vis.attrs["platform_name"], sensor, atmosphere=atmosphere, @@ -162,8 +162,8 @@ def __call__(self, projectables, optional_datasets=None, **info): satz = satz.data # get dask array underneath logger.info("Correction for limb cooling") - sensor = get_pyspectral_instrument_name( - get_one_instrument_from_attrs(band.attrs) + sensor = instru.get_pyspectral_instrument_name( + instru.get_one_instrument_from_attrs(band.attrs) ) corrector = AtmosphericalCorrection(band.attrs["platform_name"], sensor) diff --git a/satpy/modifiers/spectral.py b/satpy/modifiers/spectral.py index 67562f19e9..3a37aa8579 100644 --- a/satpy/modifiers/spectral.py +++ b/satpy/modifiers/spectral.py @@ -21,8 +21,8 @@ import xarray as xr +import satpy._instruments as instru from satpy.modifiers import ModifierBase -from satpy.utils import get_one_instrument_from_attrs, get_pyspectral_instrument_name try: from pyspectral.near_infrared_reflectance import Calculator @@ -132,8 +132,8 @@ def _init_reflectance_calculator(self, metadata): if not Calculator: logger.info("Couldn't load pyspectral") raise ImportError("No module named pyspectral.near_infrared_reflectance") - sensor = get_pyspectral_instrument_name( - get_one_instrument_from_attrs(metadata) + sensor = instru.get_pyspectral_instrument_name( + instru.get_one_instrument_from_attrs(metadata) ) reflectance_3x_calculator = Calculator(metadata["platform_name"], sensor, metadata["name"], sunz_threshold=self.sun_zenith_threshold, diff --git a/satpy/scene.py b/satpy/scene.py index 30452ee989..f43757a4a4 100644 --- a/satpy/scene.py +++ b/satpy/scene.py @@ -29,6 +29,7 @@ from pyresample.geometry import AreaDefinition, BaseDefinition, CoordinateDefinition, SwathDefinition from xarray import DataArray +import satpy._instruments as instru from satpy.area import get_area_def from satpy.composites.config_loader import load_compositor_configs_for_sensors from satpy.composites.core import IncompatibleAreas @@ -38,7 +39,6 @@ from satpy.readers.core.loading import load_readers from satpy.utils import ( convert_remote_files_to_fsspec, - get_instruments_from_attrs, get_storage_options_from_reader_kwargs, ) @@ -201,7 +201,7 @@ def sensor_names(self) -> set[str]: def _contained_sensor_names(self) -> set[str]: sensor_names = set() for data_arr in self.values(): - sensor_names.update(get_instruments_from_attrs(data_arr.attrs)) + sensor_names.update(instru.get_instruments_from_attrs(data_arr.attrs)) return sensor_names @property diff --git a/satpy/tests/test_instruments.py b/satpy/tests/test_instruments.py new file mode 100644 index 0000000000..473aad6a9d --- /dev/null +++ b/satpy/tests/test_instruments.py @@ -0,0 +1,81 @@ +# Copyright (c) 2026 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 instrument helpers.""" + +import pytest + +import satpy +import satpy._instruments as instru + + +@pytest.mark.parametrize( + ("attrs", "expected"), + [ + ({"instruments": {"myinstr"}}, {"myinstr"}), + ({}, set()), + ] +) +def test_get_instruments_from_attrs(attrs, expected): + """Test getting instruments from dataset attributes.""" + assert instru.get_instruments_from_attrs(attrs) == expected + +@pytest.mark.parametrize( + ("attrs", "expected"), + [ + ({"sensor": "myinstr"}, {"myinstr"}), + ({"sensor": {"myinstr"}}, {"myinstr"}), + ({"instruments": "myinstr"}, {"myinstr"}), + ] +) +def test_get_instruments_from_attrs_with_warning(attrs, expected): + """Test deprecation warnings when getting instruments.""" + with pytest.warns(DeprecationWarning, match="v1.1"): + assert instru.get_instruments_from_attrs(attrs) == expected + +def test_normalize_instrument_name(): + """Test instrument name normalization.""" + instr = "My Instrument-123/1" + expected = "my_instrument123-1" + assert instru.normalize_instrument_name(instr) == expected + +def test_serialize_instruments(): + """Test instrument set serialization.""" + instruments = {"My Instrument-123/1", "ABI"} + expected = "abi-myinstrument1231" + assert instru.serialize_instruments(instruments) == expected + +def test_set_instruments_attr(): + """Test setting instruments attribute.""" + attrs = {"instruments": {"myinstrument"}} + new_instruments = {"i1", "i2"} + with satpy.config.set(instruments_key="instruments"): + instru.set_instruments_attr(attrs, new_instruments) + assert attrs["instruments"] == new_instruments + +def test_get_one_instrument_from_attrs(): + """Test getting a single instrument from dataset attributes.""" + attrs = {"instruments": {"i1"}} + with satpy.config.set(instruments_key="instruments"): + assert instru.get_one_instrument_from_attrs(attrs) == "i1" + +def test_get_one_instrument_from_attrs_with_warning(caplog): + """Test warnings when getting a single instrument.""" + attrs = {"instruments": {"i1", "i2"}} + with satpy.config.set(instruments_key="instruments"): + instru.get_one_instrument_from_attrs(attrs) + assert "More than one" in caplog.text + with pytest.raises(KeyError): + instru.get_one_instrument_from_attrs({}) diff --git a/satpy/tests/test_utils.py b/satpy/tests/test_utils.py index 2c69479a56..fbb917b948 100644 --- a/satpy/tests/test_utils.py +++ b/satpy/tests/test_utils.py @@ -28,20 +28,14 @@ import pytest import xarray as xr -import satpy from satpy.utils import ( angle2xyz, datetime64_to_pydatetime, - get_instruments_from_attrs, get_legacy_chunk_size, - get_one_instrument_from_attrs, get_satpos, import_error_helper, lonlat2xyz, - normalize_instrument_name, proj_units_to_meters, - serialize_instruments, - set_instruments_attr, xyz2angle, xyz2lonlat, ) @@ -668,66 +662,3 @@ def test_flatten_dict(): "b_d_e": 1, "b_d_f_g": [1, 2]} assert flatten_dict(d) == expected - - -class TestInstrumentsUtils: - """Test instruments attribute utilities.""" - - @pytest.mark.parametrize( - ("attrs", "expected"), - [ - ({"instruments": {"myinstr"}}, {"myinstr"}), - ({}, set()), - ] - ) - def test_get_instruments_from_attrs(self, attrs, expected): - """Test getting instruments from dataset attributes.""" - assert get_instruments_from_attrs(attrs) == expected - - @pytest.mark.parametrize( - ("attrs", "expected"), - [ - ({"sensor": "myinstr"}, {"myinstr"}), - ({"sensor": {"myinstr"}}, {"myinstr"}), - ({"instruments": "myinstr"}, {"myinstr"}), - ] - ) - def test_get_instruments_from_attrs_with_warning(self, attrs, expected): - """Test deprecation warnings when getting instruments.""" - with pytest.warns(DeprecationWarning, match="v1.1"): - assert get_instruments_from_attrs(attrs) == expected - - def test_normalize_instrument_name(self): - """Test instrument name normalization.""" - instr = "My Instrument-123/1" - expected = "my_instrument123-1" - assert normalize_instrument_name(instr) == expected - - def test_serialize_instruments(self): - """Test instrument set serialization.""" - instruments = {"My Instrument-123/1", "ABI"} - expected = "abi-myinstrument1231" - assert serialize_instruments(instruments) == expected - - def test_set_instruments_attr(self): - """Test setting instruments attribute.""" - attrs = {"instruments": {"myinstrument"}} - new_instruments = {"i1", "i2"} - with satpy.config.set(instruments_key="instruments"): - set_instruments_attr(attrs, new_instruments) - assert attrs["instruments"] == new_instruments - - def test_get_one_instrument_from_attrs(self): - """Test getting a single instrument from dataset attributes.""" - attrs = {"instruments": {"i1"}} - with satpy.config.set(instruments_key="instruments"): - assert get_one_instrument_from_attrs(attrs) == "i1" - - def test_get_one_instrument_from_attrs_with_warning(self, caplog): - """Test warnings when getting a single instrument.""" - attrs = {"instruments": {"i1", "i2"}} - with satpy.config.set(instruments_key="instruments"): - get_one_instrument_from_attrs(attrs) - assert "More than one" in caplog.text - with pytest.raises(KeyError): - get_one_instrument_from_attrs({}) diff --git a/satpy/utils.py b/satpy/utils.py index f051d7d7fe..bdf9d77e4c 100644 --- a/satpy/utils.py +++ b/satpy/utils.py @@ -39,8 +39,6 @@ from numpy.typing import ArrayLike, DTypeLike from yaml import BaseLoader, UnsafeLoader -import satpy - _is_logging_on = False TRACE_LEVEL = 5 @@ -942,71 +940,3 @@ def flatten_dict(d, parent_key="", sep="_"): else: items.append((new_key, v)) return dict(items) - - -def get_instruments_from_attrs(attrs: dict[str,Any]) -> set[str]: - """Get instrument names from dataset attributes. - - String type attributes are converted to set. This can be - removed once all file handlers provide instruments as a - set. - """ - legacy = attrs.get("sensor", set()) - instruments = attrs.get("instruments", legacy) - if legacy: - warnings.warn( - "Satpy will ignore the 'sensor' attribute as of v1.1. " - "Use the 'instruments' attribute instead.", - DeprecationWarning, - stacklevel=2 - ) - if isinstance(instruments, str): - warnings.warn( - "Converting 'instruments' attribute from string to set. " - "This will result in an error in v1.1, when Satpy will require " - "set type instruments attributes.", - DeprecationWarning, - stacklevel=2 - ) - instruments = set([instruments]) - return instruments - - - -def normalize_instrument_name(instrument: str) -> str: - """Normalize instrument name for internal usage.""" - return instrument.replace("-", "").replace(" ", "_").replace("/", "-").lower() - - -def get_one_instrument_from_attrs(attrs: dict[str,Any]) -> str: - """Get a single instrument name from dataset attributes.""" - instruments = get_instruments_from_attrs(attrs) - if not instruments: - raise KeyError("No 'instruments' in dataset attribute") - if len(instruments) > 1: - logger.warning(f"More than one instrument in dataset attributes, will use the first value: {instruments}") - return list(instruments)[0] - - -def get_pyspectral_instrument_name(instrument: str) -> str: - """Get instrument name expected by pyspectral.""" - return normalize_instrument_name(instrument) - - -def serialize_instruments(instruments: set[str]) -> str: - """Serialize a set of instruments.""" - return "-".join( - instr.replace("-", "").replace(" ", "").replace("/", "").lower() - for instr in sorted(instruments) - ) - - -def set_instruments_attr(attrs: dict[str,Any], instruments: set[str]|str) -> None: - """Set 'instruments' dataset atrribute.""" - key = get_instruments_key() - attrs[key] = instruments - - -def get_instruments_key(): - """Get key for instruments in dataset attributes.""" - return satpy.config.get("instruments_key") diff --git a/satpy/writers/core/base.py b/satpy/writers/core/base.py index c2f5f69f3b..2582414475 100644 --- a/satpy/writers/core/base.py +++ b/satpy/writers/core/base.py @@ -22,9 +22,9 @@ import typing import warnings +import satpy._instruments as instru from satpy.aux_download import DataDownloadMixin from satpy.plugin_base import Plugin -from satpy.utils import get_instruments_from_attrs, serialize_instruments, set_instruments_attr from satpy.writers.core.compute import compute_writer_results, split_results if typing.TYPE_CHECKING: @@ -139,9 +139,9 @@ def create_filename_parser(self, base_dir): @staticmethod def _prepare_metadata_for_filename_formatting(attrs): with contextlib.suppress(KeyError): - instruments = get_instruments_from_attrs(attrs) - serialized = serialize_instruments(instruments) - set_instruments_attr(attrs, serialized) + instruments = instru.get_instruments_from_attrs(attrs) + serialized = instru.serialize_instruments(instruments) + instru.set_instruments_attr(attrs, serialized) def get_filename(self, **kwargs): """Create a filename where output data will be saved. diff --git a/satpy/writers/mitiff.py b/satpy/writers/mitiff.py index b8a38c955d..11803ee8e8 100644 --- a/satpy/writers/mitiff.py +++ b/satpy/writers/mitiff.py @@ -26,9 +26,9 @@ import numpy as np from PIL import Image, ImagePalette +import satpy._instruments as instru from satpy.dataset import DataID, DataQuery from satpy.enhancements.enhancer import get_enhanced_image -from satpy.utils import get_one_instrument_from_attrs from satpy.writers.core.image import ImageWriter if typing.TYPE_CHECKING: @@ -56,7 +56,7 @@ def _adjust_kwargs(dataset, kwargs): if "sensor" not in kwargs: # MITIFFs needing to handle sensor can only have one sensor # Assume the first value of set as the sensor. - kwargs["sensor"] = get_one_instrument_from_attrs(dataset.attrs) + kwargs["sensor"] = instru.get_one_instrument_from_attrs(dataset.attrs) class MITIFFWriter(ImageWriter): From 9ca9b79b9d689e94481e568ada6f4f7fc09c8d27 Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Fri, 8 May 2026 11:38:33 +0000 Subject: [PATCH 22/37] Remove explicit sensor usage in dependency tree --- satpy/dependency_tree.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/satpy/dependency_tree.py b/satpy/dependency_tree.py index 97777a71e9..c250987802 100644 --- a/satpy/dependency_tree.py +++ b/satpy/dependency_tree.py @@ -23,6 +23,7 @@ import numpy as np +import satpy._instruments as instru from satpy import DataID, DatasetDict from satpy.dataset import ModifierTuple, create_filtered_query from satpy.dataset.data_dict import TooManyResults, get_key @@ -504,6 +505,7 @@ def get_compositor(self, key): def get_modifier(self, comp_id): """Get a modifer.""" # create a DataID for the compositor we are generating + instr_key = instru.get_instruments_key() modifier = comp_id["modifiers"][-1] for sensor_name in sorted(self.modifiers): modifiers = self.modifiers[sensor_name] @@ -514,7 +516,7 @@ def get_modifier(self, comp_id): mloader, moptions = modifiers[modifier] moptions = moptions.copy() moptions.update(comp_id.to_dict()) - moptions["sensor"] = sensor_name + moptions[instr_key] = sensor_name compositors[comp_id] = mloader(_satpy_id=comp_id, **moptions) return compositors[comp_id] From 6fa9743dc3c8c92d92b23fb10895b41cbd8c067a Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Wed, 20 May 2026 16:34:39 +0000 Subject: [PATCH 23/37] Use better name for instruments module --- satpy/composites/aux_data.py | 4 ++-- satpy/composites/config_loader.py | 4 ++-- satpy/composites/core.py | 6 +++--- satpy/composites/fill.py | 6 +++--- satpy/composites/glm.py | 4 ++-- satpy/dependency_tree.py | 4 ++-- satpy/modifiers/_crefl_utils.py | 8 ++++---- satpy/modifiers/atmosphere.py | 10 +++++----- satpy/modifiers/spectral.py | 6 +++--- satpy/scene.py | 4 ++-- satpy/writers/core/base.py | 8 ++++---- satpy/writers/mitiff.py | 4 ++-- 12 files changed, 34 insertions(+), 34 deletions(-) diff --git a/satpy/composites/aux_data.py b/satpy/composites/aux_data.py index 6b1724241f..10c0eeab50 100644 --- a/satpy/composites/aux_data.py +++ b/satpy/composites/aux_data.py @@ -22,7 +22,7 @@ import os import satpy -import satpy._instruments as instru +import satpy._instruments as inst_utils from satpy.aux_download import DataDownloadMixin from .core import GenericCompositor @@ -157,7 +157,7 @@ def __call__(self, *args, **kwargs): if self.area is None: raise AttributeError("Area definition needs to be configured") img.attrs["area"] = self.area - instru.set_instruments_attr(img.attrs, set()) + inst_utils.set_instruments_attr(img.attrs, set()) img.attrs["mode"] = "".join(img.bands.data) img.attrs.pop("modifiers", None) img.attrs.pop("calibration", None) diff --git a/satpy/composites/config_loader.py b/satpy/composites/config_loader.py index e8a1ca192d..37004a5865 100644 --- a/satpy/composites/config_loader.py +++ b/satpy/composites/config_loader.py @@ -28,7 +28,7 @@ from yaml import UnsafeLoader import satpy -import satpy._instruments as instru +import satpy._instruments as inst_utils from satpy import DataID, DataQuery from satpy._config import config_search_paths, get_entry_points_config_dirs, glob_config from satpy.dataset.dataid import minimal_default_keys_config @@ -269,7 +269,7 @@ def load_compositor_configs_for_sensor(sensor_name: str) -> tuple[dict[str, dict DataID key -> key properties """ - config_filename = instru.normalize_instrument_name(sensor_name) + ".yaml" + config_filename = inst_utils.normalize_instrument_name(sensor_name) + ".yaml" logger.debug("Looking for composites config file %s", config_filename) paths = get_entry_points_config_dirs("satpy.composites") composite_configs = config_search_paths( diff --git a/satpy/composites/core.py b/satpy/composites/core.py index c44c289cd9..e3b4b733e0 100644 --- a/satpy/composites/core.py +++ b/satpy/composites/core.py @@ -26,7 +26,7 @@ import numpy as np import xarray as xr -import satpy._instruments as instru +import satpy._instruments as inst_utils from satpy.dataset import DataID, combine_metadata from satpy.dataset.dataid import minimal_default_keys_config from satpy.utils import unify_chunks @@ -437,7 +437,7 @@ def _concat_datasets(self, projectables, mode): def _get_sensors(self, projectables) -> set[str]: sensors = set() for projectable in projectables: - sensors.update(instru.get_instruments_from_attrs(projectable.attrs)) + sensors.update(inst_utils.get_instruments_from_attrs(projectable.attrs)) return sensors def __call__( @@ -504,7 +504,7 @@ def _get_updated_attrs(self, datasets, attrs, mode): new_attrs.update(self.attrs) if resolution is not None: new_attrs["resolution"] = resolution - instru.set_instruments_attr(new_attrs, self._get_sensors(datasets)) + inst_utils.set_instruments_attr(new_attrs, self._get_sensors(datasets)) new_attrs["mode"] = mode return new_attrs diff --git a/satpy/composites/fill.py b/satpy/composites/fill.py index b10c80ab43..81e02750a1 100644 --- a/satpy/composites/fill.py +++ b/satpy/composites/fill.py @@ -25,7 +25,7 @@ import numpy as np import xarray as xr -import satpy._instruments as instru +import satpy._instruments as inst_utils from satpy.dataset import combine_metadata from .core import ( @@ -407,10 +407,10 @@ def _combine_metadata_with_mode_and_sensor(self, # 'mode' is no longer valid after we've remove the 'A' # let the base class __call__ determine mode attrs.pop("mode", None) - if not instru.get_instruments_from_attrs(attrs): + if not inst_utils.get_instruments_from_attrs(attrs): # sensor can be a set instruments = self._get_sensors([foreground, background]) - instru.set_instruments_attr(attrs, instruments) + inst_utils.set_instruments_attr(attrs, instruments) return attrs @staticmethod diff --git a/satpy/composites/glm.py b/satpy/composites/glm.py index 6c5ff5f26f..c7591e9de3 100644 --- a/satpy/composites/glm.py +++ b/satpy/composites/glm.py @@ -21,7 +21,7 @@ import xarray as xr -import satpy._instruments as instru +import satpy._instruments as inst_utils from satpy.enhancements.enhancer import get_enhanced_image from .core import GenericCompositor @@ -97,7 +97,7 @@ def _update_attrs(self, new_data, background_layer, highlight_layer): new_data.attrs = background_layer.attrs.copy() new_data.attrs["units"] = 1 new_sensors = self._get_sensors((highlight_layer, background_layer)) - instru.set_instruments_attr(new_data.attrs, new_sensors) + inst_utils.set_instruments_attr(new_data.attrs, new_sensors) def __call__(self, projectables, optional_datasets=None, **attrs): """Create RGBA image with highlighted pixels.""" diff --git a/satpy/dependency_tree.py b/satpy/dependency_tree.py index c250987802..19348633a8 100644 --- a/satpy/dependency_tree.py +++ b/satpy/dependency_tree.py @@ -23,7 +23,7 @@ import numpy as np -import satpy._instruments as instru +import satpy._instruments as inst_utils from satpy import DataID, DatasetDict from satpy.dataset import ModifierTuple, create_filtered_query from satpy.dataset.data_dict import TooManyResults, get_key @@ -505,7 +505,7 @@ def get_compositor(self, key): def get_modifier(self, comp_id): """Get a modifer.""" # create a DataID for the compositor we are generating - instr_key = instru.get_instruments_key() + instr_key = inst_utils.get_instruments_key() modifier = comp_id["modifiers"][-1] for sensor_name in sorted(self.modifiers): modifiers = self.modifiers[sensor_name] diff --git a/satpy/modifiers/_crefl_utils.py b/satpy/modifiers/_crefl_utils.py index 5a9c3796a0..ecda921933 100644 --- a/satpy/modifiers/_crefl_utils.py +++ b/satpy/modifiers/_crefl_utils.py @@ -69,7 +69,7 @@ import numpy as np import xarray as xr -import satpy._instruments as instru +import satpy._instruments as inst_utils from satpy.dataset.dataid import WavelengthRange LOG = logging.getLogger(__name__) @@ -283,7 +283,7 @@ def run_crefl(refl, :param avg_elevation: average elevation (usually pre-calculated and stored in CMGDEM.hdf) """ - sensor = instru.get_one_instrument_from_attrs(refl.attrs) + sensor = inst_utils.get_one_instrument_from_attrs(refl.attrs) runner_cls = _runner_class_for_sensor(sensor) runner = runner_cls(refl) corr_refl = runner(sensor_azimuth, sensor_zenith, solar_azimuth, solar_zenith, avg_elevation) @@ -350,7 +350,7 @@ def _run_crefl(self, mus, muv, phi, solar_zenith, sensor_zenith, height, coeffs) class _VIIRSMODISCREFLRunner(_CREFLRunner): def _run_crefl(self, mus, muv, phi, solar_zenith, sensor_zenith, height, coeffs): - instrument = instru.get_one_instrument_from_attrs(self._refl.attrs) + instrument = inst_utils.get_one_instrument_from_attrs(self._refl.attrs) return da.map_blocks(_run_crefl, self._refl.data, mus.data, muv.data, phi.data, height, instrument, *coeffs, meta=np.ndarray((), dtype=self._refl.dtype), @@ -387,7 +387,7 @@ def _run_crefl(self, mus, muv, phi, solar_zenith, sensor_zenith, height, coeffs) def _runner_class_for_sensor(sensor_name: str) -> Type[_CREFLRunner]: try: - return _SENSOR_TO_RUNNER[instru.normalize_instrument_name(sensor_name)] + return _SENSOR_TO_RUNNER[inst_utils.normalize_instrument_name(sensor_name)] except KeyError: raise NotImplementedError(f"Don't know how to apply CREFL to data from sensor {sensor_name}.") diff --git a/satpy/modifiers/atmosphere.py b/satpy/modifiers/atmosphere.py index de68dbd976..0a93418c70 100644 --- a/satpy/modifiers/atmosphere.py +++ b/satpy/modifiers/atmosphere.py @@ -23,7 +23,7 @@ import numpy as np import xarray as xr -import satpy._instruments as instru +import satpy._instruments as inst_utils from satpy.modifiers import ModifierBase from satpy.modifiers._crefl import ReflectanceCorrector # noqa from satpy.modifiers.angles import compute_relative_azimuth, get_angles, get_satellite_zenith_angle @@ -105,8 +105,8 @@ def __call__(self, projectables, optional_datasets=None, **info): logger.info("Removing Rayleigh scattering with atmosphere '%s' and " "aerosol type '%s' for '%s'", atmosphere, aerosol_type, vis.attrs["name"]) - sensor = instru.get_pyspectral_instrument_name( - instru.get_one_instrument_from_attrs(vis.attrs) + sensor = inst_utils.get_pyspectral_instrument_name( + inst_utils.get_one_instrument_from_attrs(vis.attrs) ) corrector = Rayleigh(vis.attrs["platform_name"], sensor, atmosphere=atmosphere, @@ -162,8 +162,8 @@ def __call__(self, projectables, optional_datasets=None, **info): satz = satz.data # get dask array underneath logger.info("Correction for limb cooling") - sensor = instru.get_pyspectral_instrument_name( - instru.get_one_instrument_from_attrs(band.attrs) + sensor = inst_utils.get_pyspectral_instrument_name( + inst_utils.get_one_instrument_from_attrs(band.attrs) ) corrector = AtmosphericalCorrection(band.attrs["platform_name"], sensor) diff --git a/satpy/modifiers/spectral.py b/satpy/modifiers/spectral.py index 3a37aa8579..7c990417e8 100644 --- a/satpy/modifiers/spectral.py +++ b/satpy/modifiers/spectral.py @@ -21,7 +21,7 @@ import xarray as xr -import satpy._instruments as instru +import satpy._instruments as inst_utils from satpy.modifiers import ModifierBase try: @@ -132,8 +132,8 @@ def _init_reflectance_calculator(self, metadata): if not Calculator: logger.info("Couldn't load pyspectral") raise ImportError("No module named pyspectral.near_infrared_reflectance") - sensor = instru.get_pyspectral_instrument_name( - instru.get_one_instrument_from_attrs(metadata) + sensor = inst_utils.get_pyspectral_instrument_name( + inst_utils.get_one_instrument_from_attrs(metadata) ) reflectance_3x_calculator = Calculator(metadata["platform_name"], sensor, metadata["name"], sunz_threshold=self.sun_zenith_threshold, diff --git a/satpy/scene.py b/satpy/scene.py index f43757a4a4..70cfa32c94 100644 --- a/satpy/scene.py +++ b/satpy/scene.py @@ -29,7 +29,7 @@ from pyresample.geometry import AreaDefinition, BaseDefinition, CoordinateDefinition, SwathDefinition from xarray import DataArray -import satpy._instruments as instru +import satpy._instruments as inst_utils from satpy.area import get_area_def from satpy.composites.config_loader import load_compositor_configs_for_sensors from satpy.composites.core import IncompatibleAreas @@ -201,7 +201,7 @@ def sensor_names(self) -> set[str]: def _contained_sensor_names(self) -> set[str]: sensor_names = set() for data_arr in self.values(): - sensor_names.update(instru.get_instruments_from_attrs(data_arr.attrs)) + sensor_names.update(inst_utils.get_instruments_from_attrs(data_arr.attrs)) return sensor_names @property diff --git a/satpy/writers/core/base.py b/satpy/writers/core/base.py index 2582414475..65024dc557 100644 --- a/satpy/writers/core/base.py +++ b/satpy/writers/core/base.py @@ -22,7 +22,7 @@ import typing import warnings -import satpy._instruments as instru +import satpy._instruments as inst_utils from satpy.aux_download import DataDownloadMixin from satpy.plugin_base import Plugin from satpy.writers.core.compute import compute_writer_results, split_results @@ -139,9 +139,9 @@ def create_filename_parser(self, base_dir): @staticmethod def _prepare_metadata_for_filename_formatting(attrs): with contextlib.suppress(KeyError): - instruments = instru.get_instruments_from_attrs(attrs) - serialized = instru.serialize_instruments(instruments) - instru.set_instruments_attr(attrs, serialized) + instruments = inst_utils.get_instruments_from_attrs(attrs) + serialized = inst_utils.serialize_instruments(instruments) + inst_utils.set_instruments_attr(attrs, serialized) def get_filename(self, **kwargs): """Create a filename where output data will be saved. diff --git a/satpy/writers/mitiff.py b/satpy/writers/mitiff.py index 11803ee8e8..25d9656ed3 100644 --- a/satpy/writers/mitiff.py +++ b/satpy/writers/mitiff.py @@ -26,7 +26,7 @@ import numpy as np from PIL import Image, ImagePalette -import satpy._instruments as instru +import satpy._instruments as inst_utils from satpy.dataset import DataID, DataQuery from satpy.enhancements.enhancer import get_enhanced_image from satpy.writers.core.image import ImageWriter @@ -56,7 +56,7 @@ def _adjust_kwargs(dataset, kwargs): if "sensor" not in kwargs: # MITIFFs needing to handle sensor can only have one sensor # Assume the first value of set as the sensor. - kwargs["sensor"] = instru.get_one_instrument_from_attrs(dataset.attrs) + kwargs["sensor"] = inst_utils.get_one_instrument_from_attrs(dataset.attrs) class MITIFFWriter(ImageWriter): From 677a3886562961760cfc8739739762812f3caba4 Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Wed, 20 May 2026 17:14:31 +0000 Subject: [PATCH 24/37] Choose better method names --- satpy/_instruments.py | 31 ++++++++++------- satpy/composites/config_loader.py | 2 +- satpy/enhancements/enhancer.py | 6 ++-- satpy/modifiers/_crefl_utils.py | 2 +- satpy/tests/test_instruments.py | 55 +++++++++++++++++++------------ satpy/writers/core/base.py | 8 ++--- 6 files changed, 62 insertions(+), 42 deletions(-) diff --git a/satpy/_instruments.py b/satpy/_instruments.py index 3c846c2869..b9f0a72b52 100644 --- a/satpy/_instruments.py +++ b/satpy/_instruments.py @@ -23,7 +23,7 @@ logger = logging.getLogger(__name__) -def get_instruments_from_attrs(attrs: dict[str,Any]) -> set[str]: +def get_instruments_from_attrs(attrs: dict[str,Any], to_internal: bool=False) -> set[str]: """Get instrument names from dataset attributes. String type attributes are converted to set. This can be @@ -48,13 +48,25 @@ def get_instruments_from_attrs(attrs: dict[str,Any]) -> set[str]: stacklevel=2 ) instruments = set([instruments]) + if to_internal: + return { + wmo_to_internal(inst) for inst in instruments + } return instruments -def normalize_instrument_name(instrument: str) -> str: - """Normalize instrument name for internal usage.""" - return instrument.replace("-", "").replace(" ", "_").replace("/", "-").lower() +def wmo_to_internal(instrument: str) -> str: + """Convert WMO to internal instrument name.""" + sep_map = { + "-": "-", + "(": "", + ")": "", + " ": "_", + "/": "-" + } + sep_trans = str.maketrans(sep_map) + return instrument.translate(sep_trans).lower() def get_one_instrument_from_attrs(attrs: dict[str,Any]) -> str: @@ -69,15 +81,12 @@ def get_one_instrument_from_attrs(attrs: dict[str,Any]) -> str: def get_pyspectral_instrument_name(instrument: str) -> str: """Get instrument name expected by pyspectral.""" - return normalize_instrument_name(instrument) + return wmo_to_internal(instrument) -def serialize_instruments(instruments: set[str]) -> str: - """Serialize a set of instruments.""" - return "-".join( - instr.replace("-", "").replace(" ", "").replace("/", "").lower() - for instr in sorted(instruments) - ) +def join_instrument_names(instruments: set[str]) -> str: + """Join a set of instrument names.""" + return "-".join(sorted(instruments)) def set_instruments_attr(attrs: dict[str,Any], instruments: set[str]|str) -> None: diff --git a/satpy/composites/config_loader.py b/satpy/composites/config_loader.py index 37004a5865..a33b1bfd06 100644 --- a/satpy/composites/config_loader.py +++ b/satpy/composites/config_loader.py @@ -269,7 +269,7 @@ def load_compositor_configs_for_sensor(sensor_name: str) -> tuple[dict[str, dict DataID key -> key properties """ - config_filename = inst_utils.normalize_instrument_name(sensor_name) + ".yaml" + config_filename = inst_utils.wmo_to_internal(sensor_name) + ".yaml" logger.debug("Looking for composites config file %s", config_filename) paths = get_entry_points_config_dirs("satpy.composites") composite_configs = config_search_paths( diff --git a/satpy/enhancements/enhancer.py b/satpy/enhancements/enhancer.py index a26085c289..8c0f0a7719 100644 --- a/satpy/enhancements/enhancer.py +++ b/satpy/enhancements/enhancer.py @@ -22,7 +22,7 @@ import yaml from yaml import UnsafeLoader -import satpy._instruments as instru +import satpy._instruments as inst_utils from satpy._config import config_search_paths, get_entry_points_config_dirs from satpy.decision_tree import DecisionTree from satpy.utils import ( @@ -130,7 +130,7 @@ def get_sensor_enhancement_config(self, sensors: set[str]): """Get the sensor-specific config.""" paths = get_entry_points_config_dirs("satpy.enhancements") for sensor_name in sensors: - basename = instru.normalize_instrument_name(sensor_name) + ".yaml" + basename = inst_utils.wmo_to_internal(sensor_name) + ".yaml" config_fn = os.path.join("enhancements", basename) config_files = config_search_paths(config_fn, search_dirs=paths) # Note: Enhancement configuration files can't overwrite individual @@ -210,7 +210,7 @@ def get_enhanced_image(dataset, enhance=None, overlay=None, decorate=None, if enhancer is None or enhancer.enhancement_tree is None: LOG.debug("No enhancement being applied to dataset") else: - sensors = instru.get_instruments_from_attrs(dataset.attrs) + sensors = inst_utils.get_instruments_from_attrs(dataset.attrs) if sensors: enhancer.add_sensor_enhancements(sensors) enhancer.apply(img, **dataset.attrs) diff --git a/satpy/modifiers/_crefl_utils.py b/satpy/modifiers/_crefl_utils.py index ecda921933..69322d6a11 100644 --- a/satpy/modifiers/_crefl_utils.py +++ b/satpy/modifiers/_crefl_utils.py @@ -387,7 +387,7 @@ def _run_crefl(self, mus, muv, phi, solar_zenith, sensor_zenith, height, coeffs) def _runner_class_for_sensor(sensor_name: str) -> Type[_CREFLRunner]: try: - return _SENSOR_TO_RUNNER[inst_utils.normalize_instrument_name(sensor_name)] + return _SENSOR_TO_RUNNER[inst_utils.wmo_to_internal(sensor_name)] except KeyError: raise NotImplementedError(f"Don't know how to apply CREFL to data from sensor {sensor_name}.") diff --git a/satpy/tests/test_instruments.py b/satpy/tests/test_instruments.py index 473aad6a9d..53975948af 100644 --- a/satpy/tests/test_instruments.py +++ b/satpy/tests/test_instruments.py @@ -18,19 +18,20 @@ import pytest import satpy -import satpy._instruments as instru +import satpy._instruments as inst_utils @pytest.mark.parametrize( - ("attrs", "expected"), + ("attrs", "to_internal", "expected"), [ - ({"instruments": {"myinstr"}}, {"myinstr"}), - ({}, set()), + ({"instruments": {"AVHRR/3"}}, False, {"AVHRR/3"}), + ({"instruments": {"AVHRR/3"}}, True, {"avhrr-3"}), + ({}, False, set()), ] ) -def test_get_instruments_from_attrs(attrs, expected): +def test_get_instruments_from_attrs(attrs, to_internal, expected): """Test getting instruments from dataset attributes.""" - assert instru.get_instruments_from_attrs(attrs) == expected + assert inst_utils.get_instruments_from_attrs(attrs, to_internal) == expected @pytest.mark.parametrize( ("attrs", "expected"), @@ -43,39 +44,51 @@ def test_get_instruments_from_attrs(attrs, expected): def test_get_instruments_from_attrs_with_warning(attrs, expected): """Test deprecation warnings when getting instruments.""" with pytest.warns(DeprecationWarning, match="v1.1"): - assert instru.get_instruments_from_attrs(attrs) == expected + assert inst_utils.get_instruments_from_attrs(attrs) == expected + + +@pytest.mark.parametrize( + ("instrument", "expected"), + [ + ("AVHRR/3", "avhrr-3"), + ("IMAGER (GOES 8-11)", "imager_goes_8-11"), + ("MERSI-1", "mersi-1"), + ("MSU-GS/A", "msu-gs-a"), + ] +) +def test_wmo_to_internal(instrument, expected): + """Test conversion to internal instrument name.""" + assert inst_utils.wmo_to_internal(instrument) == expected + -def test_normalize_instrument_name(): - """Test instrument name normalization.""" - instr = "My Instrument-123/1" - expected = "my_instrument123-1" - assert instru.normalize_instrument_name(instr) == expected +def test_join_instruments(): + """Test joining a set of instruments.""" + instruments = {"mersi-1", "abi"} + expected = "abi-mersi-1" + assert inst_utils.join_instrument_names(instruments) == expected -def test_serialize_instruments(): - """Test instrument set serialization.""" - instruments = {"My Instrument-123/1", "ABI"} - expected = "abi-myinstrument1231" - assert instru.serialize_instruments(instruments) == expected def test_set_instruments_attr(): """Test setting instruments attribute.""" attrs = {"instruments": {"myinstrument"}} new_instruments = {"i1", "i2"} with satpy.config.set(instruments_key="instruments"): - instru.set_instruments_attr(attrs, new_instruments) + inst_utils.set_instruments_attr(attrs, new_instruments) assert attrs["instruments"] == new_instruments + def test_get_one_instrument_from_attrs(): """Test getting a single instrument from dataset attributes.""" attrs = {"instruments": {"i1"}} with satpy.config.set(instruments_key="instruments"): - assert instru.get_one_instrument_from_attrs(attrs) == "i1" + assert inst_utils.get_one_instrument_from_attrs(attrs) == "i1" + def test_get_one_instrument_from_attrs_with_warning(caplog): """Test warnings when getting a single instrument.""" attrs = {"instruments": {"i1", "i2"}} with satpy.config.set(instruments_key="instruments"): - instru.get_one_instrument_from_attrs(attrs) + inst_utils.get_one_instrument_from_attrs(attrs) assert "More than one" in caplog.text with pytest.raises(KeyError): - instru.get_one_instrument_from_attrs({}) + inst_utils.get_one_instrument_from_attrs({}) diff --git a/satpy/writers/core/base.py b/satpy/writers/core/base.py index 65024dc557..11b81708ed 100644 --- a/satpy/writers/core/base.py +++ b/satpy/writers/core/base.py @@ -16,7 +16,6 @@ """Shared objects and base classes for writers.""" from __future__ import annotations -import contextlib import logging import os import typing @@ -138,10 +137,9 @@ def create_filename_parser(self, base_dir): @staticmethod def _prepare_metadata_for_filename_formatting(attrs): - with contextlib.suppress(KeyError): - instruments = inst_utils.get_instruments_from_attrs(attrs) - serialized = inst_utils.serialize_instruments(instruments) - inst_utils.set_instruments_attr(attrs, serialized) + instruments = inst_utils.get_instruments_from_attrs(attrs, to_internal=True) + joined = inst_utils.join_instrument_names(instruments) + inst_utils.set_instruments_attr(attrs, joined) def get_filename(self, **kwargs): """Create a filename where output data will be saved. From 0f9c99c838ce854684fe8f6af13e8864f59931b0 Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Wed, 20 May 2026 17:15:50 +0000 Subject: [PATCH 25/37] Add internal to WMO conversion method --- .pre-commit-config.yaml | 2 +- satpy/_instruments.py | 73 +++++++++++++++++++++++++++++++++ satpy/tests/test_instruments.py | 13 ++++++ 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 38aeed0b47..fbb852bb20 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,7 +27,7 @@ repos: - types-setuptools - types-PyYAML - types-requests - args: ["--python-version", "3.10", "--ignore-missing-imports"] + args: ["--python-version", "3.11", "--ignore-missing-imports"] - repo: https://github.com/pycqa/isort rev: 9.0.0a3 hooks: diff --git a/satpy/_instruments.py b/satpy/_instruments.py index b9f0a72b52..8696e53a33 100644 --- a/satpy/_instruments.py +++ b/satpy/_instruments.py @@ -17,6 +17,7 @@ import logging import warnings +from enum import StrEnum from typing import Any import satpy @@ -98,3 +99,75 @@ def set_instruments_attr(attrs: dict[str,Any], instruments: set[str]|str) -> Non def get_instruments_key(): """Get key for instruments in dataset attributes.""" return satpy.config.get("instruments_key") + + +class OSCAR(StrEnum): + """WMO OSCAR instrument names.""" + ABI = "ABI" + AHI = "AHI" + AMSR_2 = "AMSR2" + AMSU_A = "AMSU-A" + AMSU_B = "AMSU-B" + ATMS = "ATMS" + AVHRR = "AVHRR" + AVHRR_2 = "AVHRR/2" + AVHRR_3 = "AVHRR/3" + CRIS = "CrIS" + EPIC = "EPIC" + ETM_PLUS = "ETM+" + FCI = "FCI" + GLM = "GLM" + GMI = "GMI" + IASI = "IASI" + IASI_NG = "IASI-NG" + IMAGER_GOES_12_15 = "IMAGER (GOES 12-15)" + IMAGER_GOES_8_11 = "IMAGER (GOES 8-11)" + IMAGER_INSAT = "IMAGER (INSAT)" + IMAGER_MTSAT_2 = "IMAGER (MTSAT-2)" + JAMI = "JAMI" + LI = "LI" + MERIS = "MERIS" + MERSI_1 = "MERSI-1" + MERSI_2 = "MERSI-2" + MERSI_3 = "MERSI-3" + MERSI_LL = "MERSI-LL" + MERSI_RM = "MERSI-RM" + METIMAGE = "METimage" + MHS = "MHS" + MODIS = "MODIS" + MSS = "MSS" + MSU_GS = "MSU-GS" + MSU_GS_A = "MSU-GS/A" + MVIRI = "MVIRI" + # OSCAR lists "MWR (Sterna)", "MWR (AWS)" etc. + # But to avoid enhancement/composite duplication + # we just use "MWR". + MWR = "MWR" + OCI = "OCI" + OLCI = "OLCI" + OLI = "OLI" + SEAWIFS = "SeaWiFS" + SEVIRI = "SEVIRI" + SGLI = "SGLI" + SLSTR = "SLSTR" + SSMIS = "SSMIS" + TIRS = "TIRS" + TM = "TM" + VIIRS = "VIIRS" + VISSR = "VISSR" + VISSR_HIMAWARI_5 = "VISSR (Himawari-5)" + + +def enum_to_str(instruments: set[StrEnum]) -> set[str]: + """Convert OSCAR enums to string.""" + return {str(i) for i in instruments} + + +_INTERNAL_TO_WMO = { + wmo_to_internal(inst): str(inst) + for inst in OSCAR +} + +def internal_to_wmo(instrument: str) -> str: + """Convert internal to WMO instrument name.""" + return _INTERNAL_TO_WMO.get(instrument, instrument) diff --git a/satpy/tests/test_instruments.py b/satpy/tests/test_instruments.py index 53975948af..abed85dbf2 100644 --- a/satpy/tests/test_instruments.py +++ b/satpy/tests/test_instruments.py @@ -92,3 +92,16 @@ def test_get_one_instrument_from_attrs_with_warning(caplog): assert "More than one" in caplog.text with pytest.raises(KeyError): inst_utils.get_one_instrument_from_attrs({}) + + + +@pytest.mark.parametrize( + ("instrument", "expected"), + [ + ("abi", "ABI"), + ("ABI", "ABI"), + ] +) +def test_internal_to_wmo(instrument, expected): + """Test conversion to WMO instrument name.""" + assert inst_utils.internal_to_wmo(instrument) == expected From 5ecfa7f693b1c4035af689664281238ff34c22d3 Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Wed, 20 May 2026 17:36:56 +0000 Subject: [PATCH 26/37] Make scene.sensor_names return WMO names --- satpy/scene.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/satpy/scene.py b/satpy/scene.py index 70cfa32c94..e26f2e363f 100644 --- a/satpy/scene.py +++ b/satpy/scene.py @@ -183,7 +183,7 @@ def _create_reader_instances(self, @property def sensor_names(self) -> set[str]: - """Return sensor names for the data currently contained in this Scene. + """Return WMO sensor names for the data currently contained in this Scene. Sensor information is collected from data contained in the Scene whether loaded from a reader or generated as a composite with @@ -194,8 +194,7 @@ def sensor_names(self) -> set[str]: """ contained_sensor_names = self._contained_sensor_names() - reader_sensor_names = set([sensor for reader_instance in self._readers.values() - for sensor in reader_instance.sensor_names]) + reader_sensor_names = self._reader_sensor_names() return contained_sensor_names | reader_sensor_names def _contained_sensor_names(self) -> set[str]: @@ -204,6 +203,20 @@ def _contained_sensor_names(self) -> set[str]: sensor_names.update(inst_utils.get_instruments_from_attrs(data_arr.attrs)) return sensor_names + def _reader_sensor_names(self) -> set[str]: + """Get WMO instrument names from readers.""" + instruments = set( + [ + instrument + for reader_instance in self._readers.values() + for instrument in reader_instance.sensor_names + ] + ) + return { + inst_utils.internal_to_wmo(inst) + for inst in instruments + } + @property def start_time(self): """Return the start time of the contained data. From 020a1d9ba98a6d2089f4284af355750db436c2ae Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Wed, 20 May 2026 17:52:31 +0000 Subject: [PATCH 27/37] Convert WMO to internal in enhancer --- satpy/enhancements/enhancer.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/satpy/enhancements/enhancer.py b/satpy/enhancements/enhancer.py index 8c0f0a7719..fb2299b660 100644 --- a/satpy/enhancements/enhancer.py +++ b/satpy/enhancements/enhancer.py @@ -210,10 +210,11 @@ def get_enhanced_image(dataset, enhance=None, overlay=None, decorate=None, if enhancer is None or enhancer.enhancement_tree is None: LOG.debug("No enhancement being applied to dataset") else: - sensors = inst_utils.get_instruments_from_attrs(dataset.attrs) + sensors = inst_utils.get_instruments_from_attrs(dataset.attrs, to_internal=True) if sensors: enhancer.add_sensor_enhancements(sensors) - enhancer.apply(img, **dataset.attrs) + dataset_attrs = _get_dataset_attrs_for_enh(dataset, sensors) + enhancer.apply(img, **dataset_attrs) if overlay is not None: from satpy.enhancements.overlays import add_overlay @@ -226,3 +227,14 @@ def get_enhanced_image(dataset, enhance=None, overlay=None, decorate=None, img = add_decorate(img, fill_value=fill_value, **decorate) return img + + +def _get_dataset_attrs_for_enh(dataset, instruments_int): + """Get dataset attributes for applying enhancement. + + In particular, use instrument names in internal format so that + they match the enhancement definition in the YAML. + """ + attrs = dataset.attrs.copy() + inst_utils.set_instruments_attr(attrs, instruments_int) + return attrs From 18744ea539e220ceb71b62fd7cec1f3ab7cd0c1b Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Wed, 20 May 2026 17:59:33 +0000 Subject: [PATCH 28/37] Fix sensor_names expectations in tests --- satpy/tests/reader_tests/test_iasi_l2.py | 2 +- satpy/tests/reader_tests/test_iasi_l2_so2_bufr.py | 2 +- satpy/tests/reader_tests/test_seviri_l1b_native.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/satpy/tests/reader_tests/test_iasi_l2.py b/satpy/tests/reader_tests/test_iasi_l2.py index 3f0e618119..6c92077a50 100644 --- a/satpy/tests/reader_tests/test_iasi_l2.py +++ b/satpy/tests/reader_tests/test_iasi_l2.py @@ -157,7 +157,7 @@ def test_scene(test_data): assert scn.start_time is not None assert scn.end_time is not None assert scn.sensor_names - assert "iasi" in scn.sensor_names + assert "IASI" in scn.sensor_names def test_scene_load_available_datasets(test_data): diff --git a/satpy/tests/reader_tests/test_iasi_l2_so2_bufr.py b/satpy/tests/reader_tests/test_iasi_l2_so2_bufr.py index b75ac67dee..e9f4c0180a 100644 --- a/satpy/tests/reader_tests/test_iasi_l2_so2_bufr.py +++ b/satpy/tests/reader_tests/test_iasi_l2_so2_bufr.py @@ -366,7 +366,7 @@ def test_scene(self): assert scn.start_time is not None assert scn.end_time is not None assert scn.sensor_names - assert "iasi" in scn.sensor_names + assert "IASI" in scn.sensor_names @unittest.skipIf(sys.platform.startswith("win"), "'eccodes' not supported on Windows") def test_scene_load_available_datasets(self): diff --git a/satpy/tests/reader_tests/test_seviri_l1b_native.py b/satpy/tests/reader_tests/test_seviri_l1b_native.py index c2a5ffee9e..aa8f83ed5b 100644 --- a/satpy/tests/reader_tests/test_seviri_l1b_native.py +++ b/satpy/tests/reader_tests/test_seviri_l1b_native.py @@ -1301,7 +1301,7 @@ def test_read_physical_seviri_nat_file(full_path): """ scene = scene_from_physical_seviri_nat_file(full_path) - assert scene.sensor_names == {"seviri"} + assert scene.sensor_names == {"SEVIRI"} assert len(scene.available_dataset_ids()) == 36 assert set(scene.available_dataset_names()) == set(CHANNEL_INDEX_LIST) From 8afd9f254fde1eda3b180b7ba744df2f326462cc Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Fri, 22 May 2026 08:28:07 +0000 Subject: [PATCH 29/37] Wrap deprecation warning with v1.0 scissors --- satpy/_instruments.py | 10 +++++++--- satpy/tests/test_instruments.py | 3 +++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/satpy/_instruments.py b/satpy/_instruments.py index 8696e53a33..a0b6aa3b8b 100644 --- a/satpy/_instruments.py +++ b/satpy/_instruments.py @@ -31,15 +31,18 @@ def get_instruments_from_attrs(attrs: dict[str,Any], to_internal: bool=False) -> removed once all file handlers provide instruments as a set. """ - legacy = attrs.get("sensor", set()) - instruments = attrs.get("instruments", legacy) - if legacy: + instruments = attrs.get("instruments", set()) + # 8< v1.0 + sensor = attrs.get("sensor", set()) + if sensor: warnings.warn( "Satpy will ignore the 'sensor' attribute as of v1.1. " "Use the 'instruments' attribute instead.", DeprecationWarning, stacklevel=2 ) + if not instruments: + instruments = sensor if isinstance(instruments, str): warnings.warn( "Converting 'instruments' attribute from string to set. " @@ -49,6 +52,7 @@ def get_instruments_from_attrs(attrs: dict[str,Any], to_internal: bool=False) -> stacklevel=2 ) instruments = set([instruments]) + # >8 v1.0 if to_internal: return { wmo_to_internal(inst) for inst in instruments diff --git a/satpy/tests/test_instruments.py b/satpy/tests/test_instruments.py index abed85dbf2..91008c0023 100644 --- a/satpy/tests/test_instruments.py +++ b/satpy/tests/test_instruments.py @@ -33,6 +33,8 @@ def test_get_instruments_from_attrs(attrs, to_internal, expected): """Test getting instruments from dataset attributes.""" assert inst_utils.get_instruments_from_attrs(attrs, to_internal) == expected + +# 8< v1.0 @pytest.mark.parametrize( ("attrs", "expected"), [ @@ -45,6 +47,7 @@ def test_get_instruments_from_attrs_with_warning(attrs, expected): """Test deprecation warnings when getting instruments.""" with pytest.warns(DeprecationWarning, match="v1.1"): assert inst_utils.get_instruments_from_attrs(attrs) == expected +# >8 v1.0 @pytest.mark.parametrize( From bd2a3555c9c51e18a610b4ea6dca28a49d405e19 Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Fri, 22 May 2026 08:30:29 +0000 Subject: [PATCH 30/37] Change scissor version to 1.1 --- satpy/_instruments.py | 4 ++-- satpy/tests/test_instruments.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/satpy/_instruments.py b/satpy/_instruments.py index a0b6aa3b8b..5526510d07 100644 --- a/satpy/_instruments.py +++ b/satpy/_instruments.py @@ -32,7 +32,7 @@ def get_instruments_from_attrs(attrs: dict[str,Any], to_internal: bool=False) -> set. """ instruments = attrs.get("instruments", set()) - # 8< v1.0 + # 8< v1.1 sensor = attrs.get("sensor", set()) if sensor: warnings.warn( @@ -52,7 +52,7 @@ def get_instruments_from_attrs(attrs: dict[str,Any], to_internal: bool=False) -> stacklevel=2 ) instruments = set([instruments]) - # >8 v1.0 + # >8 v1.1 if to_internal: return { wmo_to_internal(inst) for inst in instruments diff --git a/satpy/tests/test_instruments.py b/satpy/tests/test_instruments.py index 91008c0023..5a724590f4 100644 --- a/satpy/tests/test_instruments.py +++ b/satpy/tests/test_instruments.py @@ -34,7 +34,7 @@ def test_get_instruments_from_attrs(attrs, to_internal, expected): assert inst_utils.get_instruments_from_attrs(attrs, to_internal) == expected -# 8< v1.0 +# 8< v1.1 @pytest.mark.parametrize( ("attrs", "expected"), [ @@ -47,7 +47,7 @@ def test_get_instruments_from_attrs_with_warning(attrs, expected): """Test deprecation warnings when getting instruments.""" with pytest.warns(DeprecationWarning, match="v1.1"): assert inst_utils.get_instruments_from_attrs(attrs) == expected -# >8 v1.0 +# >8 v1.1 @pytest.mark.parametrize( From e0e76de83333f5b009878fed4cef00077f5ed19c Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Fri, 22 May 2026 09:55:03 +0000 Subject: [PATCH 31/37] Add config switch for legacy sensor attribute --- doc/source/reading.rst | 8 +++--- satpy/_config.py | 3 ++- satpy/scene.py | 21 ++++++++++++++++ satpy/tests/scene_tests/test_data_access.py | 27 +++++++++++++++++++++ 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/doc/source/reading.rst b/doc/source/reading.rst index 288bb3d6d4..de375ec6dc 100644 --- a/doc/source/reading.rst +++ b/doc/source/reading.rst @@ -222,9 +222,11 @@ time etc. The following attributes are standardized across all readers: :class:`~pyresample.geometry.SwathDefinition` if data is geolocated. Areas are used for gridded projected data and Swaths when data must be described by individual longitude/latitude coordinates. See the Coordinates section below. -* ``sensor``: The name of the sensor that recorded the data. For full support through Satpy this - should be all lowercase. If the dataset is the result of observations from multiple sensors a - ``set`` object can be used to specify more than one sensor name. +* ``instruments`` (previously ``sensor`` in Satpy < v1.0): Names of instruments that recorded the + data, stored in a ``set`` object. Starting with Satpy v1.0 the instrument names follow WMO OSCAR + naming conventions. To facilitate the transision, you can get the ``sensor`` attribute back by + setting ``satpy.config.set(legacy_sensor_attribute=True)``. This config switch will be removed + in Satpy v1.1. * ``reader``: The name of the Satpy reader that produced the dataset. * ``orbital_parameters``: Dictionary of orbital parameters describing the satellite's position. See the :ref:`orbital_parameters` section below for more information. diff --git a/satpy/_config.py b/satpy/_config.py index 0b2e6a57c0..b55a89bd18 100644 --- a/satpy/_config.py +++ b/satpy/_config.py @@ -54,7 +54,8 @@ "readers": { "clip_negative_radiances": False, }, - "instruments_key": "sensor" + "instruments_key": "sensor", + "legacy_sensor_attribute": False } # Satpy main configuration object diff --git a/satpy/scene.py b/satpy/scene.py index e26f2e363f..5fb0d02fcd 100644 --- a/satpy/scene.py +++ b/satpy/scene.py @@ -29,6 +29,7 @@ from pyresample.geometry import AreaDefinition, BaseDefinition, CoordinateDefinition, SwathDefinition from xarray import DataArray +import satpy import satpy._instruments as inst_utils from satpy.area import get_area_def from satpy.composites.config_loader import load_compositor_configs_for_sensors @@ -841,8 +842,28 @@ def __getitem__(self, key): """Get a dataset or create a new 'slice' of the Scene.""" if isinstance(key, tuple): return self.slice(key) + # 8< v1.1 + data_array = self._datasets[key] + if self._should_add_legacy_sensor_attribute(data_array): + self._set_legacy_sensor_attribute(data_array) + return data_array + # >8 v1.1 return self._datasets[key] + # 8< v1.1 + def _should_add_legacy_sensor_attribute(self, data_array: xr.DataArray) -> bool: + instruments = inst_utils.get_instruments_from_attrs(data_array.attrs) + return bool(instruments) and satpy.config.get("legacy_sensor_attribute") + + def _set_legacy_sensor_attribute(self, data_array: xr.DataArray) -> None: + instruments = inst_utils.get_instruments_from_attrs(data_array.attrs) + if len(instruments) == 1: + # In satpy < v1.0 single sensors are provided as string + data_array.attrs["sensor"] = inst_utils.wmo_to_internal(list(instruments)[0]) + else: + data_array.attrs["sensor"] = {inst_utils.wmo_to_internal(inst) for inst in instruments} + # >8 v1.1 + def __setitem__(self, key, value): """Add the item to the scene.""" self._datasets[key] = value diff --git a/satpy/tests/scene_tests/test_data_access.py b/satpy/tests/scene_tests/test_data_access.py index 60ada6c78b..6e4cdb8baf 100644 --- a/satpy/tests/scene_tests/test_data_access.py +++ b/satpy/tests/scene_tests/test_data_access.py @@ -21,6 +21,7 @@ import xarray as xr from dask import array as da +import satpy from satpy import Scene from satpy.dataset.dataid import default_id_keys_config from satpy.tests.utils import FAKE_FILEHANDLER_END, FAKE_FILEHANDLER_START, make_cid, make_dataid @@ -393,3 +394,29 @@ def test_chunk_pass_through(self): scene.load(["ds1"]) scene = scene.chunk(chunks=2) assert scene["ds1"].data.chunksize == (2, 2) + + +class TestLegacySensorAttribute: + """Tests for legacy sensor attribute.""" + + @pytest.mark.parametrize( + ("instruments", "expected"), + [ + ({"inst1"}, "inst1"), + ({"inst1", "inst2"}, {"inst1", "inst2"}) + ] + ) + def test_getting_dataset_with_sensor_attribute(self, instruments, expected): + """Test getting dataset with sensor attribute.""" + scene = Scene() + scene["ds"] = xr.DataArray(attrs={"instruments": instruments}) + assert "sensor" not in scene["ds"].attrs + with satpy.config.set(legacy_sensor_attribute=True): + assert scene["ds"].attrs["sensor"] == expected + + def test_no_instruments_no_sensor(self): + """Test setting sensor only if instruments are present.""" + scene = Scene() + scene["ds"] = xr.DataArray() + with satpy.config.set(legacy_sensor_attribute=True): + assert "sensor" not in scene["ds"].attrs From f6a5fee34eb59b47b719cb36afb7acc9ce6689d3 Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Fri, 22 May 2026 10:15:58 +0000 Subject: [PATCH 32/37] Update documentation --- doc/source/reading.rst | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/doc/source/reading.rst b/doc/source/reading.rst index de375ec6dc..a7061a395f 100644 --- a/doc/source/reading.rst +++ b/doc/source/reading.rst @@ -222,11 +222,27 @@ time etc. The following attributes are standardized across all readers: :class:`~pyresample.geometry.SwathDefinition` if data is geolocated. Areas are used for gridded projected data and Swaths when data must be described by individual longitude/latitude coordinates. See the Coordinates section below. -* ``instruments`` (previously ``sensor`` in Satpy < v1.0): Names of instruments that recorded the - data, stored in a ``set`` object. Starting with Satpy v1.0 the instrument names follow WMO OSCAR - naming conventions. To facilitate the transision, you can get the ``sensor`` attribute back by - setting ``satpy.config.set(legacy_sensor_attribute=True)``. This config switch will be removed - in Satpy v1.1. +* ``sensor``: The name of the sensor that recorded the data. For full support through Satpy this + should be all lowercase. If the dataset is the result of observations from multiple sensors a + ``set`` object can be used to specify more than one sensor name. + + .. versionremoved:: 1.0 + + The ``sensor`` attribute has been replaced by ``instruments`` in Satpy v1.0. During + a transition phase the attribute can be restored by setting + + .. code-block:: python + + import satpy + satpy.config.set(legacy_sensor_attribute=True) + + This option will be removed in Satpy v1.1. + +* ``instruments``: Names of instruments that recorded the data, stored in a ``set`` object. + Instrument names follow the WMO OSCAR naming conventions. + + .. versionadded:: 1.0 + * ``reader``: The name of the Satpy reader that produced the dataset. * ``orbital_parameters``: Dictionary of orbital parameters describing the satellite's position. See the :ref:`orbital_parameters` section below for more information. From 02c455a1abc7ea94d0b27eabbd8915f82a3a312d Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Fri, 22 May 2026 12:26:59 +0000 Subject: [PATCH 33/37] Change scissors again --- satpy/_instruments.py | 8 ++++---- satpy/tests/test_instruments.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/satpy/_instruments.py b/satpy/_instruments.py index 5526510d07..2c9bf22b96 100644 --- a/satpy/_instruments.py +++ b/satpy/_instruments.py @@ -32,11 +32,11 @@ def get_instruments_from_attrs(attrs: dict[str,Any], to_internal: bool=False) -> set. """ instruments = attrs.get("instruments", set()) - # 8< v1.1 + # 8< v1.0 sensor = attrs.get("sensor", set()) if sensor: warnings.warn( - "Satpy will ignore the 'sensor' attribute as of v1.1. " + "Satpy will ignore the 'sensor' attribute as of v1.0. " "Use the 'instruments' attribute instead.", DeprecationWarning, stacklevel=2 @@ -46,13 +46,13 @@ def get_instruments_from_attrs(attrs: dict[str,Any], to_internal: bool=False) -> if isinstance(instruments, str): warnings.warn( "Converting 'instruments' attribute from string to set. " - "This will result in an error in v1.1, when Satpy will require " + "This will result in an error in v1.0, when Satpy will require " "set type instruments attributes.", DeprecationWarning, stacklevel=2 ) instruments = set([instruments]) - # >8 v1.1 + # >8 v1.0 if to_internal: return { wmo_to_internal(inst) for inst in instruments diff --git a/satpy/tests/test_instruments.py b/satpy/tests/test_instruments.py index 5a724590f4..1229a822c8 100644 --- a/satpy/tests/test_instruments.py +++ b/satpy/tests/test_instruments.py @@ -34,7 +34,7 @@ def test_get_instruments_from_attrs(attrs, to_internal, expected): assert inst_utils.get_instruments_from_attrs(attrs, to_internal) == expected -# 8< v1.1 +# 8< v1.0 @pytest.mark.parametrize( ("attrs", "expected"), [ @@ -45,9 +45,9 @@ def test_get_instruments_from_attrs(attrs, to_internal, expected): ) def test_get_instruments_from_attrs_with_warning(attrs, expected): """Test deprecation warnings when getting instruments.""" - with pytest.warns(DeprecationWarning, match="v1.1"): + with pytest.warns(DeprecationWarning, match="v1.0"): assert inst_utils.get_instruments_from_attrs(attrs) == expected -# >8 v1.1 +# >8 v1.0 @pytest.mark.parametrize( From c147e098d84d5f371e1d8fcbb462791c957174de Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Fri, 22 May 2026 12:30:14 +0000 Subject: [PATCH 34/37] Change scissors again --- satpy/scene.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/satpy/scene.py b/satpy/scene.py index 5fb0d02fcd..3fa5cd098e 100644 --- a/satpy/scene.py +++ b/satpy/scene.py @@ -842,15 +842,15 @@ def __getitem__(self, key): """Get a dataset or create a new 'slice' of the Scene.""" if isinstance(key, tuple): return self.slice(key) - # 8< v1.1 + # 8< v1.0 data_array = self._datasets[key] if self._should_add_legacy_sensor_attribute(data_array): self._set_legacy_sensor_attribute(data_array) return data_array - # >8 v1.1 + # >8 v1.0 return self._datasets[key] - # 8< v1.1 + # 8< v1.0 def _should_add_legacy_sensor_attribute(self, data_array: xr.DataArray) -> bool: instruments = inst_utils.get_instruments_from_attrs(data_array.attrs) return bool(instruments) and satpy.config.get("legacy_sensor_attribute") @@ -862,7 +862,7 @@ def _set_legacy_sensor_attribute(self, data_array: xr.DataArray) -> None: data_array.attrs["sensor"] = inst_utils.wmo_to_internal(list(instruments)[0]) else: data_array.attrs["sensor"] = {inst_utils.wmo_to_internal(inst) for inst in instruments} - # >8 v1.1 + # >8 v1.0 def __setitem__(self, key, value): """Add the item to the scene.""" From e9e3e9f3b2c7a19fdc30b8f122885572ec53f6f4 Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Fri, 22 May 2026 12:40:02 +0000 Subject: [PATCH 35/37] Remove config switch --- doc/source/reading.rst | 14 +++----------- satpy/_config.py | 1 - satpy/scene.py | 13 ++++--------- satpy/tests/scene_tests/test_data_access.py | 16 ++++------------ 4 files changed, 11 insertions(+), 33 deletions(-) diff --git a/doc/source/reading.rst b/doc/source/reading.rst index a7061a395f..ac2e6718b2 100644 --- a/doc/source/reading.rst +++ b/doc/source/reading.rst @@ -226,22 +226,14 @@ time etc. The following attributes are standardized across all readers: should be all lowercase. If the dataset is the result of observations from multiple sensors a ``set`` object can be used to specify more than one sensor name. - .. versionremoved:: 1.0 + .. deprecated:: 0.61.0 - The ``sensor`` attribute has been replaced by ``instruments`` in Satpy v1.0. During - a transition phase the attribute can be restored by setting - - .. code-block:: python - - import satpy - satpy.config.set(legacy_sensor_attribute=True) - - This option will be removed in Satpy v1.1. + The ``sensor`` attribute will be replaced by ``instruments`` in Satpy v1.0. * ``instruments``: Names of instruments that recorded the data, stored in a ``set`` object. Instrument names follow the WMO OSCAR naming conventions. - .. versionadded:: 1.0 + .. versionadded:: 0.61.0 * ``reader``: The name of the Satpy reader that produced the dataset. * ``orbital_parameters``: Dictionary of orbital parameters describing the satellite's position. diff --git a/satpy/_config.py b/satpy/_config.py index b55a89bd18..8ed4dbf93b 100644 --- a/satpy/_config.py +++ b/satpy/_config.py @@ -55,7 +55,6 @@ "clip_negative_radiances": False, }, "instruments_key": "sensor", - "legacy_sensor_attribute": False } # Satpy main configuration object diff --git a/satpy/scene.py b/satpy/scene.py index 3fa5cd098e..ac0e808ed4 100644 --- a/satpy/scene.py +++ b/satpy/scene.py @@ -29,7 +29,6 @@ from pyresample.geometry import AreaDefinition, BaseDefinition, CoordinateDefinition, SwathDefinition from xarray import DataArray -import satpy import satpy._instruments as inst_utils from satpy.area import get_area_def from satpy.composites.config_loader import load_compositor_configs_for_sensors @@ -844,19 +843,15 @@ def __getitem__(self, key): return self.slice(key) # 8< v1.0 data_array = self._datasets[key] - if self._should_add_legacy_sensor_attribute(data_array): - self._set_legacy_sensor_attribute(data_array) + instruments = inst_utils.get_instruments_from_attrs(data_array.attrs) + if instruments: + self._set_legacy_sensor_attribute(data_array, instruments) return data_array # >8 v1.0 return self._datasets[key] # 8< v1.0 - def _should_add_legacy_sensor_attribute(self, data_array: xr.DataArray) -> bool: - instruments = inst_utils.get_instruments_from_attrs(data_array.attrs) - return bool(instruments) and satpy.config.get("legacy_sensor_attribute") - - def _set_legacy_sensor_attribute(self, data_array: xr.DataArray) -> None: - instruments = inst_utils.get_instruments_from_attrs(data_array.attrs) + def _set_legacy_sensor_attribute(self, data_array: xr.DataArray, instruments: set[str]) -> None: if len(instruments) == 1: # In satpy < v1.0 single sensors are provided as string data_array.attrs["sensor"] = inst_utils.wmo_to_internal(list(instruments)[0]) diff --git a/satpy/tests/scene_tests/test_data_access.py b/satpy/tests/scene_tests/test_data_access.py index 6e4cdb8baf..3a620c3455 100644 --- a/satpy/tests/scene_tests/test_data_access.py +++ b/satpy/tests/scene_tests/test_data_access.py @@ -21,7 +21,6 @@ import xarray as xr from dask import array as da -import satpy from satpy import Scene from satpy.dataset.dataid import default_id_keys_config from satpy.tests.utils import FAKE_FILEHANDLER_END, FAKE_FILEHANDLER_START, make_cid, make_dataid @@ -409,14 +408,7 @@ class TestLegacySensorAttribute: def test_getting_dataset_with_sensor_attribute(self, instruments, expected): """Test getting dataset with sensor attribute.""" scene = Scene() - scene["ds"] = xr.DataArray(attrs={"instruments": instruments}) - assert "sensor" not in scene["ds"].attrs - with satpy.config.set(legacy_sensor_attribute=True): - assert scene["ds"].attrs["sensor"] == expected - - def test_no_instruments_no_sensor(self): - """Test setting sensor only if instruments are present.""" - scene = Scene() - scene["ds"] = xr.DataArray() - with satpy.config.set(legacy_sensor_attribute=True): - assert "sensor" not in scene["ds"].attrs + scene["ds1"] = xr.DataArray(attrs={"instruments": instruments}) + scene["ds2"] = xr.DataArray() + assert scene["ds1"].attrs["sensor"] == expected + assert "sensor" not in scene["ds2"].attrs From 831938d4ca409468722985f0d029fe1fa77c21bd Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Fri, 22 May 2026 13:22:26 +0000 Subject: [PATCH 36/37] Fix exceptions in sensor compatibility --- satpy/scene.py | 24 ++++++++++++---- satpy/tests/scene_tests/test_data_access.py | 32 +++++++++++++++------ 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/satpy/scene.py b/satpy/scene.py index ac0e808ed4..796f72fd49 100644 --- a/satpy/scene.py +++ b/satpy/scene.py @@ -843,20 +843,32 @@ def __getitem__(self, key): return self.slice(key) # 8< v1.0 data_array = self._datasets[key] - instruments = inst_utils.get_instruments_from_attrs(data_array.attrs) - if instruments: - self._set_legacy_sensor_attribute(data_array, instruments) + if self._should_add_sensor_attribute(data_array): + self._set_sensor_attribute(data_array) return data_array # >8 v1.0 return self._datasets[key] # 8< v1.0 - def _set_legacy_sensor_attribute(self, data_array: xr.DataArray, instruments: set[str]) -> None: - if len(instruments) == 1: - # In satpy < v1.0 single sensors are provided as string + def _should_add_sensor_attribute(self, data_array: xr.DataArray) -> bool: + return "instruments" in data_array.attrs and "sensor" not in data_array.attrs + + def _set_sensor_attribute(self, data_array: xr.DataArray) -> None: + instruments = data_array.attrs["instruments"] + if self._should_convert_to_string(instruments): data_array.attrs["sensor"] = inst_utils.wmo_to_internal(list(instruments)[0]) else: data_array.attrs["sensor"] = {inst_utils.wmo_to_internal(inst) for inst in instruments} + + def _should_convert_to_string(self, instruments): + # In satpy < v1.0 single sensors are provided as string by most readers + has_one_element = len(instruments) == 1 + readers_providing_set = [ + "seadas_l2", + "oci_l2_bgc" + ] + is_exception = any([reader in self._readers for reader in readers_providing_set]) + return has_one_element and not is_exception # >8 v1.0 def __setitem__(self, key, value): diff --git a/satpy/tests/scene_tests/test_data_access.py b/satpy/tests/scene_tests/test_data_access.py index 3a620c3455..f2463abf54 100644 --- a/satpy/tests/scene_tests/test_data_access.py +++ b/satpy/tests/scene_tests/test_data_access.py @@ -395,20 +395,34 @@ def test_chunk_pass_through(self): assert scene["ds1"].data.chunksize == (2, 2) +# 8< v1.0 class TestLegacySensorAttribute: """Tests for legacy sensor attribute.""" @pytest.mark.parametrize( - ("instruments", "expected"), + ("reader", "expected"), [ - ({"inst1"}, "inst1"), - ({"inst1", "inst2"}, {"inst1", "inst2"}) + ("most-readers", "inst1"), + ("seadas_l2", {"inst1"}), + ("oci_l2_bgc", {"inst1"}), ] ) - def test_getting_dataset_with_sensor_attribute(self, instruments, expected): - """Test getting dataset with sensor attribute.""" + def test_set_single_sensor(self, reader, expected): + """Test setting sensor attribute with a single sensor.""" scene = Scene() - scene["ds1"] = xr.DataArray(attrs={"instruments": instruments}) - scene["ds2"] = xr.DataArray() - assert scene["ds1"].attrs["sensor"] == expected - assert "sensor" not in scene["ds2"].attrs + scene._readers[reader] = "dummy" + scene["ds"] = xr.DataArray(attrs={"instruments": {"inst1"}}) + assert scene["ds"].attrs["sensor"] == expected + + def test_set_multi_sensor(self): + """Test setting sensor attribute with multiple sensors.""" + scene = Scene() + scene["ds"] = xr.DataArray(attrs={"instruments": {"inst1", "inst2"}}) + assert scene["ds"].attrs["sensor"] == {"inst1", "inst2"} + + def test_set_no_sensor(self): + """Test sensor attribute is set only if instruments present.""" + scene = Scene() + scene["ds"] = xr.DataArray() + assert "sensor" not in scene["ds"].attrs +# >8 v1.0 From da4b46018de59d2b150260b505276ee4d4ef5a29 Mon Sep 17 00:00:00 2001 From: Stephan Finkensieper Date: Fri, 22 May 2026 14:02:46 +0000 Subject: [PATCH 37/37] Update documentation --- doc/source/dev_guide/custom_reader.rst | 2 +- doc/source/dev_guide/xarray_migration.rst | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/source/dev_guide/custom_reader.rst b/doc/source/dev_guide/custom_reader.rst index 0475e07144..0098e14f13 100644 --- a/doc/source/dev_guide/custom_reader.rst +++ b/doc/source/dev_guide/custom_reader.rst @@ -494,7 +494,7 @@ needs to implement a few methods: successful, containing the data and :ref:`metadata ` of the loaded dataset, or return None if the loading was unsuccessful. If the reader is reading satellite data the returned xarray.DataArrays should also have the - attributes ``platform_name`` and ``sensor`` with names according to https://space.oscar.wmo.int/. + attributes ``platform_name`` and ``instruments`` with names according to https://space.oscar.wmo.int/. The DataArray should at least have a ``y`` dimension. For data covering a 2D region on the Earth, their should be at least a ``y`` and ``x`` diff --git a/doc/source/dev_guide/xarray_migration.rst b/doc/source/dev_guide/xarray_migration.rst index 1a3b02f61e..6bd2d1d248 100644 --- a/doc/source/dev_guide/xarray_migration.rst +++ b/doc/source/dev_guide/xarray_migration.rst @@ -35,7 +35,7 @@ To create such an array, you can do for example my_dataarray = xr.DataArray(my_data, dims=['y', 'x'], coords={'x': np.arange(...)}, - attrs={'sensor': 'olci'}) + attrs={'instruments': {'OLCI'}) where ``my_data`` can be a regular numpy array, a numpy memmap, or, if you want to keep things lazy, a dask array (more on dask later). Satpy uses dask @@ -105,7 +105,7 @@ Some metadata that should always be present in our dataarrays: - ``area`` the area of the dataset. This should be handled in the reader. - ``start_time``, ``end_time`` -- ``sensor`` +- ``instruments`` Operations on DataArrays ************************