Exposures#

One of the strengths of basecam is that it allows to define a datamodel for the camera exposures, which is evaluated when the exposure is written. The datamodel is built upon three basic concepts: Cards which represent a header keyword-value pair; Extensions which represent a FITS extension with a header defined by cards, and specify how the data will be stored; and a FITS model which bundles up several extensions.

Each camera class and instance has an associated FITS model. When the camera expose method in a camera is called, it returns an Exposure object that includes the image data and additional metadata (exposure time, date of observation, stacking). When the exposure is written, the model is evaluated for that specific exposure and camera.

>>> exposure = await camera.expose(1.0)
>>> type(exposure.fits_model)
basecam.models.fits.FITSModel
>>> exposure.fits_model
[<Extension (name='raw', compressed=GZIP_2)>]
>>> list(exposure.fits_model[0].header_model)
[<DefaultCard (name='CAMNAME', value='{__camera__.name}')>,
 <DefaultCard (name='VCAM', value='{__camera__.__version__}')>,
 <DefaultCard (name='IMAGETYP', value='{__exposure__.image_type}')>,
 <DefaultCard (name='EXPTIME', value='{__exposure__.exptime}')>,
 <DefaultCard (name='EXPTIMEN', value='{__exposure__.exptime_n}')>,
 <DefaultCard (name='STACK', value='{__exposure__.stack}')>,
 <DefaultCard (name='STACKFUN', value='{__exposure__.stack_function.__name__}')>,
 <Card (name='TIMESYS', value='TAI')>,
 <Card (name='DATE-OBS', value='{__exposure__.obstime.tai}')>,
 <Card (name='CCDTEMP', value='{__camera__.status[temperature_ccd]}')>,
 <WCSCards (name=WCS information)>]

If we convert the exposure to an HDUList

>>> hdulist = exposure.to_hdu()
>>> hdulist[1].header
XTENSION= 'IMAGE   '           / Image extension
BITPIX  =                   16 / data type of original image
NAXIS   =                    2 / dimension of original image
NAXIS1  =                 2048 / length of original image axis
NAXIS2  =                 2048 / length of original image axis
PCOUNT  =                    0 / number of parameters
GCOUNT  =                    1 / number of groups
BSCALE  =                    1
BZERO   =                32768
CAMNAME = 'gfa0    '           / Camera name
VCAM    = '0.2.0-alpha.0'      / Version of the camera library
IMAGETYP= 'object  '           / The image type of the file
EXPTIME =                  1.0 / Exposure time of single integration [s]
EXPTIMEN=                  1.0 / Total exposure time [s]
STACK   =                    1 / Number of stacked frames
STACKFUN= 'median  '           / Function used for stacking
TIMESYS = 'TAI     '           / Time reference system
DATE-OBS= '2021-02-15 06:42:59.592779' / Time of the start of the exposure [TAI]
CCDTEMP =                -25.0 / Degrees C
WCSAXES =                    2 / Number of coordinate axes
CRPIX1  =                  0.0 / Pixel coordinate of reference point
CRPIX2  =                  0.0 / Pixel coordinate of reference point
CDELT1  =                  1.0 / Coordinate increment at reference point
CDELT2  =                  1.0 / Coordinate increment at reference point
CRVAL1  =                  0.0 / Coordinate value at reference point
CRVAL2  =                  0.0 / Coordinate value at reference point
LATPOLE =                 90.0 / [deg] Native latitude of celestial pole
MJDREF  =                  0.0 / [d] MJD of fiducial time
CHECKSUM= '7aV5AXS37aS3AUS3'   / HDU checksum updated 2021-02-14T22:42:23
DATASUM = '3919376360'         / data unit checksum updated 2021-02-14T22:42:23

Cards#

A card is simply a tuple of (name, value) or (name, value, comment) that defines a header keyword-value pair. In that sense they are similar to astropy’s Card objects. The main difference is that in basecam cards, the value can be defined as a placeholder that is evaluated in runtime. For example

>>> import datetime
>>> card = Card('DATE', '{date}', 'Some date')
>>> now = datetime.datetime.utcnow()
>>> card.evaluate(None, context={'date': now})
EvaluatedCard(name='date', value='2021-02-15 07:03:24.024889', comment='Some date')

Values can be defined following the same syntax as Python’s string templates. The values of the placeholders are specified via the context. The context can be specified at the moment of evaluating the card, but normally it’s defined at the FITS model level and passed down when evaluating the model for a given exposure. Note that we called evaluate with None as the first argument; normally evaluate is called with an Exposure instance. In this case two context parameters are automatically defined: __exposure__, which is replaced with the Exposure object, and __camera__ which is replaced with the camera that took the exposure. This allows us to define more useful cards

>>> exptime = Card('EXPTIME', '{__exposure__.exptime}', 'Exposure time')
>>> exptime.evaluate(exposure)
EvaluatedCard(name='EXPTIME', value=900., comment='Exposure time')
>>> camname = Card('CAMNAME', '{__camera__.name}', 'Camera name')
>>> camname.evaluate(exposure)
EvaluatedCard(name='CAMNAME', value='gfa1', comment='Camera name')

We can access any attribute or property of the context placeholders

>>> Card('CCDTEMP', '{__camera__.status[ccdtemp]}').evaluate(exposure)
EvaluatedCard(name='CCDTEMP', value=-30.1, comment='')

Note that in this case we don’t need to use quotes around status[ccdtemp]. Again, this is in line with Python’s string formatting.

By default, when the card is evaluated basecam will try to cast the value to a valid FITS type, or fall back to a string if that’s not possible. This can be disabled by passing autocast=False

>>> Card('CCDTEMP', '5.0').evaluate(exposure)
EvaluatedCard(name='CCDTEMP', value=5, comment='')
>>> Card('CCDTEMP', '5.0', autocast=False).evaluate(exposure)
EvaluatedCard(name='CCDTEMP', value='5.0', comment='')

It’s also possible to specify the casting type (this implies autocast=False)

>>> Card('CCDTEMP', '{__camera__.status[ccdtemp]}', type=int).evaluate(exposure)
EvaluatedCard(name='CCDTEMP', value=-30, comment='')

The value can be a function that is called at the time of evaluation

def f():
    return 10

>>> Card('FUNC', f).evaluate(exposure)
EvaluatedCard(name='FUNC', value=10, comment='')

In this case we can define arguments to be passed to the function, and those arguments can also be evaluated in runtime (note that in this case the arguments will be strings so the function needs to do the casting if necessary)

def square(value):
    return float(value)**2

>>> Card('SQEXPTIM', square, fargs=['{__exposure__.exptime}']).evaluate(exposure)
EvaluatedCard(name='SQEXPTIM', value=25.0, comment='')

Value expressions can be evaluated

>>> Card('SUM', "2+2", evaluate=True).evaluate(None)
EvaluatedCard(name='SUM', value=4, comment='')

In this case the variables in the context are accessible as local variables

>>> Card('CCDF', "__camera__.status[ccdtemp]*9/5+32", comment='CCD temperature in Fahrenheit').evaluate(exposure)
EvaluatedCard(name='CCDF', value=-25.6, comment='CCD temperature in Fahrenheit')

Note that in this case we don’t use curly brackets around the variables.

Default cards#

basecam defines a number of cards that are of general use. They are available at DEFAULT_CARDS and can be retrieved by creating a Card with the name of the default card and without a value. For example

>>> obstime = Card('obstime')
>>> obstime
DefaultCard("OBSTIME", value="{__exposure__.obstime.tai}", comment="Time of the start of the exposure [TAI]")

Advanced cards#

Card is very versatile but there are a couple other types of card classes that are also useful.

CardGroup allows to define a list of Card or default cards that is expanded when evaluated. This is useful to define cards that share a topic and allows reusability

>>> camcards = CardGroup(
    [
        "CAMNAME",
        Card("MODEL", "{__camera__.model}", "Camera model")]),
        ("VENDOR", "{__camera__.vendor}", "Camera manufacturer")
    ]
)

This assumes that the camera class has attributes model and vendor that have been set when the camera connects. Cards in the group can be defined as a single string which must be the name of a default card, or as a two- or three-item tuple that is evaluated to (key, value, [comment]).

MacroCard classes provide more flexibility to create cards or groups of cards. Let’s assume the code has access to some weather service weather. We can create a macro that returns a list of cards with weather information

class WeatherCards(MacroCard):
    def macro(self, exposure, context={}):
        truss_temp = weather.get_truss_temp()
        rh = weather.get_humid()
        dew_point = truss_temp - ((100 - rh) / 5.)
        return [('TEMP', truss_temp, 'Truss temperature (C)'),
                ('RELHUM', rh, 'Relative humidity (%)'),
                ('DEWPOINT', dew_point, 'Dew point temperature (C)')]

MacroCard needs to be subclassed and macro must be overridden with a method that returns a list of tuples. Macros are specially useful when combined with actors that have access to the state of the system. They can be used to, for example, add information about the telescope position and status.

WCS macro#

basecam includes a predefined WCSCards macro that returns a complete set of WCS astrometric cards. When used, the wcs attribute in the exposure must be set to a valid WCS object. This is usually done in _exposure_internal or before calling Exposure.to_hdu or Exposure.write. If Exposure.wcs=None a default WCS header is added.

FITS models#

A FITS model is equivalent to an HDUList, consisting of a list of Exposure, each one defining its own header model. Let’s start with a simple example

>>> header = HeaderModel(
    [
        "CAMNAME",
        "CAMUID",
        "IMAGETYP",
        "EXPTIME",
        Card("DATE-OBS", value="{__exposure__.obstime.tai.isot}", comment="Date (in TIMESYS) the exposure started")
        WeatherCards()
    ]
)
>>> model = FITSModel([Extension(header_model=header, name="PRIMARY")])

We’ve defined a header model with several default cards (CAMNAME, CAMUID, etc.), one card to record the time of the observation in ISOT format, and the WeatherCards macro that we defined above. Next, we created a FITS model with a single extension which we called "PRIMARY". To use this model when exposing, we can

>>> exposure = await camera.expose(15.0, fits_model=model)

or we can set it in the Exposure instance as exposure.fits_model=model. The model will then be used when Exposure.to_hdu or Exposure.write are called.

In Extension we can define the format of the data. To create an empty extension with a header

>>> empty_ext = Extension(data='none', header_model=header, name="EMPTY")

If data=None (the default), Exposure.data will be used to create the image HDU. We can define a compressed HDU

>>> compressed = Extension(header_model=header, compressed="RICE_1")

The available compression algorithms are the same as astropy’s CompImageHDU. Compressed HDUs cannot be the primary header of a FITS file, so in this case an empty HDU will be prepended as the primary extension.

Additional HDUs#

Sometimes one wants to add additional HDUs (be those images or binary tables) to an exposure. For example, one may perform source extraction after taking the exposure and want to store the resulting table of centroids in the resulting exposure. That can be accomplished by dynamically appending HDUs to Exposure

def _expose_internal(self, exposure, **kwargs):
    # Take the exposure (skipped for shortness)
    ...

    exposure.data = data

    sources = get_sources(exposure.data)  # sources is an astropy Table
    sources_hdu = astropy.io.fits.BinTableHDU(sources

    exposure.add_hdu(sources_hdu)

When we call .Exposure.to_hdu or .Exposure.write, the binary table will be added after the extensions defined by the FITS model. add_hdu accepts an index parameter that allows to specify where the extra HDU will be inserted. Note that FITS files require a primary HDU as the first extension, and that astropy may rearrange the HDUs to ensure it.

Naming images#

Exposure filenames can be defined manually or using an ImageNamer instance. The image namer allows to define a file path that is evaluated at the time at which the image is written

>>> image_namer = ImageNamer('{camera.name}-{num:04d}.fits', dirname='/data/images/{date.mjd}')
>>> img_path = image_namer(camera)
>>> print(img_path)
'/data/images/59260/gfa1-0012.fits'
>>> exposure.write(img_path)
>>> image_namer(camera)
'/data/images/59260/gfa1-0013.fits'

As with the cards, two values can be used in the templates: the camera instance, and the date (an astropy Time object) when the image namer is called. The num placeholder can be used to get the first available number in a sequence of images, ensuring that the new path doesn’t collide with any previous image.

Modifying the default model and image namer#

BaseCamera includes a default FITS model and image namer which are mean to provide general but basic functionality. The default model defines a single, uncompressed extension with the raw data and a basic header model. The default image namer writes returns new image paths in the current directory with format '{camera.name}-{num:04d}.fits'.

While these are reasonable defaults, normally one wants to customise the model and namer for a given camera class. This can be achieved when subclassing from BaseCamera

class MyCamera(BaseCamera):
    fits_model = my_fits_model
    image_namer = my_image_namer

    def __init__(self, *args, **kwargs):
        ...

The image namer can also be defined when instantiating a new camera: my_camera=MyCamera(..., image_namer=another_image_namer, ...).