Skip to content

Formatting float values

Astropy is popular to create FITS files for instruments at Subaru Telescope. By default, one cannot specify format (e.g., %20.3f, etc.) for floating point values with Astropy. However, it is important from scientific point of view to clearly transfer information on how many significant digits the instrument can ensure. Here, we describe a couple of ways to realize it.

We hope this will help prepare the FITS files for instruments on Subaru Telescope. If you have any questions, please contact the FITS committee. We appreciate your feedback.

Use astropy.io.fits.Card.fromstring()

Reference: https://docs.astropy.org/en/stable/io/fits/appendix/faq.html#why-am-i-losing-precision-when-i-assign-floating-point-values-in-the-header

import pprint

from astropy.io import fits


format_dict = {
    "AIRMASS": {
        "format": "20.3f",
        "comment": "Average airmass during exposure",
    },
    "SEEING": {
        "format": "20.2f",
        "comment": "[arcsec] FWHM of the star at telescope focus",
    },
}
values = {
    "AIRMASS": 1234.56789,
    "SEEING": 1.2345678901234567,
    "TESTNUM": 2.2345678901234567,
}

# first put necessary inforamtion in string format
str_entries = []
sep = "="
for k, v in values.items():
    if k in format_dict.keys():
        fmt = format_dict[k]["format"]
        if "comment" in format_dict[k].keys():
            comment = format_dict[k]["comment"]
    else:
        fmt = "20.16G"
        comment = "not listed in the dictionary"
    str_entry = f"{k:8s}{sep:2s}{v:{fmt}} / {comment}"
    str_entries.append(str_entry)

# create a FITS header
prihdu = fits.PrimaryHDU()

# append header keys with float values using fromstring()
for str_entry in str_entries:
    prihdu.header.append(fits.Card.fromstring(str_entry))

pprint.pprint(prihdu.header)
prihdu.writeto("test1.fits", overwrite=True)

The resulting header should look like the following.

SIMPLE  =                    T / conforms to FITS standard
BITPIX  =                    8 / array data type
NAXIS   =                    0 / number of array dimensions
EXTEND  =                    T
AIRMASS =             1234.568 / Average airmass during exposure
SEEING  =                 1.23 / [arcsec] FWHM of the star at telescope focus
TESTNUM =    2.234567890123457 / not listed in the dictionary
END

Override the float formatter for astropy.io.fits

The following example shows how to make sure to show the correct decimal points in a FITS header by overriding the float formatter for astropy.io.fits.card._format_float().

Note

Note that the _float_format() in Astropy implemented differently for <5.3 and >=5.3. You can check them by the links below.

The example is a mixture of two implementations. For example, as you can see from the latest implementation (Astropy 6.0), my_format_float() and overriding astropy.io.fits.card._format_float() are not actually necessary, while fits.card.Card._format_value() is still required to get overridden.

First, define a Python class FormattedFloat as follows.

class FormattedFloat(float):
    """
    A class used to represent a FormattedFloat, which is a subclass of float.

    ...

    Attributes
    ----------
    formatstr : str
        a formatted string that is used to represent the float value

    Methods
    -------
    __str__():
        Returns the float value as a formatted string.
    """

    def __new__(cls, value, formatstr=None):
        """
        Constructs a new instance of the FormattedFloat class.

        Parameters
        ----------
        value : float
            the float value to be formatted
        formatstr : str, optional
            the format string to be used (default is None)
        """
        return super().__new__(cls, value)

    def __init__(self, value, formatstr=None):
        """
        Initializes the FormattedFloat instance.

        Parameters
        ----------
        value : float
            the float value to be formatted
        formatstr : str, optional
            the format string to be used (default is None)
        """
        if formatstr is not None:
            # remove the leading % if present to be compatible with the f-string format
            self.formatstr = formatstr[1:] if formatstr[0] == "%" else formatstr

    def __str__(self):
        """
        Returns the float value as a formatted string.

        Returns
        -------
        str
            the formatted string representation of the float value
        """
        return f"{self.__float__():{self.formatstr}}"

Then, override float formatter in astropy.io.fits as follows.

from astropy.io import fits

def my_format_float(value):
    """
    Format a floating number to make sure it is at most 20 characters
    with the specified decimal points for the case of FormattedFloat.
    """

    value_str = str(value).replace("e", "E")

    if ("." not in value_str) and ("E" not in value_str):
        value_str += ".0"
    elif "E" in value_str:
        # On some Windows builds of Python (and possibly other platforms?) the
        # exponent is zero-padded out to, it seems, three digits.  Normalize
        # the format to pad only to two digits.
        significant, exponent = value_str.split("E")
        if exponent[0] in ("+", "-"):
            sign = exponent[0]
            exponent = exponent[1:]
        else:
            sign = ""
        value_str = f"{significant}E{sign}{int(exponent):02d}"

    # Limit the value string to at most 20 characters.
    str_len = len(value_str)

    if str_len > 20:
        idx = value_str.find("E")
        if idx < 0:
            value_str = value_str[:20]
        else:
            value_str = value_str[: 20 - (str_len - idx)] + value_str[idx:]

    return value_str


def my_format_value_method(self):
    global fmtDict
    if self.keyword in fmtDict:
        value = self.value
        self.value = None
        self.value = FormattedFloat(value, fmtDict[self.keyword]["format"])
    return original_format_value_method(self)


# override the format_float function
original_format_float = fits.card._format_float
fits.card._format_float = my_format_float

# override the format_value method
original_format_value_method = fits.card.Card._format_value
fits.card.Card._format_value = my_format_value_method

The following code will create the identical header to the 1st method described above.

import pprint

# set up the format dictionary as a global variable to be shared with the formatting functions
global fmtDict
# format can be C-style or f-string style
fmtDict = {
    "AIRMASS": {
        "format": "20.3f",
        "comment": "Average airmass during exposure",
    },
    "SEEING": {
        "format": "%20.2f",
        "comment": "[arcsec] FWHM of the star at telescope focus",
    },
}
h = fits.Header()
h["AIRMASS"] = 1234.56789  # will be formatted as 1234.568
h["SEEING"] = 1.23456789012345678901234567890123456789  # will be formatted as 1.23
h["TESTNUM"] = 2.23456789012345678901234567890123456789  # will be unformatted
prihdu = fits.PrimaryHDU()
for k, v in h.items():
    prihdu.header[k] = (
        v,
        fmtDict[k]["comment"] if k in fmtDict else "not listed in the dictionary",
    )
pprint.pprint(prihdu.header)