Writing FITS Images

yt has capabilities for writing 2D and 3D uniformly gridded data generated from datasets to FITS files. This is via the FITSImageData class. We’ll test these capabilities out on an Athena dataset.

[1]:
import yt
[2]:
units_override = {
    "length_unit": (1.0, "Mpc"),
    "mass_unit": (1.0e14, "Msun"),
    "time_unit": (1.0, "Myr"),
}
ds = yt.load("MHDSloshing/virgo_low_res.0054.vtk", units_override=units_override)

Creating FITS images from Slices and Projections

There are several ways to make a FITSImageData instance. The most intuitive ways are to use the FITSSlice, FITSProjection, FITSOffAxisSlice, and FITSOffAxisProjection classes to write slices and projections directly to FITS. To demonstrate a useful example of creating a FITS file, let’s first make a ProjectionPlot:

[3]:
prj = yt.ProjectionPlot(
    ds,
    "z",
    ("gas", "temperature"),
    weight_field=("gas", "density"),
    width=(500.0, "kpc"),
)
prj.show()

Suppose that we wanted to write this projection to a FITS file for analysis and visualization in other programs, such as ds9. We can do that using FITSProjection:

[4]:
prj_fits = yt.FITSProjection(
    ds, "z", ("gas", "temperature"), weight_field=("gas", "density")
)

which took the same parameters as ProjectionPlot except the width, because FITSProjection and FITSSlice always make slices and projections of the width of the domain size, at the finest resolution available in the simulation, in a unit determined to be appropriate for the physical size of the dataset.

We can also set width manually in FITSProjection. For example, set the width to 500 kiloparsec to get FITS file of the same projection plot as discussed above.

[5]:
prj_fits = yt.FITSProjection(
    ds,
    "z",
    ("gas", "temperature"),
    weight_field=("gas", "density"),
    width=(500.0, "kpc"),
)

If you want the center coordinates of the image in either a slice or a projection to be (0,0) instead of the domain coordinates, set origin="image":

[6]:
prj_fits_img = yt.FITSProjection(
    ds,
    "z",
    ("gas", "temperature"),
    weight_field=("gas", "density"),
    width=(500.0, "kpc"),
    origin="image",
)

Making FITS images from Particle Projections

To create a FITS image from a particle field which is smeared onto the image, we can use FITSParticleProjection:

[7]:
dsp = yt.load("gizmo_64/output/snap_N64L16_135.hdf5")
prjp_fits = yt.FITSParticleProjection(
    dsp, "x", ("PartType1", "particle_mass"), deposition="cic"
)
prjp_fits.writeto("prjp.fits", overwrite=True)

Note that we used the “Cloud-In-Cell” interpolation method ("cic") instead of the default “Nearest-Grid-Point” ("ngp") method.

If you want the projection to be divided by the pixel area (to make a projection of mass density, for example), supply the density keyword argument:

[8]:
prjpd_fits = yt.FITSParticleProjection(
    dsp, "x", ("PartType1", "particle_mass"), density=True, deposition="cic"
)
prjpd_fits.writeto("prjpd.fits", overwrite=True)

FITSParticleOffAxisProjection can be used to make a projection along any arbitrary sight line:

[9]:
L = [1, -1, 1]  # normal or "line of sight" vector
N = [0, 0, 1]  # north or "up" vector
poff_fits = yt.FITSParticleOffAxisProjection(
    dsp, L, ("PartType1", "particle_mass"), deposition="cic", north_vector=N
)
poff_fits.writeto("poff.fits", overwrite=True)

Using HDUList Methods

We can call a number of the AstroPy ``HDUList` <https://astropy.readthedocs.io/en/latest/io/fits/api/hdulists.html>`__ class’s methods from a FITSImageData object. For example, info shows us the contents of the virtual FITS file:

[10]:
prj_fits.info()
Filename: (No file associated with this FITSImageData)
No.    Name      Ver    Type      Cards   Dimensions   Format     Units
  0  TEMPERATURE    1 PrimaryHDU      29   (512, 512)   float64   K

We can also look at the header for a particular field:

[11]:
prj_fits["temperature"].header
[11]:
SIMPLE  =                    T / conforms to FITS standard
BITPIX  =                  -64 / array data type
NAXIS   =                    2 / number of array dimensions
NAXIS1  =                  512
NAXIS2  =                  512
EXTEND  =                    T
EXTNAME = 'TEMPERATURE'        / extension name
BTYPE   = 'temperature'
BUNIT   = 'K       '
LUNIT   =                  1.0 / [kpc]
TUNIT   =                  1.0 / [Myr]
MUNIT   =    100000000000000.0 / [Msun]
VUNIT   =                  1.0 / [Mpc/Myr]
BFUNIT  =  0.02851538907227106 / [G]
TIME    =             2700.111
WCSAXES =                    2
CRPIX1  =                256.5
CRPIX2  =                256.5
CDELT1  =            0.9765625
CDELT2  =            0.9765625
CUNIT1  = 'kpc     '
CUNIT2  = 'kpc     '
CTYPE1  = 'LINEAR  '
CTYPE2  = 'LINEAR  '
CRVAL1  =                  0.0
CRVAL2  =                  0.0
LATPOLE =                 90.0
WCSNAME = 'yt      '
MJDREF  =                  0.0

where we can see that the units of the temperature field are Kelvin and the cell widths are in kiloparsecs. Note that the length, time, mass, velocity, and magnetic field units of the dataset have been copied into the header

If we want the raw image data with units, we can use the data attribute of this field:

[12]:
prj_fits["temperature"].data
[12]:
unyt_array([[34403626.80544101, 34403626.80544101, 34403626.80544101, ...,
        30453524.91959378, 30453524.91959378, 30453524.91959378],
       [34403626.80544101, 34403626.80544101, 34403626.80544101, ...,
        30453524.91959378, 30453524.91959378, 30453524.91959378],
       [34403626.80544101, 34403626.80544101, 34403626.80544101, ...,
        30453524.91959378, 30453524.91959378, 30453524.91959378],
       ...,
       [35071597.64056484, 35071597.64056484, 35071597.64056484, ...,
        30673962.05243628, 30673962.05243628, 30673962.05243628],
       [35071597.64056484, 35071597.64056484, 35071597.64056484, ...,
        30673962.05243628, 30673962.05243628, 30673962.05243628],
       [35071597.64056484, 35071597.64056484, 35071597.64056484, ...,
        30673962.05243628, 30673962.05243628, 30673962.05243628]], 'K')

Changing Aspects of the Images

We can use the set_unit method to change the units of a particular field:

[13]:
prj_fits.set_unit("temperature", "R")
prj_fits["temperature"].data
[13]:
unyt_array([[61926528.24979382, 61926528.24979382, 61926528.24979382, ...,
        54816344.8552688 , 54816344.8552688 , 54816344.8552688 ],
       [61926528.24979382, 61926528.24979382, 61926528.24979382, ...,
        54816344.8552688 , 54816344.8552688 , 54816344.8552688 ],
       [61926528.24979382, 61926528.24979382, 61926528.24979382, ...,
        54816344.8552688 , 54816344.8552688 , 54816344.8552688 ],
       ...,
       [63128875.75301671, 63128875.75301671, 63128875.75301671, ...,
        55213131.69438529, 55213131.69438529, 55213131.69438529],
       [63128875.75301671, 63128875.75301671, 63128875.75301671, ...,
        55213131.69438529, 55213131.69438529, 55213131.69438529],
       [63128875.75301671, 63128875.75301671, 63128875.75301671, ...,
        55213131.69438529, 55213131.69438529, 55213131.69438529]], 'R')

The length units of the image (and its coordinate system), as well as the resolution of the image, can be adjusted when creating it using the length_unit and image_res keyword arguments, respectively:

[14]:
# length_unit defaults to that from the dataset
# image_res defaults to 512
slc_fits = yt.FITSSlice(
    ds, "z", ("gas", "density"), width=(500, "kpc"), length_unit="ly", image_res=256
)
WARNING: UnitsWarning: 'ly' did not parse as fits unit: At col 0, Unit 'ly' not supported by the FITS standard. Did you mean lyr? If this is meant to be a custom unit, define it with 'u.def_unit'. To have it recognized inside a file reader or other code, enable it with 'u.add_enabled_units'. For details, see https://docs.astropy.org/en/latest/units/combining_and_defining.html [astropy.units.core]

We can now check that this worked by looking at the header, notice in particular the NAXIS[12] and CUNIT[12] keywords (the CDELT[12] and CRPIX[12] values also change):

[15]:
slc_fits["density"].header
[15]:
SIMPLE  =                    T / conforms to FITS standard
BITPIX  =                  -64 / array data type
NAXIS   =                    2 / number of array dimensions
NAXIS1  =                  256
NAXIS2  =                  256
EXTEND  =                    T
EXTNAME = 'DENSITY '           / extension name
BTYPE   = 'density '
BUNIT   = 'g/cm**3 '
LUNIT   =                  1.0 / [ly]
TUNIT   =                  1.0 / [Myr]
MUNIT   =    100000000000000.0 / [Msun]
VUNIT   =                  1.0 / [Mpc/Myr]
BFUNIT  =  0.02851538907227106 / [G]
TIME    =             2700.111
WCSAXES =                    2
CRPIX1  =                128.5
CRPIX2  =                128.5
CDELT1  =      6370.3778101354
CDELT2  =      6370.3778101354
CUNIT1  = 'ly      '
CUNIT2  = 'ly      '
CTYPE1  = 'LINEAR  '
CTYPE2  = 'LINEAR  '
CRVAL1  =                  0.0
CRVAL2  =                  0.0
LATPOLE =                 90.0
WCSNAME = 'yt      '
MJDREF  =                  0.0

Saving and Loading Images

The image can be written to disk using the writeto method:

[16]:
prj_fits.writeto("sloshing.fits", overwrite=True)

Since yt can read FITS image files, it can be loaded up just like any other dataset. Since we created this FITS file with FITSImageData, the image will contain information about the units and the current time of the dataset:

[17]:
ds2 = yt.load("sloshing.fits")

and we can make a SlicePlot of the 2D image, which shows the same data as the previous image:

[18]:
slc2 = yt.SlicePlot(ds2, "z", ("gas", "temperature"), width=(500.0, "kpc"))
slc2.set_log("temperature", True)
slc2.show()

Creating FITSImageData Instances Directly from FRBs, PlotWindow instances, and 3D Grids

If you want more fine-grained control over what goes into the FITS file, you can call FITSImageData directly, with various kinds of inputs. For example, you could use a FixedResolutionBuffer, and specify you want the units in parsecs instead:

[19]:
slc3 = ds.slice(0, 0.0)
frb = slc3.to_frb((500.0, "kpc"), 800)
fid_frb = frb.to_fits_data(
    fields=[("gas", "density"), ("gas", "temperature")], length_unit="pc"
)

If one creates a PlotWindow instance, e.g. SlicePlot, ProjectionPlot, etc., you can also call this same method there:

[20]:
fid_pw = prj.to_fits_data(
    fields=[("gas", "density"), ("gas", "temperature")], length_unit="pc"
)

A 3D FITS cube can also be created from regularly gridded 3D data. In yt, there are covering grids and “arbitrary grids”. The easiest way to make an arbitrary grid object is using ds.r, where we can index the dataset like a NumPy array, creating a grid of 1.0 Mpc on a side, centered on the origin, with 64 cells on a side:

[21]:
grid = ds.r[
    (-0.5, "Mpc"):(0.5, "Mpc"):64j,
    (-0.5, "Mpc"):(0.5, "Mpc"):64j,
    (-0.5, "Mpc"):(0.5, "Mpc"):64j,
]
fid_grid = grid.to_fits_data(
    fields=[("gas", "density"), ("gas", "temperature")], length_unit="Mpc"
)

Other FITSImageData Methods

Creating Images from Others

A FITSImageData instance can be generated from one previously written to disk using the from_file classmethod:

[22]:
fid = yt.FITSImageData.from_file("sloshing.fits")
fid.info()
Filename: sloshing.fits
No.    Name      Ver    Type      Cards   Dimensions   Format     Units
  0  TEMPERATURE    1 PrimaryHDU      29   (512, 512)   float64   R

Multiple FITSImageData can be combined to create a new one, provided that the coordinate information is the same:

[23]:
prj_fits2 = yt.FITSProjection(ds, "z", ("gas", "density"), width=(500.0, "kpc"))
prj_fits3 = yt.FITSImageData.from_images([prj_fits, prj_fits2])
prj_fits3.info()
Filename: (No file associated with this FITSImageData)
No.    Name      Ver    Type      Cards   Dimensions   Format     Units
  0  TEMPERATURE    1 PrimaryHDU      29   (512, 512)   float64   R
  1  DENSITY       1 ImageHDU        30   (512, 512)   float64   g/cm**2

Alternatively, individual fields can be popped as well to produce new instances of FITSImageData:

[24]:
dens_fits = prj_fits3.pop("density")

So this new instance would only have the "density" field:

[25]:
dens_fits.info()
Filename: (No file associated with this FITSImageData)
No.    Name      Ver    Type      Cards   Dimensions   Format     Units
  0  DENSITY       1 PrimaryHDU      28   (512, 512)   float64   g/cm**2

and the old one has the "density" field removed:

[26]:
prj_fits3.info()
Filename: (No file associated with this FITSImageData)
No.    Name      Ver    Type      Cards   Dimensions   Format     Units
  0  TEMPERATURE    1 PrimaryHDU      29   (512, 512)   float64   R

Adding Sky Coordinates to Images

So far, the FITS images we have shown have linear spatial coordinates. We can see this by looking at the header for one of the fields, and examining the CTYPE1 and CTYPE2 keywords:

[27]:
prj_fits["temperature"].header
[27]:
SIMPLE  =                    T / conforms to FITS standard
BITPIX  =                  -64 / array data type
NAXIS   =                    2 / number of array dimensions
NAXIS1  =                  512
NAXIS2  =                  512
EXTEND  =                    T
EXTNAME = 'TEMPERATURE'        / extension name
BTYPE   = 'temperature'
BUNIT   = 'R       '
LUNIT   =                  1.0 / [kpc]
TUNIT   =                  1.0 / [Myr]
MUNIT   =    100000000000000.0 / [Msun]
VUNIT   =                  1.0 / [Mpc/Myr]
BFUNIT  =  0.02851538907227106 / [G]
TIME    =             2700.111
WCSAXES =                    2
CRPIX1  =                256.5
CRPIX2  =                256.5
CDELT1  =            0.9765625
CDELT2  =            0.9765625
CUNIT1  = 'kpc     '
CUNIT2  = 'kpc     '
CTYPE1  = 'LINEAR  '
CTYPE2  = 'LINEAR  '
CRVAL1  =                  0.0
CRVAL2  =                  0.0
LATPOLE =                 90.0
WCSNAME = 'yt      '
MJDREF  =                  0.0

The WCSNAME keyword is set to "yt" by default.

However, one may want to take a projection of an object and make a crude mock observation out of it, with celestial coordinates. For this, we can use the create_sky_wcs method. Specify a center (RA, Dec) coordinate in degrees, as well as a linear scale in terms of angle per distance:

[28]:
sky_center = [30.0, 45.0]  # in degrees
sky_scale = (2.5, "arcsec/kpc")  # could also use a YTQuantity
prj_fits.create_sky_wcs(sky_center, sky_scale, ctype=["RA---TAN", "DEC--TAN"])

By default, a tangent RA/Dec projection is used, but one could also use another projection using the ctype keyword. We can now look at the header and see it has the appropriate WCS now. The old "yt" WCS has been added to a second WCS in the header, where the parameters have an "A" appended to them:

[29]:
prj_fits["temperature"].header
[29]:
SIMPLE  =                    T / conforms to FITS standard
BITPIX  =                  -64 / array data type
NAXIS   =                    2 / number of array dimensions
NAXIS1  =                  512
NAXIS2  =                  512
EXTEND  =                    T
EXTNAME = 'TEMPERATURE'        / extension name
BTYPE   = 'temperature'
BUNIT   = 'R       '
LUNIT   =                  1.0 / [kpc]
TUNIT   =                  1.0 / [Myr]
MUNIT   =    100000000000000.0 / [Msun]
VUNIT   =                  1.0 / [Mpc/Myr]
BFUNIT  =  0.02851538907227106 / [G]
TIME    =             2700.111
WCSAXES =                    2
CRPIX1  =                256.5
CRPIX2  =                256.5
CDELT1  = -0.00067816840277778
CDELT2  =  0.00067816840277778
CUNIT1  = 'deg     '
CUNIT2  = 'deg     '
CTYPE1  = 'RA---TAN'
CTYPE2  = 'DEC--TAN'
CRVAL1  =                 30.0
CRVAL2  =                 45.0
LATPOLE =                 45.0
WCSNAME = 'celestial'
MJDREF  =                  0.0
LONPOLE =                180.0
RADESYS = 'ICRS    '
WCSAXESA=                    2
CRPIX1A =                256.5
CRPIX2A =                256.5
CDELT1A =            0.9765625
CDELT2A =            0.9765625
CUNIT1A = 'kpc     '
CUNIT2A = 'kpc     '
CTYPE1A = 'LINEAR  '
CTYPE2A = 'LINEAR  '
CRVAL1A =                  0.0
CRVAL2A =                  0.0
LATPOLEA=                 90.0
WCSNAMEA= 'yt      '
MJDREFA =                  0.0

and now the WCSNAME has been set to "celestial". If you want the original WCS to remain in the original place, then you can make the call to create_sky_wcs and set replace_old_wcs=False, which will put the new, celestial WCS in the second one:

[30]:
prj_fits3.create_sky_wcs(
    sky_center, sky_scale, ctype=["RA---TAN", "DEC--TAN"], replace_old_wcs=False
)
[31]:
prj_fits3["temperature"].header
[31]:
SIMPLE  =                    T / conforms to FITS standard
BITPIX  =                  -64 / array data type
NAXIS   =                    2 / number of array dimensions
NAXIS1  =                  512
NAXIS2  =                  512
EXTEND  =                    T
EXTNAME = 'TEMPERATURE'        / extension name
BTYPE   = 'temperature'
BUNIT   = 'R       '
LUNIT   =                  1.0 / [kpc]
TUNIT   =                  1.0 / [Myr]
MUNIT   =    100000000000000.0 / [Msun]
VUNIT   =                  1.0 / [Mpc/Myr]
BFUNIT  =  0.02851538907227106 / [G]
TIME    =             2700.111
WCSAXES =                    2
CRPIX1  =                256.5
CRPIX2  =                256.5
CDELT1  =            0.9765625
CDELT2  =            0.9765625
CUNIT1  = 'kpc     '
CUNIT2  = 'kpc     '
CTYPE1  = 'LINEAR  '
CTYPE2  = 'LINEAR  '
CRVAL1  =                  0.0
CRVAL2  =                  0.0
LATPOLE =                 90.0
WCSNAME = 'yt      '
MJDREF  =                  0.0
WCSAXESA=                    2
CRPIX1A =                256.5
CRPIX2A =                256.5
CDELT1A = -0.00067816840277778
CDELT2A =  0.00067816840277778
CUNIT1A = 'deg     '
CUNIT2A = 'deg     '
CTYPE1A = 'RA---TAN'
CTYPE2A = 'DEC--TAN'
CRVAL1A =                 30.0
CRVAL2A =                 45.0
LONPOLEA=                180.0
LATPOLEA=                 45.0
WCSNAMEA= 'celestial'
MJDREFA =                  0.0
RADESYSA= 'ICRS    '

Updating Header Parameters

We can also add header keywords to a single field or for all fields in the FITS image using update_header:

[32]:
fid_frb.update_header("all", "time", 0.1)  # Update all the fields
fid_frb.update_header("temperature", "scale", "Rankine")  # Update just one field
[33]:
print(fid_frb["density"].header["time"])
print(fid_frb["temperature"].header["scale"])
0.1
Rankine

Changing Image Names

You can use the change_image_name method to change the name of an image in a FITSImageData instance:

[34]:
fid_frb.change_image_name("density", "mass_per_volume")
fid_frb.info()  # now "density" should be gone and "mass_per_volume" should be in its place
Filename: (No file associated with this FITSImageData)
No.    Name      Ver    Type      Cards   Dimensions   Format     Units
  0  MASS_PER_VOLUME    1 PrimaryHDU      29   (800, 800)   float64   g/cm**3
  1  TEMPERATURE    1 ImageHDU        31   (800, 800)   float64   K

Convolving FITS Images

Finally, you can convolve an image inside a FITSImageData instance with a kernel, either a Gaussian with a specific standard deviation, or any kernel provided by AstroPy. See AstroPy’s Convolution and filtering for more details.

[35]:
dens_fits.writeto("not_convolved.fits", overwrite=True)
# Gaussian kernel with standard deviation of 3.0 kpc
dens_fits.convolve("density", 3.0)
dens_fits.writeto("convolved.fits", overwrite=True)

Now let’s load these up as datasets and see the difference:

[36]:
ds0 = yt.load("not_convolved.fits")
dsc = yt.load("convolved.fits")
[37]:
slc3 = yt.SlicePlot(ds0, "z", ("gas", "density"), width=(500.0, "kpc"))
slc3.set_log("density", True)
slc3.show()

[38]:
slc4 = yt.SlicePlot(dsc, "z", ("gas", "density"), width=(500.0, "kpc"))
slc4.set_log("density", True)
slc4.show()