Skip to main content
`Ctrl`+`K`

# Source code for astropy.utils.iers.iers

    # Licensed under a 3-clause BSD style license - see LICENSE.rst
    """
    The astropy.utils.iers package provides access to the tables provided by
    the International Earth Rotation and Reference Systems Service, in
    particular allowing interpolation of published UT1-UTC values for given
    times.  These are used in `astropy.time` to provide UT1 values.  The polar
    motions are also used for determining earth orientation for
    celestial-to-terrestrial coordinate transformations
    (in `astropy.coordinates`).
    """

    import os
    import re
    from datetime import UTC, datetime
    from typing import Self
    from urllib.parse import urlparse
    from warnings import warn

    import erfa
    import numpy as np
    from astropy_iers_data import (
        IERS_A_FILE,
        IERS_A_README,
        IERS_A_URL,
        IERS_A_URL_MIRROR,
        IERS_B_FILE,
        IERS_B_README,
        IERS_B_URL,
        IERS_LEAP_SECOND_FILE,
        IERS_LEAP_SECOND_URL,
    )
    from astropy_iers_data import IERS_LEAP_SECOND_URL_MIRROR as IETF_LEAP_SECOND_URL

    from astropy import config as _config
    from astropy import units as u
    from astropy import utils
    from astropy.table import MaskedColumn, QTable
    from astropy.time import Time, TimeDelta
    from astropy.utils.data import (
        clear_download_cache,
        get_readable_fileobj,
        is_url_in_cache,
    )
    from astropy.utils.decorators import deprecated
    from astropy.utils.exceptions import AstropyDeprecationWarning, AstropyWarning
    from astropy.utils.state import ScienceState

    __all__ = [
        "FROM_IERS_A",
        "FROM_IERS_A_PREDICTION",
        "FROM_IERS_B",
        "IERS",
        "IERS_A",
        "IERS_A_FILE",
        "IERS_A_README",
        "IERS_A_URL",
        "IERS_A_URL_MIRROR",
        "IERS_B",
        "IERS_B_FILE",
        "IERS_B_README",
        "IERS_B_URL",
        "IERS_LEAP_SECOND_FILE",
        "IERS_LEAP_SECOND_URL",
        "IETF_LEAP_SECOND_URL",
        "TIME_BEFORE_IERS_RANGE",
        "TIME_BEYOND_IERS_RANGE",
        "Conf",
        "IERSDegradedAccuracyWarning",
        "IERSRangeError",
        "IERSStaleWarning",
        "IERSWarning",
        "IERS_Auto",
        "LeapSeconds",
        "conf",
        "earth_orientation_table",
    ]

    # Status/source values returned by IERS.ut1_utc
    FROM_IERS_B = 0
    FROM_IERS_A = 1
    FROM_IERS_A_PREDICTION = 2
    TIME_BEFORE_IERS_RANGE = -1
    TIME_BEYOND_IERS_RANGE = -2

    MJD_ZERO = 2400000.5

    INTERPOLATE_ERROR = """\
    interpolating from IERS_Auto using predictive values that are more
    than {0} days old.

    Normally you should not see this error because this class
    automatically downloads the latest IERS-A table.  Perhaps you are
    offline?  If you understand what you are doing then this error can be
    suppressed by setting the auto_max_age configuration variable to
    ``None``:

      from astropy.utils.iers import conf
      conf.auto_max_age = None
    """

    MONTH_ABBR = [
        "Jan",
        "Feb",
        "Mar",
        "Apr",
        "May",
        "Jun",
        "Jul",
        "Aug",
        "Sep",
        "Oct",
        "Nov",
        "Dec",
    ]

    class IERSWarning(AstropyWarning):
        """
        Generic warning class for IERS.
        """

    class IERSDegradedAccuracyWarning(AstropyWarning):
        """
        IERS time conversion has degraded accuracy normally due to setting
        ``conf.auto_download = False`` and ``conf.iers_degraded_accuracy = 'warn'``.
        """

    class IERSStaleWarning(IERSWarning):
        """
        Downloaded IERS table may be stale.
        """

    def download_file(*args, **kwargs):
        """
        Overload astropy.utils.data.download_file within iers module to use a
        custom (longer) wait time.  This just passes through ``*args`` and
        ``**kwargs`` after temporarily setting the download_file remote timeout to
        the local ``iers.conf.remote_timeout`` value.
        """
        kwargs.setdefault(
            "http_headers",
            {
                "User-Agent": "astropy/iers",
                "Accept": "*/*",
            },
        )

        with utils.data.conf.set_temp("remote_timeout", conf.remote_timeout):
            return utils.data.download_file(*args, **kwargs)

    def _none_to_float(value):
        """
        Convert None to a valid floating point value.  Especially
        for auto_max_age = None.
        """
        return value if value is not None else np.finfo(float).max

    class Conf(_config.ConfigNamespace):
        """
        Configuration parameters for `astropy.utils.iers`.
        """

        auto_download = _config.ConfigItem(
            True,
            "Enable auto-downloading of the latest IERS-A data.  If set to False "
            "then the bundled IERS-A file will be used by default (even if a "
            "newer version of the IERS-A file was previously downloaded and cached). "
            "This parameter also controls whether internet resources will be "
            "queried to update the leap second table if the installed version is "
            "out of date. Default is True.",
        )
        auto_max_age = _config.ConfigItem(
            30.0,
            "Maximum age (days) of predictive data before auto-downloading. "
            'See "Auto refresh behavior" in astropy.utils.iers documentation for details. '
            "Default is 30.",
        )
        iers_auto_url = _config.ConfigItem(
            IERS_A_URL, "URL for auto-downloading IERS file data."
        )
        iers_auto_url_mirror = _config.ConfigItem(
            IERS_A_URL_MIRROR, "Mirror URL for auto-downloading IERS file data."
        )
        remote_timeout = _config.ConfigItem(
            10.0, "Remote timeout downloading IERS file data (seconds)."
        )
        iers_degraded_accuracy = _config.ConfigItem(
            ["error", "warn", "ignore"],
            "IERS behavior if the range of available IERS data does not "
            "cover the times when converting time scales, potentially leading "
            "to degraded accuracy.  Applies only when using IERS-B data on its own.",
        )
        system_leap_second_file = _config.ConfigItem("", "System file with leap seconds.")
        iers_leap_second_auto_url = _config.ConfigItem(
            IERS_LEAP_SECOND_URL, "URL for auto-downloading leap seconds."
        )
        ietf_leap_second_auto_url = _config.ConfigItem(
            IETF_LEAP_SECOND_URL, "Alternate URL for auto-downloading leap seconds."
        )

    conf = Conf()

    class IERSRangeError(IndexError):
        """
        Any error for when dates are outside of the valid range for IERS.
        """

    class IERS(QTable):
        """Generic IERS table class, defining interpolation functions.

        Sub-classed from `astropy.table.QTable`.  The table should hold columns
        'MJD', 'UT1_UTC', 'dX_2000A'/'dY_2000A', and 'PM_x'/'PM_y'.
        """

        iers_table = None
        """Cached table, returned if ``open`` is called without arguments."""

        @classmethod
        def open(cls, file=None, cache=False, **kwargs):
            """Open an IERS table, reading it from a file if not loaded before.

            Parameters
            ----------
            file : str or None
                full local or network path to the ascii file holding IERS data,
                for passing on to the ``read`` class methods (further optional
                arguments that are available for some IERS subclasses can be added).
                If None, use the default location from the ``read`` class method.
            cache : bool
                Whether to use cache. Defaults to False, since IERS files
                are regularly updated.

            Returns
            -------
            IERS
                An IERS table class instance

            Notes
            -----
            On the first call in a session, the table will be memoized (in the
            ``iers_table`` class attribute), and further calls to ``open`` will
            return this stored table if ``file=None`` (the default).

            If a table needs to be re-read from disk, pass on an explicit file
            location or use the (sub-class) close method and re-open.

            If the location is a network location it is first downloaded via
            download_file.

            For the IERS class itself, an IERS_B sub-class instance is opened.

            """
            if file is not None or cls.iers_table is None:
                if file is not None:
                    if urlparse(file).netloc:
                        kwargs.update(file=download_file(file, cache=cache))
                    else:
                        kwargs.update(file=file)

                # TODO: the below is really ugly and probably a bad idea.  Instead,
                # there should probably be an IERSBase class, which provides
                # useful methods but cannot really be used on its own, and then
                # *perhaps* an IERS class which provides best defaults.  But for
                # backwards compatibility, we use the IERS_B reader for IERS here.
                if cls is IERS:
                    cls.iers_table = IERS_B.read(**kwargs)
                else:
                    cls.iers_table = cls.read(**kwargs)
            return cls.iers_table

        @classmethod
        def close(cls):
            """Remove the IERS table from the class.

            This allows the table to be re-read from disk during one's session
            (e.g., if one finds it is out of date and has updated the file).
            """
            cls.iers_table = None

        def mjd_utc(self, jd1, jd2=0.0):
            """Turn a time to MJD, returning integer and fractional parts.

            Parameters
            ----------
            jd1 : float, array, or `~astropy.time.Time`
                first part of two-part JD, or Time object
            jd2 : float or array, optional
                second part of two-part JD.
                Default is 0., ignored if jd1 is `~astropy.time.Time`.

            Returns
            -------
            mjd : float or array
                integer part of MJD
            utc : float or array
                fractional part of MJD
            """
            try:  # see if this is a Time object
                jd1, jd2 = jd1.utc.jd1, jd1.utc.jd2
            except Exception:
                pass

            mjd = np.floor(jd1 - MJD_ZERO + jd2)
            utc = jd1 - (MJD_ZERO + mjd) + jd2
            return mjd, utc

        def ut1_utc(self, jd1, jd2=0.0, return_status=False):
            """Interpolate UT1-UTC corrections in IERS Table for given dates.

            Parameters
            ----------
            jd1 : float, array of float, or `~astropy.time.Time` object
                first part of two-part JD, or Time object
            jd2 : float or float array, optional
                second part of two-part JD.
                Default is 0., ignored if jd1 is `~astropy.time.Time`.
            return_status : bool
                Whether to return status values.  If False (default),
                raise ``IERSRangeError`` if any time is out of the range covered
                by the IERS table.

            Returns
            -------
            ut1_utc : float or float array
                UT1-UTC, interpolated in IERS Table
            status : int or int array
                Status values (if ``return_status``=``True``)::
                ``iers.FROM_IERS_B``
                ``iers.FROM_IERS_A``
                ``iers.FROM_IERS_A_PREDICTION``
                ``iers.TIME_BEFORE_IERS_RANGE``
                ``iers.TIME_BEYOND_IERS_RANGE``
            """
            return self._interpolate(
                jd1, jd2, ["UT1_UTC"], self.ut1_utc_source if return_status else None
            )

        def dcip_xy(self, jd1, jd2=0.0, return_status=False):
            """Interpolate CIP corrections in IERS Table for given dates.

            Parameters
            ----------
            jd1 : float, array of float, or `~astropy.time.Time` object
                first part of two-part JD, or Time object
            jd2 : float or float array, optional
                second part of two-part JD (default 0., ignored if jd1 is Time)
            return_status : bool
                Whether to return status values.  If False (default),
                raise ``IERSRangeError`` if any time is out of the range covered
                by the IERS table.

            Returns
            -------
            D_x : `~astropy.units.Quantity` ['angle']
                x component of CIP correction for the requested times.
            D_y : `~astropy.units.Quantity` ['angle']
                y component of CIP correction for the requested times
            status : int or int array
                Status values (if ``return_status``=``True``)::
                ``iers.FROM_IERS_B``
                ``iers.FROM_IERS_A``
                ``iers.FROM_IERS_A_PREDICTION``
                ``iers.TIME_BEFORE_IERS_RANGE``
                ``iers.TIME_BEYOND_IERS_RANGE``
            """
            return self._interpolate(
                jd1,
                jd2,
                ["dX_2000A", "dY_2000A"],
                self.dcip_source if return_status else None,
            )

        def pm_xy(self, jd1, jd2=0.0, return_status=False):
            """Interpolate polar motions from IERS Table for given dates.

            Parameters
            ----------
            jd1 : float, array of float, or `~astropy.time.Time` object
                first part of two-part JD, or Time object
            jd2 : float or float array, optional
                second part of two-part JD.
                Default is 0., ignored if jd1 is `~astropy.time.Time`.
            return_status : bool
                Whether to return status values.  If False (default),
                raise ``IERSRangeError`` if any time is out of the range covered
                by the IERS table.

            Returns
            -------
            PM_x : `~astropy.units.Quantity` ['angle']
                x component of polar motion for the requested times.
            PM_y : `~astropy.units.Quantity` ['angle']
                y component of polar motion for the requested times.
            status : int or int array
                Status values (if ``return_status``=``True``)::
                ``iers.FROM_IERS_B``
                ``iers.FROM_IERS_A``
                ``iers.FROM_IERS_A_PREDICTION``
                ``iers.TIME_BEFORE_IERS_RANGE``
                ``iers.TIME_BEYOND_IERS_RANGE``
            """
            return self._interpolate(
                jd1, jd2, ["PM_x", "PM_y"], self.pm_source if return_status else None
            )

        def _check_interpolate_indices(self, indices_orig, indices_clipped, max_input_mjd):
            """
            Check that the indices from interpolation match those after clipping
            to the valid table range.  This method gets overridden in the IERS_Auto
            class because it has different requirements.
            """
            if np.any(indices_orig != indices_clipped):
                if conf.iers_degraded_accuracy == "error":
                    msg = (
                        "(some) times are outside of range covered by IERS table. Cannot"
                        " convert with full accuracy. To allow conversion with degraded"
                        " accuracy set astropy.utils.iers.conf.iers_degraded_accuracy to"
                        ' "warn" or "silent". For more information about setting this'
                        " configuration parameter or controlling its value globally, see"
                        " the Astropy configuration system documentation"
                        " https://docs.astropy.org/en/stable/config/index.html."
                    )
                    raise IERSRangeError(msg)
                elif conf.iers_degraded_accuracy == "warn":
                    # No IERS data covering the time(s) and user requested a warning.
                    msg = (
                        "(some) times are outside of range covered by IERS table, "
                        "accuracy is degraded."
                    )
                    warn(msg, IERSDegradedAccuracyWarning)
                # No IERS data covering the time(s) and user is OK with no warning.

        def _interpolate(self, jd1, jd2, columns, source=None):
            mjd, utc = self.mjd_utc(jd1, jd2)
            # enforce array
            is_scalar = not hasattr(mjd, "__array__") or mjd.ndim == 0
            if is_scalar:
                mjd = np.array([mjd])
                utc = np.array([utc])

            self._refresh_table_as_needed(mjd)

            # For typical format, will always find a match (since MJD are integer)
            # hence, important to define which side we will be; this ensures
            # self['MJD'][i-1]<=mjd<self['MJD'][i]
            i = np.searchsorted(self["MJD"].value, mjd, side="right")

            # Get index to MJD at or just below given mjd, clipping to ensure we
            # stay in range of table (status will be set below for those outside)
            i1 = np.clip(i, 1, len(self) - 1)
            i0 = i1 - 1
            mjd_0, mjd_1 = self["MJD"][i0].value, self["MJD"][i1].value
            results = []
            for column in columns:
                val_0, val_1 = self[column][i0], self[column][i1]
                d_val = val_1 - val_0
                if column == "UT1_UTC":
                    # Check & correct for possible leap second (correcting diff.,
                    # not 1st point, since jump can only happen right at 2nd point)
                    d_val -= d_val.round()
                # Linearly interpolate (which is what TEMPO does for UT1-UTC, but
                # may want to follow IERS gazette #13 for more precise
                # interpolation and correction for tidal effects;
                # https://maia.usno.navy.mil/iers-gaz13)
                val = val_0 + (mjd - mjd_0 + utc) / (mjd_1 - mjd_0) * d_val

                # Do not extrapolate outside range, instead just propagate last values.
                val[i == 0] = self[column][0]
                val[i == len(self)] = self[column][-1]

                if is_scalar:
                    val = val[0]

                results.append(val)

            if source:
                # Set status to source, using the routine passed in.
                status = source(i1)
                # Check for out of range
                status[i == 0] = TIME_BEFORE_IERS_RANGE
                status[i == len(self)] = TIME_BEYOND_IERS_RANGE
                if is_scalar:
                    status = status[0]
                results.append(status)
                return results
            else:
                # Pass in initial to np.max to allow things to work for empty mjd.
                self._check_interpolate_indices(i1, i, np.max(mjd, initial=50000))
                return results[0] if len(results) == 1 else results

        def _refresh_table_as_needed(self, mjd):
            """
            Potentially update the IERS table in place depending on the requested
            time values in ``mdj`` and the time span of the table.  The base behavior
            is not to update the table.  ``IERS_Auto`` overrides this method.
            """

        def ut1_utc_source(self, i):
            """Source for UT1-UTC.  To be overridden by subclass."""
            return np.zeros_like(i)

        def dcip_source(self, i):
            """Source for CIP correction.  To be overridden by subclass."""
            return np.zeros_like(i)

        def pm_source(self, i):
            """Source for polar motion.  To be overridden by subclass."""
            return np.zeros_like(i)

        @property
        @deprecated(since="8.0", alternative="Time.now()", obj_type="property")
        def time_now(self):
            """
            Property to provide the current time, but also allow for explicitly setting
            the _time_now attribute for testing purposes.
            """
            try:
                return self._time_now
            except Exception:
                return Time.now()

        def _convert_col_for_table(self, col):
            # Fill masked columns with units to avoid dropped-mask warnings
            # when converting to Quantity.
            # TODO: Once we support masked quantities, we can drop this and
            # in the code below replace b_bad with table['UT1_UTC_B'].mask, etc.
            if getattr(col, "unit", None) is not None and isinstance(col, MaskedColumn):
                col = col.filled(np.nan)

            return super()._convert_col_for_table(col)

    class IERS_A(IERS):
        """IERS Table class targeted to IERS A, provided by USNO.

        These include rapid turnaround and predicted times.
        See https://datacenter.iers.org/eop.php

        Notes
        -----
        If the package IERS A file (``iers.IERS_A_FILE``) is out of date, a new
        version can be downloaded from ``iers.IERS_A_URL`` or ``iers.IERS_A_URL_MIRROR``.
        See ``iers.__doc__`` for instructions on use in ``Time``, etc.
        """

        iers_table = None

        @classmethod
        def _combine_a_b_columns(cls, iers_a):
            """
            Return a new table with appropriate combination of IERS_A and B columns.
            """
            # IERS A has some rows at the end that hold nothing but dates & MJD
            # presumably to be filled later.  Exclude those a priori -- there
            # should at least be a predicted UT1-UTC and PM!
            table = iers_a[np.isfinite(iers_a["UT1_UTC_A"]) & (iers_a["PolPMFlag_A"] != "")]

            # This does nothing for IERS_A, but allows IERS_Auto to ensure the
            # IERS B values in the table are consistent with the true ones.
            table = cls._substitute_iers_b(table)

            # Combine A and B columns, using B where possible.
            b_bad = np.isnan(table["UT1_UTC_B"])
            table["UT1_UTC"] = np.where(b_bad, table["UT1_UTC_A"], table["UT1_UTC_B"])
            table["UT1Flag"] = np.where(b_bad, table["UT1Flag_A"], "B")
            # Repeat for polar motions.
            b_bad = np.isnan(table["PM_X_B"]) | np.isnan(table["PM_Y_B"])
            table["PM_x"] = np.where(b_bad, table["PM_x_A"], table["PM_X_B"])
            table["PM_y"] = np.where(b_bad, table["PM_y_A"], table["PM_Y_B"])
            table["PolPMFlag"] = np.where(b_bad, table["PolPMFlag_A"], "B")

            b_bad = np.isnan(table["dX_2000A_B"]) | np.isnan(table["dY_2000A_B"])
            table["dX_2000A"] = np.where(b_bad, table["dX_2000A_A"], table["dX_2000A_B"])
            table["dY_2000A"] = np.where(b_bad, table["dY_2000A_A"], table["dY_2000A_B"])
            table["NutFlag"] = np.where(b_bad, table["NutFlag_A"], "B")

            # Get the table index for the first row that has predictive values
            # PolPMFlag_A  IERS (I) or Prediction (P) flag for
            #              Bull. A polar motion values
            # UT1Flag_A    IERS (I) or Prediction (P) flag for
            #              Bull. A UT1-UTC values
            # Since only 'P' and 'I' are possible and 'P' is guaranteed to come
            # after 'I', we can use searchsorted for 100 times speed up over
            # finding the first index where the flag equals 'P'.
            p_index = min(
                np.searchsorted(table["UT1Flag_A"], "P"),
                np.searchsorted(table["PolPMFlag_A"], "P"),
            )
            table.meta["predictive_index"] = p_index
            table.meta["predictive_mjd"] = table["MJD"][p_index].value

            return table

        @classmethod
        def _substitute_iers_b(cls, table):
            # See documentation in IERS_Auto.
            return table

        @classmethod
        def read(
            cls,
            file: str | os.PathLike[str] | None = None,
            readme: str | os.PathLike[str] | None = None,
        ) -> Self:
            """Read IERS-A table from a finals2000a.* file provided by USNO.

            Parameters
            ----------
            file : str or os.PathLike[str]
                full path to ascii file holding IERS-A data.
                Defaults to ``iers.IERS_A_FILE``.
            readme : str or os.PathLike[str]
                full path to ascii file holding CDS-style readme.
                Defaults to package version, ``iers.IERS_A_README``.

            Returns
            -------
            ``IERS_A`` class instance
            """
            if file is None:
                # In prior versions of astropy, read() would only work without a
                # file argument if a finals2000A.all file was present in the
                # current working directory. We can now use the versions from
                # astropy-iers-data but for backward-compatibility we first check
                # if there is a file in the current working directory and use that
                # if so, emitting a deprecation warning
                if os.path.exists("finals2000A.all"):
                    file = "finals2000A.all"
                    warn(
                        "The file= argument was not specified but "
                        "'finals2000A.all' is present in the current working "
                        "directory, so reading IERS data from that file. To "
                        "continue reading a local file from the current working "
                        "directory, specify file= explicitly otherwise a bundled "
                        "file will be used in future.",
                        AstropyDeprecationWarning,
                    )
                else:
                    file = IERS_A_FILE
            else:
                file = os.fspath(file)
            if readme is None:
                readme = IERS_A_README
            else:
                readme = os.fspath(readme)

            iers_a = super().read(file, format="cds", readme=readme)

            # Combine the A and B data for UT1-UTC and PM columns
            table = cls._combine_a_b_columns(iers_a)
            table.meta["data_path"] = file
            table.meta["readme_path"] = readme

            return table

        def ut1_utc_source(self, i):
            """Set UT1-UTC source flag for entries in IERS table."""
            ut1flag = self["UT1Flag"][i]
            source = np.ones_like(i) * FROM_IERS_B
            source[ut1flag == "I"] = FROM_IERS_A
            source[ut1flag == "P"] = FROM_IERS_A_PREDICTION
            return source

        def dcip_source(self, i):
            """Set CIP correction source flag for entries in IERS table."""
            nutflag = self["NutFlag"][i]
            source = np.ones_like(i) * FROM_IERS_B
            source[nutflag == "I"] = FROM_IERS_A
            source[nutflag == "P"] = FROM_IERS_A_PREDICTION
            return source

        def pm_source(self, i):
            """Set polar motion source flag for entries in IERS table."""
            pmflag = self["PolPMFlag"][i]
            source = np.ones_like(i) * FROM_IERS_B
            source[pmflag == "I"] = FROM_IERS_A
            source[pmflag == "P"] = FROM_IERS_A_PREDICTION
            return source

    class IERS_B(IERS):
        """IERS Table class targeted to IERS B, provided by IERS itself.

        These are final values; see https://www.iers.org/IERS/EN/Home/home_node.html

        Notes
        -----
        If the package IERS B file (``iers.IERS_B_FILE``) is out of date, a new
        version can be downloaded from ``iers.IERS_B_URL``.

        See `~astropy.utils.iers.IERS_B.read` for instructions on how to read
        a pre-2023 style IERS B file (usually named ``eopc04_IAU2000.62-now``).
        """

        iers_table = None

        @classmethod
        def read(
            cls,
            file: str | os.PathLike[str] | None = None,
            readme: str | os.PathLike[str] | None = None,
            data_start: int = 6,
        ) -> Self:
            """Read IERS-B table from a eopc04.* file provided by IERS.

            Parameters
            ----------
            file : str or os.PathLike[str]
                full path to ascii file holding IERS-B data.
                Defaults to package version, ``iers.IERS_B_FILE``.
            readme : str or os.PathLike[str]
                full path to ascii file holding CDS-style readme.
                Defaults to package version, ``iers.IERS_B_README``.
            data_start : int
                Starting row. Default is 6, appropriate for standard IERS files.

            Returns
            -------
            ``IERS_B`` class instance

            Notes
            -----
            To read a pre-2023 style IERS B file (usually named something like
            ``eopc04_IAU2000.62-now``), do something like this example with an
            excerpt that is used for testing::

                >>> from astropy.utils.iers import IERS_B
                >>> from astropy.utils.data import get_pkg_data_filename
                >>> old_style_file = get_pkg_data_filename(
                ...     "tests/data/iers_b_old_style_excerpt",
                ...     package="astropy.utils.iers")
                >>> iers_b = IERS_B.read(
                ...     old_style_file,
                ...     readme=get_pkg_data_filename("data/ReadMe.eopc04_IAU2000",
                ...                                  package="astropy.utils.iers"),
                ...     data_start=14)

            """
            if file is None:
                file = IERS_B_FILE
            else:
                file = os.fspath(file)
            if readme is None:
                readme = IERS_B_README
            else:
                readme = os.fspath(readme)
            table = super().read(file, format="cds", readme=readme, data_start=data_start)

            table.meta["data_path"] = file
            table.meta["readme_path"] = readme
            return table

        def ut1_utc_source(self, i):
            """Set UT1-UTC source flag for entries in IERS table."""
            return np.ones_like(i) * FROM_IERS_B

        def dcip_source(self, i):
            """Set CIP correction source flag for entries in IERS table."""
            return np.ones_like(i) * FROM_IERS_B

        def pm_source(self, i):
            """Set PM source flag for entries in IERS table."""
            return np.ones_like(i) * FROM_IERS_B

    class IERS_Auto(IERS_A):
        """
        Provide most-recent IERS data and automatically handle downloading
        of updated values as necessary.

        The returned table combines the IERS-A and IERS-B files, with the data
        in the IERS-B file considered to be official values and thus superseding
        values from the IERS-A file at the same times.
        """

        iers_table = None

        @classmethod
        def open(cls):
            """
            This reads in the bundled IERS_A table (or the version of the file in
            the current working directory, if present - see `IERS_A.read`).

            Subsequently, if values are requested which are more recent than the
            range of validity of the bundled table, an updated version of the table
            will be downloaded if the configuration setting
            ``astropy.utils.iers.conf.auto_download`` is set to True (default) and
            if the available version of this file is older than
            ``astropy.utils.iers.conf.auto_max_age`` days old.

            If the configuration setting ``astropy.utils.iers.conf.auto_download``
            is set to False then the bundled IERS-A table will always be used.

            Returns
            -------
            `~astropy.table.QTable` instance
                With IERS (Earth rotation) data columns

            """
            if cls.iers_table is None:
                cls.iers_table = cls.read()
            return cls.iers_table

        def _check_interpolate_indices(self, indices_orig, indices_clipped, max_input_mjd):
            """Check that the indices from interpolation match those after clipping to the
            valid table range.  The IERS_Auto class is exempted as long as it has
            sufficiently recent available data so the clipped interpolation is
            always within the confidence bounds of current Earth rotation
            knowledge.
            """
            predictive_mjd = self.meta["predictive_mjd"]

            # See explanation in _refresh_table_as_needed for these conditions
            auto_max_age = _none_to_float(conf.auto_max_age)
            if (
                max_input_mjd > predictive_mjd
                and Time.now().mjd - predictive_mjd > auto_max_age
            ):
                raise ValueError(INTERPOLATE_ERROR.format(auto_max_age))

        def _refresh_table_as_needed(self, mjd):
            """Potentially update the IERS table in place depending on the requested
            time values in ``mjd`` and the time span of the table.

            For IERS_Auto the behavior is that the table is refreshed from the IERS
            server if both the following apply:

            - Any of the requested IERS values are predictive.  The IERS-A table
              contains predictive data out for a year after the available
              definitive values.
            - The first predictive values are at least ``conf.auto_max_age days`` old.
              In other words the IERS-A table was created by IERS long enough
              ago that it can be considered stale for predictions.
            """
            # If downloading is disabled, bail out silently.
            # _check_interpolate_indices() will error later if appropriate.
            if not conf.auto_download:
                return

            # Pass in initial to np.max to allow things to work for empty mjd.
            max_input_mjd = np.max(mjd, initial=50000)
            now_mjd = Time.now().mjd

            # IERS-A table contains predictive data out for a year after
            # the available definitive values.
            fpi = self.meta["predictive_index"]
            predictive_mjd = self.meta["predictive_mjd"]

            # Update table in place if necessary
            auto_max_age = _none_to_float(conf.auto_max_age)

            # If auto_max_age is smaller than IERS update time then repeated downloads may
            # occur without getting updated values (giving a IERSStaleWarning).
            if auto_max_age < 10:
                raise ValueError(
                    "IERS auto_max_age configuration value must be larger than 10 days"
                )

            if max_input_mjd > predictive_mjd and (now_mjd - predictive_mjd) > auto_max_age:
                all_urls = (conf.iers_auto_url, conf.iers_auto_url_mirror)

                # Get the latest version
                try:
                    filename = download_file(all_urls[0], sources=all_urls, cache="update")
                except Exception as err:
                    # Issue a warning here, perhaps user is offline.  An exception
                    # will be raised downstream when actually trying to interpolate
                    # predictive values.
                    warn(
                        AstropyWarning(
                            f"failed to download {' and '.join(all_urls)}: {err}.\nA"
                            " coordinate or time-related calculation might be compromised"
                            " or fail because the dates are not covered by the available"
                            ' IERS file.  See the "IERS data access" section of the'
                            " astropy documentation for additional information on working"
                            " offline."
                        )
                    )
                    return

                try:
                    new_table = self.__class__.read(file=filename)
                except Exception as err:
                    # The download succeeded but the content could not be parsed as
                    # an IERS table (e.g. the server returned an error page).  Keep
                    # using the existing table; an exception will be raised
                    # downstream when actually trying to interpolate predictive
                    # values.
                    warn(
                        AstropyWarning(
                            "malformed IERS table downloaded from"
                            f" {' and '.join(all_urls)}: {err}.\nA coordinate or"
                            " time-related calculation might be compromised or fail"
                            " because the dates are not covered by the available IERS"
                            ' file.  See the "IERS data access" section of the astropy'
                            " documentation for additional information on working"
                            " offline."
                        )
                    )
                    return
                new_table.meta["data_url"] = str(all_urls[0])

                # New table has new values?
                if new_table["MJD"][-1] > self["MJD"][-1]:
                    # Replace *replace* current values from the first predictive index through
                    # the end of the current table.  This replacement is much faster than just
                    # deleting all rows and then using add_row for the whole duration.
                    new_fpi = np.searchsorted(
                        new_table["MJD"].value, predictive_mjd, side="right"
                    )
                    n_replace = len(self) - fpi
                    self[fpi:] = new_table[new_fpi : new_fpi + n_replace]

                    # Sanity check for continuity
                    if new_table["MJD"][new_fpi + n_replace] - self["MJD"][-1] != 1.0 * u.d:
                        raise ValueError("unexpected gap in MJD when refreshing IERS table")

                    # Now add new rows in place
                    for row in new_table[new_fpi + n_replace :]:
                        self.add_row(row)

                    self.meta.update(new_table.meta)
                else:
                    warn(
                        IERSStaleWarning(
                            "IERS_Auto predictive values are older than"
                            f" {conf.auto_max_age} days but downloading the latest table"
                            " did not find newer values"
                        )
                    )

        @classmethod
        def _substitute_iers_b(cls, table):
            """Substitute IERS B values with those from a real IERS B table.

            IERS-A has IERS-B values included, but for reasons unknown these
            do not match the latest IERS-B values (see comments in #4436).
            Here, we use the bundled astropy IERS-B table to overwrite the values
            in the IERS-A table.
            """
            iers_b = IERS_B.open()
            # Substitute IERS-B values for existing B values in IERS-A table
            mjd_b = table["MJD"][np.isfinite(table["UT1_UTC_B"])]
            i0 = np.searchsorted(iers_b["MJD"], mjd_b[0], side="left")
            i1 = np.searchsorted(iers_b["MJD"], mjd_b[-1], side="right")
            iers_b = iers_b[i0:i1]
            n_iers_b = len(iers_b)
            # If there is overlap then replace IERS-A values from available IERS-B
            if n_iers_b > 0:
                # Sanity check that we are overwriting the correct values
                if not u.allclose(table["MJD"][:n_iers_b], iers_b["MJD"]):
                    raise ValueError(
                        "unexpected mismatch when copying IERS-B values into IERS-A table."
                    )
                # Finally do the overwrite
                table["UT1_UTC_B"][:n_iers_b] = iers_b["UT1_UTC"]
                table["PM_X_B"][:n_iers_b] = iers_b["PM_x"]
                table["PM_Y_B"][:n_iers_b] = iers_b["PM_y"]
                table["dX_2000A_B"][:n_iers_b] = iers_b["dX_2000A"]
                table["dY_2000A_B"][:n_iers_b] = iers_b["dY_2000A"]

            return table

    class earth_orientation_table(ScienceState):
        """Default IERS table for Earth rotation and reference systems service.

        These tables are used to calculate the offsets between ``UT1`` and ``UTC``
        and for conversion to Earth-based coordinate systems.

        The state itself is an IERS table, as an instance of one of the
        `~astropy.utils.iers.IERS` classes.  The default, the auto-updating
        `~astropy.utils.iers.IERS_Auto` class, should suffice for most
        purposes.

        Examples
        --------
        To temporarily use the IERS-B file packaged with astropy::

          >>> from astropy.utils import iers
          >>> from astropy.time import Time
          >>> iers_b = iers.IERS_B.open(iers.IERS_B_FILE)
          >>> with iers.earth_orientation_table.set(iers_b):
          ...     print(Time('2000-01-01').ut1.isot)
          2000-01-01T00:00:00.355

        To use the most recent IERS-A file for the whole session::

          >>> iers_a = iers.IERS_A.open(iers.IERS_A_URL)  # doctest: +SKIP
          >>> iers.earth_orientation_table.set(iers_a)  # doctest: +SKIP
          <ScienceState earth_orientation_table: <IERS_A length=17463>...>

        To go back to the default (of `~astropy.utils.iers.IERS_Auto`)::

          >>> iers.earth_orientation_table.set(None)  # doctest: +SKIP
          <ScienceState earth_orientation_table: <IERS_Auto length=17428>...>
        """

        _value = None

        @classmethod
        def validate(cls, value):
            if value is None:
                value = IERS_Auto.open()
            if not isinstance(value, IERS):
                raise ValueError("earth_orientation_table requires an IERS Table.")
            return value

    class LeapSeconds(QTable):
        """Leap seconds class, holding TAI-UTC differences.

        The table should hold columns 'year', 'month', 'tai_utc'.

        Methods are provided to initialize the table from IERS ``Leap_Second.dat``,
        IETF/ntp ``leap-seconds.list``, or built-in ERFA/SOFA, and to update the
        list used by ERFA.

        Notes
        -----
        Astropy has a built-in ``iers.IERS_LEAP_SECONDS_FILE``. Up to date versions
        can be downloaded from ``iers.IERS_LEAP_SECONDS_URL`` or
        ``iers.LEAP_SECONDS_LIST_URL``.  Many systems also store a version
        of ``leap-seconds.list`` for use with ``ntp`` (e.g., on Debian/Ubuntu
        systems, ``/usr/share/zoneinfo/leap-seconds.list``).

        To prevent querying internet resources if the available local leap second
        file(s) are out of date, set ``iers.conf.auto_download = False``. This
        must be done prior to performing any ``Time`` scale transformations related
        to UTC (e.g. converting from UTC to TAI).
        """

        # Note: Time instances in this class should use scale='tai' to avoid
        # needing leap seconds in their creation or interpretation.

        _re_expires = re.compile(r"^#.*File expires on[:\s]+(\d+\s\w+\s\d+)\s*$")
        _expires = None
        _auto_open_files = [
            "erfa",
            IERS_LEAP_SECOND_FILE,
            "system_leap_second_file",
            "iers_leap_second_auto_url",
            "ietf_leap_second_auto_url",
        ]
        """Files or conf attributes to try in auto_open."""

        @classmethod
        def open(cls, file=None, cache=False):
            """Open a leap-second list.

            Parameters
            ----------
            file : path-like or None
                Full local or network path to the file holding leap-second data,
                for passing on to the various ``from_`` class methods.
                If 'erfa', return the data used by the ERFA library.
                If `None`, use default locations from file and configuration to
                find a table that is not expired.
            cache : bool
                Whether to use cache. Defaults to False, since leap-second files
                are regularly updated.

            Returns
            -------
            leap_seconds : `~astropy.utils.iers.LeapSeconds`
                Table with 'year', 'month', and 'tai_utc' columns, plus possibly
                others.

            Notes
            -----
            Bulletin C is released about 10 days after a possible leap second is
            introduced, i.e., mid-January or mid-July.  Expiration days are thus
            generally at least 150 days after the present.  For the auto-loading,
            a list comprised of the table shipped with astropy, and files and
            URLs in `~astropy.utils.iers.Conf` are tried, returning the first
            that is sufficiently new, or the newest among them all.
            """
            if file is None:
                return cls.auto_open()

            if file.lower() == "erfa":
                return cls.from_erfa()

            if urlparse(file).netloc:
                file = download_file(file, cache=cache)

            # Just try both reading methods.
            try:
                return cls.from_iers_leap_seconds(file)
            except Exception:
                return cls.from_leap_seconds_list(file)

        @staticmethod
        def _today():
            # Get current day in scale='tai' without going through a scale change
            # (so we do not need leap seconds).
            s = "{0.year:04d}-{0.month:02d}-{0.day:02d}".format(datetime.now(tz=UTC))
            return Time(s, scale="tai", format="iso", out_subfmt="date")

        @classmethod
        def auto_open(cls, files=None):
            """Attempt to get an up-to-date leap-second list.

            The routine will try the files in sequence until it finds one
            whose expiration date is "good enough" (see below).  If none
            are good enough, it returns the one with the most recent expiration
            date, warning if that file is expired.

            For remote files that are cached already, the cached file is tried
            first before attempting to retrieve it again.

            Parameters
            ----------
            files : list of path-like, optional
                List of files/URLs to attempt to open.  By default, uses
                ``cls._auto_open_files``.

            Returns
            -------
            leap_seconds : `~astropy.utils.iers.LeapSeconds`
                Up to date leap-second table

            Notes
            -----
            Bulletin C is released about 10 days after a possible leap second is
            introduced, i.e., mid-January or mid-July.  Expiration days are thus
            generally at least 150 days after the present.  We look for a file
            that expires more than 180 - `~astropy.utils.iers.Conf.auto_max_age`
            after the present.
            """
            offset = 180 - (30 if conf.auto_max_age is None else conf.auto_max_age)
            good_enough = cls._today() + TimeDelta(offset, format="jd")

            if files is None:
                # Basic files to go over (entries in _auto_open_files can be
                # configuration items, which we want to be sure are up to date).
                files = [getattr(conf, f, f) for f in cls._auto_open_files]

            # Remove empty entries and normalize Path objects to string
            files = [os.fspath(f) for f in files if f]

            # Our trials start with normal files and remote ones that are
            # already in cache.  The bools here indicate that the cache
            # should be used.
            trials = [
                (f, True)
                for f in files
                if not urlparse(f).netloc or is_url_in_cache(f, on_missing="ignore")
            ]
            # If we are allowed to download, we try downloading new versions
            # if none of the above worked.
            if conf.auto_download:
                trials += [(f, False) for f in files if urlparse(f).netloc]

            self = None
            err_list = []
            # Go through all entries, and return the first one that
            # is not expired, or the most up to date one.
            for f, allow_cache in trials:
                if not allow_cache:
                    clear_download_cache(f)

                try:
                    trial = cls.open(f, cache=True)
                except Exception as exc:
                    err_list.append(exc)
                    continue

                if self is None or trial.expires > self.expires:
                    self = trial
                    self.meta["data_url"] = str(f)
                    if self.expires > good_enough:
                        break

            if self is None:
                raise ValueError(
                    "none of the files could be read. The "
                    f"following errors were raised:\n {err_list}"
                )

            if self.expires < self._today() and conf.auto_max_age is not None:
                warn("leap-second file is expired.", IERSStaleWarning)

            return self

        @property
        def expires(self):
            """The limit of validity of the table."""
            return self._expires

        @classmethod
        def _read_leap_seconds(cls, file, **kwargs):
            """Read a file, identifying expiration by matching 'File expires'."""
            expires = None
            # Find expiration date.
            with get_readable_fileobj(file) as fh:
                lines = fh.readlines()
                for line in lines:
                    match = cls._re_expires.match(line)
                    if match:
                        day, month, year = match.groups()[0].split()
                        month_nb = MONTH_ABBR.index(month[:3]) + 1
                        expires = Time(
                            f"{year}-{month_nb:02d}-{day}", scale="tai", out_subfmt="date"
                        )
                        break
                else:
                    raise ValueError(f"did not find expiration date in {file}")

            self = cls.read(lines, format="ascii.no_header", **kwargs)
            self._expires = expires
            return self

        @classmethod
        def from_iers_leap_seconds(cls, file=IERS_LEAP_SECOND_FILE):
            """Create a table from a file like the IERS ``Leap_Second.dat``.

            Parameters
            ----------
            file : path-like, optional
                Full local or network path to the file holding leap-second data
                in a format consistent with that used by IERS.  By default, uses
                ``iers.IERS_LEAP_SECOND_FILE``.

            Notes
            -----
            The file *must* contain the expiration date in a comment line, like
            '#  File expires on 28 June 2020'
            """
            return cls._read_leap_seconds(
                file, names=["mjd", "day", "month", "year", "tai_utc"]
            )

        @classmethod
        def from_leap_seconds_list(cls, file):
            """Create a table from a file like the IETF ``leap-seconds.list``.

            Parameters
            ----------
            file : path-like, optional
                Full local or network path to the file holding leap-second data
                in a format consistent with that used by IETF.  Up to date versions
                can be retrieved from ``iers.IETF_LEAP_SECOND_URL``.

            Notes
            -----
            The file *must* contain the expiration date in a comment line, like
            '# File expires on:  28 June 2020'
            """
            from astropy.io.ascii import convert_numpy  # Here to avoid circular import

            names = ["ntp_seconds", "tai_utc", "comment", "day", "month", "year"]
            # Note: ntp_seconds does not fit in 32 bit, so causes problems on
            # 32-bit systems without the np.int64 converter.
            self = cls._read_leap_seconds(
                file,
                names=names,
                include_names=names[:2],
                converters={"ntp_seconds": [convert_numpy(np.int64)]},
            )
            self["mjd"] = (self["ntp_seconds"] / 86400 + 15020).round()
            # Note: cannot use Time.ymdhms, since that might require leap seconds.
            isot = Time(self["mjd"], format="mjd", scale="tai").isot
            ymd = np.array(
                [[int(part) for part in t.partition("T")[0].split("-")] for t in isot]
            )
            self["year"], self["month"], self["day"] = ymd.T
            return self

        @classmethod
        def from_erfa(cls, built_in=False):
            """Create table from the leap-second list in ERFA.

            Parameters
            ----------
            built_in : bool
                If `False` (default), retrieve the list currently used by ERFA,
                which may have been updated.  If `True`, retrieve the list shipped
                with erfa.
            """
            current = cls(erfa.leap_seconds.get())
            expires = erfa.leap_seconds.expires
            current._expires = Time(
                f"{expires.year:04d}-{expires.month:02d}-{expires.day:02d}",
                scale="tai",
            )
            if not built_in:
                return current

            try:
                erfa.leap_seconds.set(None)  # reset to defaults
                return cls.from_erfa(built_in=False)
            finally:
                erfa.leap_seconds.set(current)

        def update_erfa_leap_seconds(self, initialize_erfa=False):
            """Add any leap seconds not already present to the ERFA table.

            This method matches leap seconds with those present in the ERFA table,
            and extends the latter as necessary.

            Parameters
            ----------
            initialize_erfa : bool, or 'only', or 'empty'
                Initialize the ERFA leap second table to its built-in value before
                trying to expand it.  This is generally not needed but can help
                in case it somehow got corrupted.  If equal to 'only', the ERFA
                table is reinitialized and no attempt it made to update it.
                If 'empty', the leap second table is emptied before updating, i.e.,
                it is overwritten altogether (note that this may break things in
                surprising ways, as most leap second tables do not include pre-1970
                pseudo leap-seconds; you were warned).

            Returns
            -------
            n_update : int
                Number of items updated.

            Raises
            ------
            ValueError
                If the leap seconds in the table are not on 1st of January or July,
                or if the matches are inconsistent.  This would normally suggest
                a corrupted leap second table, but might also indicate that the
                ERFA table was corrupted.  If needed, the ERFA table can be reset
                by calling this method with an appropriate value for
                ``initialize_erfa``.
            """
            if initialize_erfa == "empty":
                # Initialize to empty and update is the same as overwrite.
                erfa.leap_seconds.set(self)
                return len(self)

            if initialize_erfa:
                erfa.leap_seconds.set()
                if initialize_erfa == "only":
                    return 0

            return erfa.leap_seconds.update(self)