diff --git a/msfc_ccd/__init__.py b/msfc_ccd/__init__.py index 75bf393..5dcf79d 100644 --- a/msfc_ccd/__init__.py +++ b/msfc_ccd/__init__.py @@ -5,11 +5,12 @@ __all__ = [ "samples", "SensorData", + "TapData", "fits", "abc", ] from . import samples -from ._images import SensorData +from ._images import SensorData, TapData from . import fits from . import abc diff --git a/msfc_ccd/_images/__init__.py b/msfc_ccd/_images/__init__.py index 6426926..e068e36 100644 --- a/msfc_ccd/_images/__init__.py +++ b/msfc_ccd/_images/__init__.py @@ -4,6 +4,8 @@ __all__ = [ "SensorData", + "TapData", ] from ._sensor_images import SensorData +from ._tap_images import TapData diff --git a/msfc_ccd/_images/_images.py b/msfc_ccd/_images/_images.py index 0355d14..7f3811f 100644 --- a/msfc_ccd/_images/_images.py +++ b/msfc_ccd/_images/_images.py @@ -24,6 +24,13 @@ class AbstractImageData( def data(self) -> na.AbstractScalar: """The underlying array storing the image data.""" + @property + @abc.abstractmethod + def pixel(self) -> dict[str, na.AbstractScalarArray]: + """ + The 2-dimensional index of each pixel in the image. + """ + @property @abc.abstractmethod def axis_x(self) -> str: @@ -40,6 +47,20 @@ def axis_y(self) -> str: the images. """ + @property + def num_x(self) -> int: + """ + The number of pixels along the x-axis. + """ + return self.data.shape[self.axis_x] + + @property + def num_y(self) -> int: + """ + The number of pixels along the y-axis. + """ + return self.data.shape[self.axis_y] + @property @abc.abstractmethod def time(self) -> astropy.time.Time | na.AbstractScalar: diff --git a/msfc_ccd/_images/_sensor_images.py b/msfc_ccd/_images/_sensor_images.py index 086b5e9..1da4d26 100644 --- a/msfc_ccd/_images/_sensor_images.py +++ b/msfc_ccd/_images/_sensor_images.py @@ -1,3 +1,4 @@ +from __future__ import annotations from typing import Self import dataclasses import pathlib @@ -6,6 +7,7 @@ import astropy.time import astropy.io.fits import named_arrays as na +import msfc_ccd from ._images import AbstractImageData __all__ = [ @@ -21,6 +23,40 @@ class AbstractSensorData( An interface for representing data captured by an entire image sensor. """ + @property + def pixel(self) -> dict[str, na.AbstractScalarArray]: + axis_x = self.axis_x + axis_y = self.axis_y + shape = self.data.shape + shape_img = { + axis_x: shape[axis_x], + axis_y: shape[axis_y], + } + return na.indices(shape_img) + + def taps( + self, + axis_tap_x: str = "tap_x", + axis_tap_y: str = "tap_y", + ) -> msfc_ccd.TapData: + """ + Split the images into separate images for each tap. + + Parameters + ---------- + axis_tap_x + The name of the logical axis corresponding to the horizontal + variation of the tap index. + axis_tap_y + The name of the logical axis corresponding to the vertical + variation of the tap index. + """ + return msfc_ccd.TapData.from_sensor_data( + a=self, + axis_tap_x=axis_tap_x, + axis_tap_y=axis_tap_y, + ) + @dataclasses.dataclass(eq=False, repr=False) class SensorData( diff --git a/msfc_ccd/_images/_tap_images.py b/msfc_ccd/_images/_tap_images.py new file mode 100644 index 0000000..4006e71 --- /dev/null +++ b/msfc_ccd/_images/_tap_images.py @@ -0,0 +1,270 @@ +from typing import Self, ClassVar +import abc +import dataclasses +import astropy.units as u +import astropy.time +import named_arrays as na +from ._images import AbstractImageData +from ._sensor_images import AbstractSensorData + +__all__ = [ + "TapData", +] + + +@dataclasses.dataclass(eq=False, repr=False) +class AbstractTapData( + AbstractImageData, +): + """ + An interface for representing data gathered by a single tap on an image + sensor. + """ + + num_tap_x: ClassVar[int] = 2 + """The number of taps along the horizontal axis of the CCD sensor.""" + + num_tap_y: ClassVar[int] = 2 + """The number of taps along the vertical axis of the CCD sensor.""" + + @property + @abc.abstractmethod + def axis_tap_x(self): + """ + The name of the logical axis corresponding to the horizontal + variation of the tap index. + """ + + @property + @abc.abstractmethod + def axis_tap_y(self): + """ + The name of the logical axis corresponding to the vertical + variation of the tap index. + """ + + @property + def tap(self) -> dict[str, na.AbstractScalarArray]: + """ + The 2-dimensional index of the tap corresponding to each image. + """ + axis_tap_x = self.axis_tap_x + axis_tap_y = self.axis_tap_y + shape = self.data.shape + shape_img = { + axis_tap_x: shape[axis_tap_x], + axis_tap_y: shape[axis_tap_y], + } + return na.indices(shape_img) + + +@dataclasses.dataclass(eq=False, repr=False) +class TapData( + AbstractTapData, +): + """ + A class designed to represent a sequence of images from each tap of an + MSFC camera. + + Examples + -------- + + Load a sample image and split it into the four tap images. + + .. jupyter-execute:: + + import named_arrays as na + import msfc_ccd + + # Define the x and y axes of the detector + axis_x = "detector_x" + axis_y = "detector_y" + + # Define the x and y axes of the taps + axis_tap_x = "tap_x" + axis_tap_y = "tap_y" + + # Load the sample image + image = msfc_ccd.fits.open( + path=msfc_ccd.samples.path_fe55_esis1, + axis_x=axis_x, + axis_y=axis_y, + ) + + # Split the sample image into four separate images for each tap + taps = image.taps(axis_tap_x, axis_tap_y) + + # Display the four images + fig, axs = na.plt.subplots( + axis_rows=axis_tap_y, + nrows=taps.data.shape[axis_tap_y], + axis_cols=axis_tap_x, + ncols=taps.data.shape[axis_tap_x], + sharex=True, + sharey=True, + constrained_layout=True, + ); + axs = axs[{axis_tap_y: slice(None, None, -1)}] + na.plt.pcolormesh( + *taps.pixel.values(), + C=taps.data, + ax=axs, + ); + + """ + + data: na.AbstractScalar = dataclasses.MISSING + """The underlying array storing the image data.""" + + pixel: dict[str, na.AbstractScalarArray] = dataclasses.MISSING + """The 2-dimensional index of each pixel in the image""" + + axis_x: str = dataclasses.MISSING + """ + The name of the logical axis representing the horizontal dimension of + the images. + """ + + axis_y: str = dataclasses.MISSING + """ + The name of the logical axis representing the vertical dimension of + the images. + """ + + axis_tap_x: str = dataclasses.MISSING + """ + The name of the logical axis corresponding to the horizontal + variation of the tap index. + """ + + axis_tap_y: str = dataclasses.MISSING + """ + The name of the logical axis corresponding to the vertical + variation of the tap index. + """ + + time: astropy.time.Time | na.AbstractScalar = dataclasses.MISSING + """The time in UTC at the midpoint of the exposure.""" + + timedelta: u.Quantity | na.AbstractScalar = dataclasses.MISSING + """The measured exposure time of each image.""" + + timedelta_requested: u.Quantity | na.AbstractScalar = dataclasses.MISSING + """The requested exposure time of each image.""" + + serial_number: None | str | na.AbstractScalar = None + """The serial number of the camera that captured each image.""" + + run_mode: None | str | na.AbstractScalar = None + """The Run Mode of the camera when each image was captured.""" + + status: None | str | na.AbstractScalar = None + """The status of the camera while each image was being captured.""" + + voltage_fpga_vccint: u.Quantity | na.AbstractScalar = 0 + """The VCCINT voltage of the FPGA when each image was captured.""" + + voltage_fpga_vccaux: u.Quantity | na.AbstractScalar = 0 + """The VCCAUX voltage of the FPGA when each image was captured.""" + + voltage_fpga_vccbram: u.Quantity | na.AbstractScalar = 0 + """The VCCBRAM voltage of the FPGA when each image was captured.""" + + temperature_fpga: u.Quantity | na.AbstractScalar = 0 + """The temperature of the FPGA when each image was captured.""" + + temperature_adc_1: u.Quantity | na.AbstractScalar = 0 + """Temperature 1 of the ADC when each image was captured.""" + + temperature_adc_2: u.Quantity | na.AbstractScalar = 0 + """Temperature 2 of the ADC when each image was captured.""" + + temperature_adc_3: u.Quantity | na.AbstractScalar = 0 + """Temperature 3 of the ADC when each image was captured.""" + + temperature_adc_4: u.Quantity | na.AbstractScalar = 0 + """Temperature 4 of the ADC when each image was captured.""" + + @classmethod + def from_sensor_data( + cls, + a: AbstractSensorData, + axis_tap_x: str = "tap_x", + axis_tap_y: str = "tap_y", + ) -> Self: + """ + Creates a new instance of this class using an instance of + :class:`msfc_ccd.SensorData`. + + Parameters + ---------- + a + An instance of :class:`msfc_ccd.SensorData` to convert. + axis_tap_x + The name of the logical axis corresponding to the horizontal + variation of the tap index. + axis_tap_y + The name of the logical axis corresponding to the vertical + variation of the tap index. + """ + + axis_x = a.axis_x + axis_y = a.axis_y + + num_x = a.num_x + num_y = a.num_y + + num_tap_x = cls.num_tap_x + num_tap_y = cls.num_tap_y + + shape_img = {axis_x: num_x, axis_y: num_y} + + num_x_new = num_x // num_tap_x + num_y_new = num_y // num_tap_y + + slice_left_x = slice(None, num_x_new) + slice_left_y = slice(None, num_y_new) + + slice_right_x = slice(None, num_x_new - 1, -1) + slice_right_y = slice(None, num_y_new - 1, -1) + + slices_x = [slice_left_x, slice_right_x] + slices_y = [slice_left_y, slice_right_y] + + pixel = a.pixel + + for ax in pixel: + p = pixel[ax].broadcast_to(shape_img) + pixel[ax] = na.stack( + arrays=[ + na.stack( + arrays=[p[{axis_x: sx, axis_y: sy}] for sy in slices_y], + axis=axis_tap_y, + ) + for sx in slices_x + ], + axis=axis_tap_x, + ) + + return cls( + data=a.data[pixel], + pixel=pixel, + axis_x=a.axis_x, + axis_y=a.axis_y, + axis_tap_x=axis_tap_x, + axis_tap_y=axis_tap_y, + time=a.time, + timedelta=a.timedelta, + timedelta_requested=a.timedelta_requested, + serial_number=a.serial_number, + run_mode=a.run_mode, + status=a.status, + voltage_fpga_vccint=a.voltage_fpga_vccint, + voltage_fpga_vccaux=a.voltage_fpga_vccaux, + voltage_fpga_vccbram=a.voltage_fpga_vccbram, + temperature_fpga=a.temperature_fpga, + temperature_adc_1=a.temperature_adc_1, + temperature_adc_2=a.temperature_adc_2, + temperature_adc_3=a.temperature_adc_3, + temperature_adc_4=a.temperature_adc_4, + ) diff --git a/msfc_ccd/_images/_tests/test_images.py b/msfc_ccd/_images/_tests/test_images.py index ebb5af9..5327fc2 100644 --- a/msfc_ccd/_images/_tests/test_images.py +++ b/msfc_ccd/_images/_tests/test_images.py @@ -14,6 +14,12 @@ def test_data(self, a: msfc_ccd.abc.AbstractImageData): result = a.data assert result.ndim >= 2 + def test_pixel(self, a: msfc_ccd.abc.AbstractImageData): + result = a.pixel + for ax in result: + assert isinstance(ax, str) + assert isinstance(result[ax], na.AbstractScalarArray) + def test_axis_x(self, a: msfc_ccd.abc.AbstractImageData): result = a.axis_x assert isinstance(result, str) @@ -24,6 +30,14 @@ def test_axis_y(self, a: msfc_ccd.abc.AbstractImageData): assert isinstance(result, str) assert result in a.data.shape + def test_num_x(self, a: msfc_ccd.abc.AbstractImageData): + result = a.num_x + assert isinstance(result, int) + + def test_num_y(self, a: msfc_ccd.abc.AbstractImageData): + result = a.num_y + assert isinstance(result, int) + def test_time(self, a: msfc_ccd.abc.AbstractImageData): result = a.time if not isinstance(result, astropy.time.Time): diff --git a/msfc_ccd/_images/_tests/test_sensor_images.py b/msfc_ccd/_images/_tests/test_sensor_images.py index 1fbf7d4..0e9ec0f 100644 --- a/msfc_ccd/_images/_tests/test_sensor_images.py +++ b/msfc_ccd/_images/_tests/test_sensor_images.py @@ -10,14 +10,16 @@ class AbstractTestAbstractSensorData( test_images.AbstractTestAbstractImageData, ): - pass + def test_taps(self, a: msfc_ccd.abc.AbstractSensorData): + result = a.taps() + assert isinstance(result, msfc_ccd.TapData) @pytest.mark.parametrize( argnames="a", argvalues=[ msfc_ccd.SensorData( - data=na.random.uniform(0, 1, shape_random=dict(x=21, y=11)), + data=na.random.uniform(0, 1, shape_random=dict(x=22, y=12)), axis_x="x", axis_y="y", time=astropy.time.Time("2024-03-25T20:49"), @@ -39,7 +41,7 @@ class AbstractTestAbstractSensorData( data=na.random.uniform( low=0, high=1, - shape_random=dict(t=5, x=21, y=11), + shape_random=dict(t=5, x=22, y=12), ), axis_x="x", axis_y="y", diff --git a/msfc_ccd/_images/_tests/test_tap_images.py b/msfc_ccd/_images/_tests/test_tap_images.py new file mode 100644 index 0000000..9084a93 --- /dev/null +++ b/msfc_ccd/_images/_tests/test_tap_images.py @@ -0,0 +1,38 @@ +import pytest +import named_arrays as na +import msfc_ccd +from . import test_images + + +class AbstractTestAbstractTapImage( + test_images.AbstractTestAbstractImageData, +): + def test_axis_tap_x(self, a: msfc_ccd.abc.AbstractTapData): + result = a.axis_tap_x + assert isinstance(result, str) + assert result in a.data.shape + + def test_axis_tap_y(self, a: msfc_ccd.abc.AbstractTapData): + result = a.axis_tap_y + assert isinstance(result, str) + assert result in a.data.shape + + def test_tap(self, a: msfc_ccd.abc.AbstractTapData): + result = a.tap + for ax in result: + assert isinstance(ax, str) + assert isinstance(result[ax], na.AbstractScalarArray) + + +@pytest.mark.parametrize( + argnames="a", + argvalues=[ + msfc_ccd.TapData.from_sensor_data( + a=msfc_ccd.fits.open(msfc_ccd.samples.path_fe55_esis1) + ), + ], +) +class TestTapImage( + AbstractTestAbstractTapImage, +): + pass diff --git a/msfc_ccd/_images/abc.py b/msfc_ccd/_images/abc.py index 9aa5fa5..995cbeb 100644 --- a/msfc_ccd/_images/abc.py +++ b/msfc_ccd/_images/abc.py @@ -5,7 +5,9 @@ __all__ = [ "AbstractImageData", "AbstractSensorData", + "AbstractTapData", ] from ._images import AbstractImageData from ._sensor_images import AbstractSensorData +from ._tap_images import AbstractTapData diff --git a/msfc_ccd/abc.py b/msfc_ccd/abc.py index 059b87a..8a90e48 100644 --- a/msfc_ccd/abc.py +++ b/msfc_ccd/abc.py @@ -5,6 +5,7 @@ __all__ = [ "AbstractImageData", "AbstractSensorData", + "AbstractTapData", ] -from ._images.abc import AbstractImageData, AbstractSensorData +from ._images.abc import AbstractImageData, AbstractSensorData, AbstractTapData