msfc-ccd#

The sounding rocket team at Marshall Space Flight Center builds CCD cameras for solar instruments flown on sounding rockets. This library reads the FITS files those cameras produce, and models the camera and sensor well enough to turn a raw readout into a calibrated image.

Every array in this package is a named_arrays array, so the axes carry names such as detector_x rather than integer positions, and an image loaded from disk broadcasts against the other arrays in the stack without reshaping.

Installation#

msfc_ccd is published on PyPI and can be installed using:

pip install msfc-ccd

Features#

  • msfc_ccd.fits.open(), which loads one FITS file, or a sequence of them, into a single object.

  • msfc_ccd.SensorData, an image or a sequence of images as read from the sensor, along with the operations that calibrate it: taps, active, unbiased, and electrons.

  • msfc_ccd.TapData, the same image split into the four quadrants read out by the four taps of the sensor.

  • msfc_ccd.ImageHeader, the FITS metadata for each image, including the exposure time, the sensor and FPGA temperatures, and the timestamps.

  • msfc_ccd.Camera and msfc_ccd.TeledyneCCD230, models of the camera and its sensor, which carry the parameters needed to calibrate an image: gain, dark current, readout noise, charge transfer efficiency, and the exposure timing.

  • msfc_ccd.noise, msfc_ccd.dark, msfc_ccd.gain and msfc_ccd.cte, which measure the readout noise, dark current, gain and charge transfer efficiency of each tap from darks, flats and Fe 55 exposures.

  • msfc_ccd.samples, a handful of real FITS files gathered from the cameras, used by the examples throughout this documentation.

Key concepts#

An image pairs pixel values with the header that describes them. msfc_ccd.SensorData stores the pixel values in outputs, in data numbers (the DN unit from astropy.units), on axes named by axis_x and axis_y, and the FITS metadata in inputs as an msfc_ccd.ImageHeader. Loading many files at once adds another named axis to both, so a sequence of images is the same type as a single image.

The sensor is read out through four taps. Each quadrant of the CCD has its own amplifier, so a raw frame is really four images side by side, each with its own bias and gain. taps splits a frame into a msfc_ccd.TapData with tap_x and tap_y axes, and from_taps() reassembles one, flipping each quadrant back into the orientation of the sensor.

Blank columns measure the bias. Each tap reads out 50 blank columns before the light-sensitive pixels and 2 overscan columns after them. The blank columns are read before any charge from the image, so the mean of the ones closest to the image is an estimate of the bias for that tap. The overscan columns are read after the image, and pick up charge deferred from its last column, so they are not used. active trims them away, unbiased subtracts the bias they measure, and the two compose: image.taps.unbiased.active.

The first rows of each tap are masked. Each tap also reads out 8 masked rows before the image, num_masked. Their charge comes from the part of the frame store beside the image area, under the edge of the mask, so they receive only the light that leaks past the edge and they accumulate dark current about 80 times faster than the image. active keeps them, since a misaligned mask can let real signal reach them, and where_masked() selects them.

Operations on any image are methods, and measurements are functions. The methods of msfc_ccd.SensorData and msfc_ccd.TapData, such as unbiased and hits(), make sense for any image. Measuring a property of the camera needs a particular kind of exposure, such as a sequence of darks, a pair of flats or an Fe 55 exposure, so those measurements are functions instead, grouped by the quantity they measure into msfc_ccd.noise, msfc_ccd.dark, msfc_ccd.gain and msfc_ccd.cte. The measurements check the signal of their images, and raise a ValueError rather than return a number when the images are clearly the wrong kind, such as a dark passed to msfc_ccd.cte.eper(). Only the \(^{55}\text{Fe}\) measurements return numpy.nan, for a tap with too few events to fit.

Readout noise comes from a sequence of darks. Differencing two adjacent dark images cancels the bias, the dark current and the fixed pattern of the sensor, leaving only the readout noise of the two frames, so msfc_ccd.noise.readout() estimates it from the active pixels of each difference outside the masked rows, rather than from the columns at the edge of a single frame.

Dark current needs a range of exposure lengths. A two-second dark accumulates less than a tenth of a data number of dark current, far below the readout noise, so msfc_ccd.dark.current() averages over all the active pixels of each image outside the masked rows and fits the result against the measured exposure time of a set of darks taken at several exposure lengths. Only the slope of that fit is the dark current; the intercept also contains the offset between the blank columns and the active pixels, and the fixed pattern of the sensor.

Flats measure the gain from their shot noise. The difference of two flats gathered with the same illumination cancels the pattern of the illumination and the response of each pixel, leaving only shot noise and readout noise. The shot noise variance, in electrons, equals the signal, so msfc_ccd.noise.photon_transfer() traces out the variance against the signal, and msfc_ccd.gain.photon_transfer() turns it into a gain, independent of the Fe 55 measurement below.

Fe 55 events are isolated single-pixel hits. A 5.9 keV X-ray from an \(^{55}\text{Fe}\) source releases a known number of electrons in one pixel, so the charge it leaves is a ruler for the gain. hits() finds every pixel far enough above the dark level whose eight neighbors are not, and measures the charge of the event as the sum of the surrounding three by three region, which recovers the charge that spilled into the neighbors. It returns an image of the same shape with the charge of each event in place and numpy.nan elsewhere, so a sequence of images is pooled by taking a histogram along the sequence axis and the two detector axes.

Converting to electrons needs a gain. electrons multiplies by msfc_ccd.Camera.gain, which is usually different for each tap and has to be measured. A msfc_ccd.Camera constructed without one, which is what msfc_ccd.fits.open() uses by default, has no gain to apply, and raises a ValueError naming the missing parameter rather than guessing. msfc_ccd.gain.fe55() measures it from an \(^{55}\text{Fe}\) exposure, and the result goes straight into msfc_ccd.Camera(gain=...).

Charge transfer efficiency comes from two kinds of image. Each transfer on the way to the amplifier leaves a small fraction of the charge in a pixel behind. msfc_ccd.cte.eper() measures the serial efficiency from a flat, using the charge that trails into the overscan columns after the last active pixel of each row. msfc_ccd.cte.fe55() measures both the serial and the parallel efficiency from how the charge of Fe 55 events falls with their distance from the amplifier, which takes several hundred images.

Examples#

Load and display a single FITS file.

import matplotlib.pyplot as plt
import named_arrays as na
import msfc_ccd

# Load the sample image
image = msfc_ccd.fits.open(msfc_ccd.samples.path_fe55_esis1)

# Display the sample image
fig, ax = plt.subplots(
    constrained_layout=True,
)
im = na.plt.imshow(
    image.outputs.value,
    axis_x=image.axis_x,
    axis_y=image.axis_y,
    ax=ax,
);
_images/index_0_0.png

Measure the bias of each tap, and remove it.

# Split the frame into the four tap images
taps = image.taps

# Each tap has its own amplifier, and so its own bias
taps.bias().outputs.ndarray
\[[[3572.0112,~3806.9828],~ [3652.9992,~3447.4969]] \; \mathrm{DN}\]
# Subtract the bias and trim the blank and overscan columns
corrected = image.from_taps(taps.unbiased.active)

# Display the corrected image
fig, ax = plt.subplots(
    constrained_layout=True,
)
im = na.plt.imshow(
    corrected.outputs.value,
    axis_x=corrected.axis_x,
    axis_y=corrected.axis_y,
    ax=ax,
    vmin=-20,
    vmax=100,
);
_images/index_2_0.png

Measure the readout noise of each tap from a pair of adjacent dark images.

import numpy as np

# Define the name of the time axis
axis_time = "time"

# Load two adjacent dark images as a sequence
darks = msfc_ccd.fits.open(
    path=na.ScalarArray(
        ndarray=np.array([
            msfc_ccd.samples.path_led_dark_esis1,
            msfc_ccd.samples.path_led_dark_esis1_next,
        ]),
        axes=axis_time,
    ),
)

# The difference of adjacent frames leaves only the readout noise
msfc_ccd.noise.readout(darks.taps, axis_time).outputs.ndarray
\[ \begin{align}\begin{aligned}[[[4.0287664],~ [3.8681375]],~\\ [[4.1737051],~ [4.243461]]] \; \mathrm{DN}\end{aligned}\end{align} \]

Measure the dark current rate of each tap from a pair of dark images with different exposure lengths.

# Load a two-second dark image and a twelve-second dark image
darks = msfc_ccd.fits.open(
    path=na.ScalarArray(
        ndarray=np.array([
            msfc_ccd.samples.path_dark_2s_esis1,
            msfc_ccd.samples.path_dark_12s_esis1,
        ]),
        axes=axis_time,
    ),
)

# The slope of the signal against the exposure time is the dark current
msfc_ccd.dark.current(darks.taps, axis_time).outputs.to("DN / s").ndarray
\[[[0.024812228,~0.02456324],~ [0.012261405,~0.019747113]] \; \mathrm{\frac{DN}{s}}\]

Measure the gain of each tap from a pair of flats, gathered two seconds apart with the same illumination.

# Load two consecutive images of a diffuse LED source
flats = msfc_ccd.fits.open(
    path=na.ScalarArray(
        ndarray=np.array([
            msfc_ccd.samples.path_led_esis1,
            msfc_ccd.samples.path_led_esis1_next,
        ]),
        axes=axis_time,
    ),
)

# The ratio of the signal to the shot noise variance is the gain
msfc_ccd.gain.photon_transfer(flats.taps, axis_time).outputs.ndarray
\[[[2.5292256,~2.5076198],~ [2.5297409,~2.5115486]] \; \mathrm{\frac{e^{-}}{DN}}\]

Measure the gain of each tap from an Fe 55 exposure, and use it to convert an image into electrons.

# Load an Fe 55 exposure and measure the gain of each tap
fe55 = msfc_ccd.fits.open(msfc_ccd.samples.path_fe55_esis3)
gain = msfc_ccd.gain.fe55(fe55.taps).outputs

gain.ndarray
\[[[2.5294598,~2.5800594],~ [2.5301461,~2.517482]] \; \mathrm{\frac{e^{-}}{DN}}\]
# Build a camera with that gain, and reload the image through it
camera = msfc_ccd.Camera(gain=gain)
calibrated = msfc_ccd.fits.open(
    path=msfc_ccd.samples.path_fe55_esis3,
    camera=camera,
)

calibrated.taps.unbiased.active.electrons.outputs.sum().ndarray
\[11835723 \; \mathrm{e^{-}}\]

Inspect the header of an image.

header = image.inputs

print(f"serial number:  {header.serial_number.ndarray}")
print(f"exposure:       {header.timedelta.ndarray}")
print(f"start:          {header.time_start.ndarray}")
print(f"FPGA temp:      {header.temperature_fpga.ndarray}")
serial number:  6
exposure:       1.999999975 s
start:          2017-07-06T16:36:48.449
FPGA temp:      38.881396484375045 deg_C

Inspect the sensor model that the calibration steps rely on.

import astropy.units as u

sensor = image.camera.sensor

print(f"sensor:          {sensor.manufacturer} {sensor.family}")
print(f"active pixels:   {sensor.num_pixel_x} x {sensor.num_pixel_y}")
print(f"blank/overscan:  {sensor.num_blank} / {sensor.num_overscan}")
print(f"readout noise:   {sensor.readout_noise}")
print(f"charge transfer: {sensor.cte}")
print(f"dark current:    {sensor.dark_current(263 * u.K):0.3f} at 263 K")
sensor:          Teledyne/e2v CCD230-42
active pixels:   2048 x 2064
blank/overscan:  50 / 2
readout noise:   4.0 electron
charge transfer: 99.9995 %
dark current:    1.925 electron / s at 263 K

Citation#

If you use msfc_ccd in your research, please cite it. The citation metadata is kept in CITATION.cff, which the “Cite this repository” button on the GitHub page can export as BibTeX or APA.

Every release of msfc_ccd is archived on Zenodo with its own DOI. The concept DOI, 10.5281/zenodo.23090943, always resolves to the latest version, and the Zenodo page lists the DOI of every version. Please include the version of msfc_ccd that you used, which is given by importlib.metadata.version("msfc-ccd"). The BibTeX entry below uses the concept DOI. To cite a specific version instead, replace doi with the DOI of that version.

@software{msfc-ccd,
  author = {Smart, Roy T. and Parker, Jacob D.},
  title = {msfc-ccd},
  version = {X.Y.Z},
  doi = {10.5281/zenodo.23090943},
  url = {https://github.com/sun-data/msfc-ccd},
}

API Reference#

msfc_ccd

A Python package for the CCD cameras developed by MSFC.


Reports#

Jupyter notebook investigations which help to characterize the CCDs and justify the decisions made in this package.


References#

[1]

I. V. Kotov, H. Neal, and P. O'Connor. Pair creation energy and Fano factor of silicon measured at 185 K using ⁵⁵Fe X-rays. Nuclear Instruments and Methods in Physics Research Section A, 2018. URL: https://doi.org/10.1016/j.nima.2018.06.022, doi:10.1016/j.nima.2018.06.022.

[2]

U. Schötzig. Half-life and X-ray emission probabilities of ⁵⁵Fe. Applied Radiation and Isotopes, 53(4–5):469–472, 2000. URL: https://doi.org/10.1016/S0969-8043(00)00166-4, doi:10.1016/S0969-8043(00)00166-4.


Indices and tables#