Advanced Features

Creating complex data products

In the previous example A Complete Recipe the utility functions of the cpl.dfs module were used to create a ESO standards compliant image data product. While there are also utilities to create image list and table data products, it may happen that these utilities are not enough. For instance there is no utility function available which would create a data product which has several extensions (e.g. data, variance and quality information). How such products can still, easily be created is shown in this example.

The example is again the basic_science recipe example. However here, the way the product file is created has changed. Instead of creating a FITS product file where the image data is stored in the FITS primary data unit, a FITS file with an ESO standard compliant primary FITS header and an empty FITS primary data unit is created first. The result image is then added to this product FITS file as an image extension:

from typing import Any, Dict

from cpl import core
from cpl import ui
from cpl import dfs
from cpl.core import Msg


class ScienceDataProcessor(ui.PyRecipe):
    # Fill in recipe information
    _name = "basic_science_extensions"
    _version = "1.0"
    _author = "U.N. Owen"
    _email = "unowen@somewhere.net"
    _copyright = "GPL-3.0-or-later"
    _synopsis = "Basic science image data processing writing products with extensions"
    _description = (
        "The recipe combines all science input files in the input set-of-frames using\n"
        + "the given method. For each input science image the master bias is subtracted,\n"
        + "and it is divided by the master flat. When creating the product the result\n"
        + "image is stored in an image extension demonstrating the assembly of complex\n"
        + "product files "
    )

    def __init__(self) -> None:
        super().__init__()

        # The recipe will have a single enumeration type parameter, which allows the
        # user to select the frame combination method.
        self.parameters = ui.ParameterList(
            (
                ui.ParameterEnum(
                    name="basic_science.stacking.method",
                    context="basic_science",
                    description="Name of the method used to combine the input images",
                    default="add",
                    alternatives=("add", "average", "median"),
                ),
            )
        )

    def run(self, frameset: ui.FrameSet, settings: Dict[str, Any]) -> ui.FrameSet:
        # Update the recipe paramters with the values requested by the user through the
        # settings argument
        for key, value in settings.items():
            try:
                self.parameters[key].value = value
            except KeyError:
                Msg.warning(
                    self.name,
                    f"Settings includes {key}:{value} but {self} has no parameter named {key}.",
                )

        raw_frames = ui.FrameSet()
        product_frames = ui.FrameSet()
        bias_frame = None
        flat_frame = None

        output_file = "OBJECT_REDUCED.fits"

        # Go through the list of input frames, check the tag and act accordingly
        for frame in frameset:
            if frame.tag == "OBJECT":
                frame.group = ui.Frame.FrameGroup.RAW
                raw_frames.append(frame)
                Msg.debug(self.name, f"Got raw frame: {frame.file}.")
            elif frame.tag == "MASTER_BIAS":
                frame.group = ui.Frame.FrameGroup.CALIB
                bias_frame = frame
                Msg.debug(self.name, f"Got bias frame: {frame.file}.")
            elif frame.tag == "MASTER_FLAT":
                frame.group = ui.Frame.FrameGroup.CALIB
                flat_frame = frame
                Msg.debug(self.name, f"Got flat field frame: {frame.file}.")
            else:
                Msg.warning(
                    self.name,
                    f"Got frame {frame.file!r} with unexpected tag {frame.tag!r}, ignoring.",
                )

        # For demonstration purposes we raise an exception here. Real world
        # recipes should rather print a message (also to have it in the log file)
        # and exit gracefully.
        if len(raw_frames) == 0:
            raise core.DataNotFoundError("No raw frames in frameset.")

        # By default images are loaded as Python float data. Raw image
        # data which is usually represented as 2-byte integer data in a
        # FITS file is converted on the fly when an image is loaded from
        # a file. It is however also possible to load images without
        # performing this conversion.
        bias_image = None
        if bias_frame:
            bias_image = core.Image.load(bias_frame.file)
            Msg.info(self.name, f"Loaded bias frame {bias_frame.file!r}.")
        else:
            raise core.DataNotFoundError("No bias frame in frameset.")

        flat_image = None
        if flat_frame:
            flat_image = core.Image.load(flat_frame.file)
            Msg.info(self.name, f"Loaded flat frame {flat_frame.file!r}.")
        else:
            raise core.DataNotFoundError("No flat frame in frameset.")

        # Flat field preparation: subtract bias and normalize it to median 1
        Msg.info(self.name, "Preparing flat field")
        flat_image.subtract(bias_image)
        median = flat_image.get_median()
        flat_image.divide_scalar(median)

        header = None
        processed_images = core.ImageList()
        for idx, frame in enumerate(raw_frames):
            Msg.info(self.name, f"Processing {frame.file!r}...")

            if idx == 0:
                header = core.PropertyList.load(frame.file, 0)

            Msg.debug(self.name, "Loading image.")
            raw_image = core.Image.load(frame.file)

            Msg.debug(self.name, "Bias subtracting...")
            raw_image.subtract(bias_image)

            Msg.debug(self.name, "Flat fielding...")
            raw_image.divide(flat_image)

            # Insert the processed image in an image list. Of course
            # there is also an append() method available.
            processed_images.insert(idx, raw_image)

        # Combine the images in the image list using the image stacking
        # option requested by the user.
        method = self.parameters["basic_science.stacking.method"].value
        Msg.info(self.name, f"Combining images using method {method!r}")

        combined_image = None
        if method == "add":
            for idx, image in enumerate(processed_images):
                if idx == 0:
                    combined_image = image
                else:
                    combined_image.add(image)
        elif method == "average":
            combined_image = processed_images.collapse_create()
        elif method == "median":
            combined_image = processed_images.collapse_median_create()
        else:
            Msg.error(
                self.name,
                f"Got unknown stacking method {method!r}. Stopping right here!",
            )
            # Since we did not create a product we need to return an empty
            # ui.FrameSet object. The result frameset product_frames will do,
            # it is still empty here!
            return product_frames

        # Create property list specifying the product tag of the processed image
        product_properties = core.PropertyList()
        product_properties.append(
            core.Property("ESO PRO CATG", core.Type.STRING, r"OBJECT_REDUCED")
        )

        # Save the result image as a FITS file with an empty primary FITS
        # data unit and an image extension. The primary FITS header is
        # written using the DFS utility functions, which will make sure that
        # the product file will be compliant with the ESO data product
        # standards. The extension header and data unit is then attached
        # using the basic cpl.core.Image file saving routines.
        Msg.info(self.name, f"Saving product file as {output_file!r}.")
        dfs.save_propertylist(
            frameset,
            self.parameters,
            frameset,
            self.name,
            product_properties,
            f"demo/{self.version!r}",
            output_file,
            header=header,
        )

        # Extend the now existing product file by adding the image extension to
        # the end of the file. Since the extension header is just a normal FITS
        # extension header, writing the image extension does not need any special
        # treatment compared to the pirmary FITS header. Thus the basic cpl.core.Image
        # saving function is be used.
        # In this example the extension header will not contain any additional
        # keywords. I contains only what is needed to be a valid FITS image extension.
        # This can be achieved by passing an empty property list to the image saving
        # function.
        combined_image.save(output_file, core.PropertyList(), core.io.EXTEND)

        # Register the created product
        product_frames.append(
            ui.Frame(
                file=output_file,
                tag="OBJECT_REDUCED",
                group=ui.Frame.FrameGroup.PRODUCT,
                level=ui.Frame.FrameLevel.FINAL,
                frameType=ui.Frame.FrameType.IMAGE,
            )
        )

        return product_frames

The primary FITS header, is written to a file using the utility functions from the cpl.dfs module to make sure that the primary FITS header of the output file meets the formal requirements of a standard ESO data product. Because an extension header is just a normal FITS header, the basic PyCPL saving functions can be used to add extensions to the product file.

Note that an extension header should still follow the general ESO guidelines for data products or may need to have additional keywords to be compliant with the ESO Science Data Producs standard. For instance the extension header should have keywords like EXTNAME added, or other keywords characterizing and describing the data in this extension. This has been skipped here deliberately to illustrate the situation where no additional information should be passed, i.e. how an empty header (except for the information needed for a valid FITS image extension) can be created.

Error handling

Errors from PyCPL

In Python errors which occur in the code at runtime are reported through exceptions, which can be raised, catched and handled, or re-raised. In order to fit within this environment returning error codes, which is the way C libraries usually deal with errors, is not the best solution.

For this reason PyCPL provides custom exceptions which seamlessly integrate with the standard Python exceptions. These custom PyCPL exceptions are also representing the errors of the CPL C library which are translated into exceptions. Because these translated errors have become Python exceptions, they can be used in exactly the same way as any other Python exception, i.e. they can be raised, caught, handled and re-raised.

Python code using PyCPL exceptions

Python code can raise any of the custom PyCPL exceptions. This is illustrated in the following.

The recipe expects to find input frames of type OBJECT in the input set-of-frames file. Therefore feeding basic_science with inappropriate input data, for instance the BIAS frames of the gimasterbias recipe, will raise a custom PyCPL exception, cpl.core.DataNotFoundError.

Because the exceptions is not handled the recipe will terminate showing the familiar Python traceback output.

>>> p = Pyesorex()
[ INFO  ] pyesorex: This is PyEsoRex, version 1.0.0.
>>> p.recipe = "basic_science"
[ INFO  ] pyesorex: Loaded recipe 'basic_science'.
>>> p.sof_location = "bias.sof"
>>> p.run()
[ INFO  ] pyesorex: Running recipe 'basic_science'...
[WARNING] basic_science: Got frame '/home/dummy/test/bias/GIRAF.2019-06-25T09:47:34.623.fits' with unexpected tag 'BIAS', ignoring.
[WARNING] basic_science: Got frame '/home/dummy/test/bias/GIRAF.2019-06-25T09:52:45.527.fits' with unexpected tag 'BIAS', ignoring.
[WARNING] basic_science: Got frame '/home/dummy/test/bias/GIRAF.2019-06-25T09:57:55.430.fits' with unexpected tag 'BIAS', ignoring.
[WARNING] basic_science: Got frame '/home/dummy/test/bias/GIRAF.2019-06-25T10:03:05.335.fits' with unexpected tag 'BIAS', ignoring.
[WARNING] basic_science: Got frame '/home/dummy/test/bias/GIRAF.2019-06-25T10:08:15.239.fits' with unexpected tag 'BIAS', ignoring.
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/dummy/VirtualEnvs/pycpl/lib/python3.12/site-packages/pyesorex/pyesorex.py", line 1231, in run
    result = self.recipe.run(self.sof, self.recipe_parameters.as_dict())
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/dummy/test/pyrecipes/pycpl_recipe_basic_science.py", line 71, in run
    raise core.DataNotFoundError("No raw frames in frameset.")
cpl.core.DataNotFoundError: Most recent error last:
  File "/home/dummy/test/pyrecipes/pycpl_recipe_basic_science.py", line 71, in run
No raw frames in frameset.

Errors from CPL

When calling a PyCPL function results in an error in the underlying CPL function, the error from the CPL library is translated into a Python exception. It can be used like any other exception in Python.

For example, trying to create a PyCPL cpl.core.Mask object with illegal, negative dimensions, results in an cpl.core.IllegalInputError:

>>> from cpl.core import Mask, IllegalInputError, Msg
>>> mask = Mask(2, 2)
>>> print(mask)
#----- mask: 1 <= x <= 2, 1 <= y <= 2 -----
        X       Y       value
        1       1       0
        1       2       0
        2       1       0
        2       2       0

>>> mask - Mask(2, -2)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 87, in __new__
cpl.core.IllegalInputError: Most recent error last:
  File "../../../cplcore/cpl_mask.c", line 798, in cpl_mask_new
Illegal input

Compared to the previous example where a PyCPL exception was raised directly from Python code, the exception here is actually triggered by an error reported by the underlying CPL function. This is visible in the resulting Python traceback, which shows the CPL source file, line number and function where the problem was detected.

Because CPL errors become ordinary Python exceptions they can be used like those, i.e. they can be raised, caught, handled or re-raised:

>>> def mask_maker(*args):
...     try:
...         mask = Mask(*args)
...     except IllegalInputError as err:
...         Msg.error("mask_maker", f"Tried to create Mask with illegal dimensions: {args!r}")
...         raise err
...     return mask
...
>>> Msg.set_config(show_domain=False, show_component=True)
>>> mask_maker(1, 3)
#----- mask: 1 <= x <= 1, 1 <= y <= 3 -----
        X       Y       value
        1       1       0
        1       2       0
        1       3       0

>>> mask_maker(0, 3)
[ ERROR ] mask_maker: Tried to create Mask with illegal dimensions: (0, 3)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 6, in mask_maker
  File "<stdin>", line 3, in mask_maker
  File "<string>", line 87, in __new__
cpl.core.IllegalInputError: Most recent error last:
  File "../../../cplcore/cpl_mask.c", line 797, in cpl_mask_new
Illegal input

Errors occurring in native CPL Recipes

When an application runs a native CPL recipe as a plug-in and an error occurs inside the CPL recipe causing it to terminate for instance with a segmentation fault it will also terminate the the calling application because the plug-in runs as part of this process.

This is not a desirable behaviour if this happens to a user’s Python session while processing data using ESO instrument pipelines and PyCPL. In order to prevent a user’s Python session being torn down by a faulty native CPL recipe, PyCPL executes native CPL recipes in a separate child process. A failing CPL recipe will therefore only affect the child process, but not the Python interpreter that was used to call the recipe. Within the Python interpreter a forcibly terminated CPL recipe is therefore simply reported as an error.

Running Recipes from Python using Low-Level PyCPL Interfaces

TBD

Debugging Native CPL Recipes when using PyCPL

TBD