diff --git a/scopesim/effects/electronic/__init__.py b/scopesim/effects/electronic/__init__.py index 2851888f..247b03e4 100644 --- a/scopesim/effects/electronic/__init__.py +++ b/scopesim/effects/electronic/__init__.py @@ -23,6 +23,6 @@ from .electrons import LinearityCurve, Quantization from .noise import (Bias, PoorMansHxRGReadoutNoise, BasicReadoutNoise, ShotNoise, DarkCurrent) -from .exposure import AutoExposure, SummedExposure +from .exposure import AutoExposure, SummedExposure, ExposureOutput from .pixels import ReferencePixelBorder, BinnedImage, UnequalBinnedImage from .dmps import DetectorModePropertiesSetter diff --git a/scopesim/effects/electronic/exposure.py b/scopesim/effects/electronic/exposure.py index 2b522748..af3fa07d 100644 --- a/scopesim/effects/electronic/exposure.py +++ b/scopesim/effects/electronic/exposure.py @@ -184,6 +184,45 @@ def apply_to(self, obj, **kwargs): return obj +class ExposureOutput(Effect): + """Return average or sum of ``ndit`` subexposures.""" + + required_keys = {"dit", "ndit"} + z_order: ClassVar[tuple[int, ...]] = (861,) + _current_str = "current_mode" + + def __init__(self, mode="average", **kwargs): + super().__init__(**kwargs) + self.meta.update(kwargs) + self.modes = ("average", "sum") + if mode not in self.modes: + raise ValueError("mode must be one of", self.modes) + self.current_mode = mode + self.meta["current_mode"] = self.current_mode + check_keys(self.meta, self.required_keys, action="error") + + def apply_to(self, obj, **kwargs): + if not isinstance(obj, DetectorBase): + return obj + + dit = from_currsys(self.meta["dit"], self.cmds) + ndit = from_currsys(self.meta["ndit"], self.cmds) + logger.debug("Exposure: DIT = %s s, NDIT = %s", dit, ndit) + + if self.current_mode == "average": + obj._hdu.data /= ndit + + return obj + + def set_mode(self, new_mode): + """Set new mode for ExposureOutput (average or sum)""" + if new_mode in self.modes: + self.current_mode = new_mode + self.meta["current_mode"] = self.current_mode + else: + logger.warning("Trying to set to unknown mode.") + + class SummedExposure(Effect): """Simulates a summed stack of ``ndit`` exposures.""" diff --git a/scopesim/tests/tests_effects/test_exposure.py b/scopesim/tests/tests_effects/test_exposure.py new file mode 100644 index 00000000..454ee8a0 --- /dev/null +++ b/scopesim/tests/tests_effects/test_exposure.py @@ -0,0 +1,84 @@ +"""Tests for Effect ExposureOutput""" + +import pytest + +import numpy as np + +from scopesim import UserCommands +from scopesim.optics.image_plane import ImagePlane +from scopesim.detector import Detector +from scopesim.effects.electronic import ExposureOutput + +from scopesim.tests.mocks.py_objects.imagehdu_objects import _image_hdu_square + +# pylint: disable=missing-class-docstring +# pylint: disable=missing-function-docstring + +def _patched_cmds(exptime=1, dit=None, ndit=None): + return UserCommands(properties={"!OBS.exptime": exptime, + "!OBS.dit": dit, + "!OBS.ndit": ndit}) + +@pytest.fixture(name="imageplane", scope="class") +def fixture_imageplane(): + """Instantiate an ImagePlane object""" + implane = ImagePlane(_image_hdu_square().header) + implane.hdu.data += 1.e5 + return implane + +@pytest.fixture(name="exposureoutput", scope="function") +def fixture_exposureoutput(): + """Instantiate an ExposureOutput object""" + return ExposureOutput(mode="average", dit=1, ndit=4) + +@pytest.fixture(name="detector", scope="function") +def fixture_detector(): + det = Detector(_image_hdu_square().header) + det._hdu.data[:] = 1.e5 + return det + +class TestExposureOutput: + def test_initialises_correctly(self, exposureoutput): + assert isinstance(exposureoutput, ExposureOutput) + + def test_fails_with_unknown_mode(self): + with pytest.raises(ValueError): + ExposureOutput(mode="something", dit=1, ndit=4) + + def test_fails_without_dit_and_ndit(self): + with pytest.raises(ValueError): + ExposureOutput(mode="sum") + + def test_works_only_on_detector_base(self, exposureoutput, imageplane): + assert exposureoutput.apply_to(imageplane) is imageplane + + def test_can_set_to_new_mode(self, exposureoutput): + assert exposureoutput.current_mode == "average" + exposureoutput.set_mode("sum") + assert exposureoutput.current_mode == "sum" + assert exposureoutput.meta["current_mode"] == "sum" + + def test_cannot_set_to_unknown_mode(self, exposureoutput): + old_mode = exposureoutput.current_mode + exposureoutput.set_mode("something") + assert exposureoutput.current_mode == old_mode + + @pytest.mark.parametrize("dit, ndit", + [(1., 1), + (2., 5), + (3, 36)]) + def test_applies_average(self, dit, ndit, detector): + det_mean = detector._hdu.data.mean() + exposureoutput = ExposureOutput("average", dit=dit, ndit=ndit) + result = exposureoutput.apply_to(detector) + assert np.isclose(result._hdu.data.mean(), det_mean / ndit) + + @pytest.mark.parametrize("dit, ndit", + [(1., 1), + (2., 5), + (3, 36)]) + def test_applies_sum(self, dit, ndit, detector): + det_mean = detector._hdu.data.mean() + exposureoutput = ExposureOutput("sum", dit=dit, ndit=ndit) + result = exposureoutput.apply_to(detector) + assert np.isclose(result._hdu.data.mean(), det_mean)