__all__ = ("Conditions",)

import functools
import warnings
from inspect import signature
from io import StringIO

import healpy as hp
import numpy as np
import pandas as pd

from rubin_scheduler.scheduler.utils import match_hp_resolution, smallest_signed_angle
from rubin_scheduler.utils import (
    DEFAULT_NSIDE,
    Site,
    _angular_separation,
    _approx_altaz2pa,
    _approx_ra_dec2_alt_az,
    _hpid2_ra_dec,
    calc_lmst,
    m5_flat_sed,
)


class Conditions:
    """Holds telemetry information, keeping calculated values in sync
    for `self.mjd` (such as ra/dec mappings to alt/az).

    Incoming values have setters to keep values in sync. Healpix maps
    are set to the expected (`self.nside`) resolution.

    Unless otherwise noted, all values are assumed to be valid at the time
    given by `self.mjd`.

    Parameters
    ----------
    nside : `int`, optional
        The healpixel nside to set the resolution of attributes.
        Default of None will use
        `rubin_scheduler.scheduler.utils.set_default_nside`.
    site : str ('LSST')
        A site name used to create a sims.utils.Site object. For
        looking up observatory parameters like latitude and longitude.
    exptime : `float`, optional
        The exposure time (seconds) to assume when computing the 5-sigma
        limiting depth maps stored in Conditions. These maps are used
        by basis functions, but are not used to calculate expected
        observation m5 values (such as when exposure time varies).
        Default 30 seconds.
    mjd : `float`, optional
        The current MJD.
        Default of None is fine on init - will be updated by telemetry
        stream.
    """

    global_maps = set(["ra", "dec", "slewtime", "airmass", "zeros_map", "nan_map"])
    by_band_maps = set(["skybrightness", "fwhm_eff", "m5_depth"])

    def __init__(
        self,
        nside=DEFAULT_NSIDE,
        site="LSST",
        exptime=30.0,
        mjd=None,
    ):
        """
        Attributes (Set on init)
        -----------
        nside : `int`
            Healpix resolution. All maps are set to this reslution.
        site : rubin_scheduler.Site object ('LSST')
            Contains static site-specific data (lat, lon, altitude, etc).
            Defaults to 'LSST'.
        ra : `np.ndarray`, (N,)
            A healpix array with the RA of each healpixel center (radians).
            Automatically generated.
        dec : `np.ndarray`, (N,)
            A healpix array with the Dec of each healpixel center (radians).
            Automatically generated.

        Attributes (to be set by user/telemetry stream/scheduler)
        -------------------------------------------
        mjd : `float`
            Modified Julian Date (days).
        bulk_cloud : `float`
            The fraction of sky covered by clouds. (In the future might
            update to transparency map)
        cloud_map : `np.ndarray`, (N,)
            XXX--to be done. HEALpix array with cloudy pixels set to NaN.
        slewtime : `np.ndarray`, (N,)
            Healpix showing the slewtime to each healpixel center (seconds)
        current_filter : `str`
            The name of the current filter. (expect one of u, g, r, i, z, y).
        mounted_filters : `list` [`str`]
            The filters that are currently mounted and thus available
            (expect 5 of u, g, r, i, z, y for LSSTCam).
        night : `int`
            The current night number (days). Probably starts at 1.
        skybrightness : `dict` {`str: `np.ndarray`, (N,)}
            Dictionary keyed by filter name.
            Values are healpix arrays with the sky brightness at each
            healpix center (mag/acsec^2)
        fwhm_eff : `dict` {`str: `np.ndarray`, (N,)}
            Dictionary keyed by filtername.
            Values are the effective seeing FWHM at each healpix
            center (arcseconds)
        moon_alt : `float`
            The altitude of the Moon (radians)
        moon_az : `float`
            The Azimuth of the moon (radians)
        moon_ra : `float`
            RA of the moon (radians)
        moon_dec : `float`
            Declination of the moon (radians)
        moon_phase : `float`
            The Phase of the moon. (percent, 0=new moon, 100=full moon)
        sun_alt : `float`
            The altitude of the sun (radians).
        sun_az : `float`
            The Azimuth of the sun (radians).
        sun_ra : `float`
            The RA of the sun (radians).
        sun_dec : `float`
            The Dec of the sun (radians).
        tel_ra : `float`
            The current telescope RA pointing (radians).
        tel_dec : `float`
            The current telescope Declination (radians).
        tel_alt : `float`
            The current telescope altitude (radians).
        tel_az : `float`
            The current telescope azimuth (radians).
        cumulative_azimuth_rad : `float`
            The cumulative telescope azimuth (radians).
            Used for tracking cable wrap.
        wind_speed : `float`
            Wind speed (m/s).
        wind_direction : `float`
            Direction from which the wind originates. A direction of
            0.0 degrees means the wind originates from the north and 90.0
            degrees from the east (radians).
        sunset : `float`
            The MJD of sunset that starts the current night. Note MJDs of
            sunset, moonset, twilight times, etc are from interpolations.
            This means the sun may actually be slightly above/below the
            horizon at the given sunset time.
        sun_n12_setting : `float`
            The MJD of when the sun is at -12 degrees altitude and
            setting during the current night. From interpolation.
        sun_n18_setting : `float`
            The MJD when the sun is at -18 degrees altitude and setting
            during the current night. From interpolation.
        sun_n18_rising : `float`
            The MJD when the sun is at -18 degrees altitude and rising
            during the current night. From interpolation.
        sun_n12_rising : `float`
            The MJD when the sun is at -12 degrees altitude and rising
            during the current night. From interpolation.
        sunrise : `float`
            The MJD of sunrise during the current night. From interpolation
        moonrise : `float`
            The MJD of moonrise during the current night. From interpolation.
        moonset : `float`
            The MJD of moon set during the current night. From interpolation.
        moon_phase_sunset : `float`
            The phase of the moon (0-100 illuminated) at sunset.
            Useful for setting which filters should be loaded.
        targets_of_opportunity : `list` [`rubin_scheduler.scheduler.targetoO`]
            targetoO objects.
        planet_positions : `dict` {`str`: `float`}
            Dictionary of planet name and coordinate e.g., 'venus_RA',
            'mars_dec'
        scheduled_observations : `np.ndarray`, (M,)
            A list of MJD times when there are scheduled observations.
            Defaults to empty array.
        sky_az_limits : `list` [[`float`, `float`]]
            A list of lists giving valid azimuth ranges. e.g.,
            [0, 2*np.pi] would mean all azimuth values are valid, while
            [[0, np.pi/2], [3*np.pi/2, 2*np.pi]] would mean anywhere in
            the south is invalid.  Radians.
        sky_alt_limits : `list` [[`float`, `float`]]
            A list of lists giving valid altitude ranges. Radians.
        tel_az_limits : `list` [`float`, `float`]
            A simple two-element list giving the valid azimuth ranges
            for the telescope movement. Radians.
        tel_alt_limits : `list` [`float`, `float`]
            A simple two-element list giving the valid altitude ranges
            for the telescope movement. Radians

        Attributes (calculated on demand and cached)
        ------------------------------------------
        alt : `np.ndarray`, (N,)
            Altitude of each healpixel (radians). Recalculated if mjd is
            updated. Uses fast approximate equation for converting
            RA,Dec to alt,az.
        az : `np.ndarray`, (N,)
            Azimuth of each healpixel (radians). Recalculated if mjd is
            updated. Uses fast approximate equation for converting RA,Dec
            to alt,az.
        airmass : `np.ndarray`, (N,)
            A healpix map with the airmass value of each healpixel. (unitless)
        pa : `np.ndarray`, (N,)
            The parallactic angle of each healpixel (radians). Recalculated
            if mjd is updated. Based on the fast approximate alt,az values.
        lmst : `float`
            The local mean sidereal time (hours). Updates is mjd is changed.
        m5_depth : `dict` {`str: `np.ndarray`, (N,)}
            the 5-sigma limiting depth healpix maps, keyed by filtername
            (mags). Will be recalculated if the skybrightness, seeing,
            or airmass are updated.
        HA : `np.ndarray`, (N,)
            Healpix map of the hour angle of each healpixel (radians).
            Runs from 0 to 2pi.
        az_to_sun : `np.ndarray`, (N,)
            Healpix map of the azimuthal distance to the sun for each
            healpixel (radians)
        az_to_anitsun : `np.ndarray`, (N,)
            Healpix map of the azimuthal distance to the anit-sun for each
            healpixel (radians)
        solar_elongation : `np.ndarray`, (N,)
            Healpix map of the solar elongation (angular distance to the sun)
            for each healpixel (radians)

        Attributes (set by the scheduler)
        -------------------------------
        queue : `list` [`observation` objects]
            The current queue of observations core_scheduler is waiting to
            execute.

        """
        self.nside = nside

        # The RA, Dec grid we are using
        hpids = np.arange(hp.nside2npix(self.nside))
        self.ra, self.dec = _hpid2_ra_dec(self.nside, hpids)

        self.site = Site(site)
        self.exptime = exptime

        # Generate an empty map so we can copy when we need a new map
        self.zeros_map = np.zeros(hp.nside2npix(self.nside), dtype=float)
        self.nan_map = np.zeros(hp.nside2npix(self.nside), dtype=float)
        self.nan_map.fill(np.nan)

        # Set other attributes to Nones
        self._init_attributes()
        self.mjd = mjd

    def _init_attributes(self):
        """Initialize or clear all the attributes"""

        # Modified Julian Date (day)
        self._mjd = None
        # Altitude and azimuth. Dict with degrees and radians
        self._alt = None
        self._az = None
        self._pa = None

        # The cloud level. Fraction, but could upgrade to transparency map
        self.clouds = None
        self._slewtime = None
        self.current_filter = None
        self.mounted_filters = None
        self.night = None
        self._lmst = None
        # Should be a dict with filtername keys
        self._skybrightness = {}
        self._fwhm_eff = {}
        self._m5_depth = None
        self._airmass = None

        self.wind_speed = None
        self.wind_direction = None

        # Upcoming scheduled observations
        self.scheduled_observations = np.array([], dtype=float)

        # Attribute to hold the current observing queue
        self.queue = None

        # Moon
        self.moon_alt = None
        self.moon_az = None
        self.moon_ra = None
        self.moon_dec = None
        self.moon_phase = None

        # Sun
        self.sun_alt = None
        self.sun_az = None
        self.sun_ra = None
        self.sun_dec = None

        # Almanac information
        self.sunset = None
        self.sun_n12_setting = None
        self.sun_n18_setting = None
        self.sun_n18_rising = None
        self.sun_n12_rising = None
        self.sunrise = None
        self.moonrise = None
        self.moonset = None
        self.moon_phase_sunset = None

        self.planet_positions = None

        # Current telescope pointing
        self.tel_ra = None
        self.tel_dec = None
        self.tel_alt = None
        self.tel_az = None
        self.cumulative_azimuth_rad = None

        # Sky coverage limits.
        # These should be in radians.
        self.sky_az_limits = None
        self.sky_alt_limits = None
        # Kinematic model (real slew) limits.
        self.tel_alt_limits = None
        self.tel_az_limits = None

        # Full sky cloud map
        self._cloud_map = None
        self._HA = None

        # XXX--document
        self.bulk_cloud = None

        self.rot_tel_pos = None

        self.targets_of_opportunity = None

        # Potential attributes that get computed
        self._solar_elongation = None
        self._az_to_sun = None
        self._az_to_antisun = None

    @property
    def lmst(self):
        if self._lmst is None:
            self._lmst = calc_lmst(self.mjd, self.site.longitude_rad)

        return self._lmst

    @lmst.setter
    def lmst(self, value):
        self._lmst = value
        self._HA = None

    @property
    def HA(self):
        if self._HA is None:
            self.calc_ha()
        return self._HA

    def calc_ha(self):
        self._HA = np.radians(self.lmst * 360.0 / 24.0) - self.ra
        self._HA[np.where(self._HA < 0)] += 2.0 * np.pi

    @property
    def cloud_map(self):
        return self._cloud_map

    @cloud_map.setter
    def cloud_map(self, value):
        self._cloud_map = match_hp_resolution(value, nside_out=self.nside)

    @property
    def slewtime(self):
        return self._slewtime

    @slewtime.setter
    def slewtime(self, value):
        # Using 0 for start of night
        if np.size(value) == 1:
            self._slewtime = value
        else:
            self._slewtime = match_hp_resolution(value, nside_out=self.nside)

    @property
    def airmass(self):
        if self._airmass is None:
            self.calc_airmass()
        return self._airmass

    def calc_airmass(self):
        self._airmass = np.zeros(self._alt.size, dtype=float)
        self._airmass.fill(np.nan)
        alt_limit = self.tel_alt_limits
        if alt_limit is None:
            alt_limit = 0
        else:
            alt_limit = np.min(alt_limit)
        good = np.where(self._alt > alt_limit)
        self._airmass[good] = 1.0 / np.cos(np.pi / 2.0 - self._alt[good])

    @property
    def pa(self):
        if self._pa is None:
            self.calc_pa()
        return self._pa

    def calc_pa(self):
        self._pa = _approx_altaz2pa(self.alt, self.az, self.site.latitude_rad)

    @property
    def alt(self):
        if self._alt is None:
            self.calc_alt_az()
        return self._alt

    @property
    def az(self):
        if self._az is None:
            self.calc_alt_az()
        return self._az

    def calc_alt_az(self):
        self._alt, self._az = _approx_ra_dec2_alt_az(
            self.ra,
            self.dec,
            self.site.latitude_rad,
            self.site.longitude_rad,
            self._mjd,
        )

    @functools.lru_cache(maxsize=10)
    def future_alt_az(self, mjd):
        """Compute the altitude and azimuth for a future time.

        Returns
        -------
        altitude : `np.array`
            The altutude of each healpix at MJD (radians)
        azimuth : : `np.array`
            The azimuth of each healpix at MJD (radians)
        """
        alt, az = _approx_ra_dec2_alt_az(
            self.ra,
            self.dec,
            self.site.latitude_rad,
            self.site.longitude_rad,
            mjd,
        )
        return alt, az

    @property
    def mjd(self):
        return self._mjd

    @mjd.setter
    def mjd(self, value):
        # If MJD is changed, everything else is no longer valid, so re-init
        if self.mjd is not None:
            warnings.warn("Changing MJD and resetting all attributes.")
        self._init_attributes()
        self._mjd = value

    @property
    def skybrightness(self):
        return self._skybrightness

    @skybrightness.setter
    def skybrightness(self, indict):
        for key in indict:
            self._skybrightness[key] = match_hp_resolution(indict[key], nside_out=self.nside)
        # If sky brightness changes, need to recalc M5 depth.
        self._m5_depth = None

    @property
    def fwhm_eff(self):
        return self._fwhm_eff

    @fwhm_eff.setter
    def fwhm_eff(self, indict):
        for key in indict:
            self._fwhm_eff[key] = match_hp_resolution(indict[key], nside_out=self.nside)
        self._m5_depth = None

    @property
    def m5_depth(self):
        if self._m5_depth is None:
            self.calc_m5_depth()
        return self._m5_depth

    def calc_m5_depth(self):
        self._m5_depth = {}
        for filtername in self._skybrightness:
            good = ~np.isnan(self._skybrightness[filtername])
            self._m5_depth[filtername] = self.nan_map.copy()
            self._m5_depth[filtername][good] = m5_flat_sed(
                filtername,
                self._skybrightness[filtername][good],
                self._fwhm_eff[filtername][good],
                self.exptime,
                self._airmass[good],
            )

    def calc_solar_elongation(self):
        self._solar_elongation = _angular_separation(self.ra, self.dec, self.sun_ra, self.sun_dec)

    @property
    def solar_elongation(self):
        if self._solar_elongation is None:
            self.calc_solar_elongation()
        return self._solar_elongation

    def calc_az_to_sun(self):
        self._az_to_sun = smallest_signed_angle(self.ra, self.sun_ra)

    def calc_az_to_antisun(self):
        self._az_to_antisun = smallest_signed_angle(self.ra + np.pi, self.sun_ra)

    @property
    def az_to_sun(self):
        if self._az_to_sun is None:
            self.calc_az_to_sun()
        return self._az_to_sun

    @property
    def az_to_antisun(self):
        if self._az_to_antisun is None:
            self.calc_az_to_antisun()
        return self._az_to_antisun

    def set_auxtel_info(
        self,
        mjd,
        slewtime,
        sun_n18_setting,
        sun_n18_rising,
        moon_alt,
        moon_az,
        tel_alt_limits,
        tel_az_limits,
        sky_alt_limits,
        sky_az_limits,
        wind_speed,
        wind_direction,
        **kwargs,
    ):
        """Method to set all the information we expect will be required by
        a standard auxtel scheduler. Extra attributes can be set via **kwargs.

        Parameters
        ----------
        mjd : `float`
            Modified Julian Date (days).
        slewtime : `np.ndarray`, (N,)
            Healpix showing the slewtime to each healpixel center (seconds)
        moon_alt : `float`
            The altitude of the Moon (radians)
        moon_az : `float`
            The Azimuth of the moon (radians)
        wind_speed : `float`
            Wind speed (m/s).
        wind_direction : `float`
            Direction from which the wind originates. A direction of
            0.0 degrees means the wind originates from the north and 90.0
            degrees from the east (radians).
        sun_n18_setting : `float`
            The MJD when the sun is at -18 degrees altitude and setting
            during the current night. From interpolation.
        sun_n18_rising : `float`
            The MJD when the sun is at -18 degrees altitude and rising
            during the current night. From interpolation.
        sky_az_limits : `list` [[`float`, `float`]]
            A list of lists giving valid azimuth ranges. e.g.,
            [0, 2*np.pi] would mean all azimuth values are valid, while
            [[0, np.pi/2], [3*np.pi/2, 2*np.pi]] would mean anywhere in
            the south is invalid.  Radians.
        sky_alt_limits : `list` [[`float`, `float`]]
            A list of lists giving valid altitude ranges. Radians.
        tel_az_limits : `list` [`float`, `float`]
            A simple two-element list giving the valid azimuth ranges
            for the telescope movement. Radians.
        tel_alt_limits : `list` [`float`, `float`]
            A simple two-element list giving the valid altitude ranges
            for the telescope movement. Radians
        """
        self._init_attributes()
        auxtel_args = signature(self.set_auxtel_info)
        loc = locals()
        for key in auxtel_args.parameters.keys():
            setattr(self, key, loc[key])

        potential_attrs = dir(self)
        for key in kwargs:
            if key not in potential_attrs:
                warnings.warn("Setting unexpected Conditions attribute %s" % key)
            setattr(self, key, kwargs[key])

    def set_maintel_info(
        self,
        mjd,
        slewtime,
        current_filter,
        mounted_filters,
        night,
        skybrightness,
        fwhm_eff,
        moon_alt,
        moon_az,
        moon_ra,
        moon_dec,
        moon_phase,
        sun_alt,
        sun_az,
        sun_ra,
        sun_dec,
        tel_ra,
        tel_dec,
        tel_alt,
        tel_az,
        wind_speed,
        wind_direction,
        sun_n12_setting,
        sun_n12_rising,
        sun_n18_setting,
        sun_n18_rising,
        moonrise,
        moonset,
        planet_positions,
        tel_alt_limits,
        tel_az_limits,
        sky_alt_limits,
        sky_az_limits,
        **kwargs,
    ):
        """Method to set all the information we expect will be required by
        a standard maintel scheduler. Extra attributes can be set via **kwargs.

        mjd : `float`
            Modified Julian Date (days).
        slewtime : `np.ndarray`, (N,)
            Healpix showing the slewtime to each healpixel center (seconds)
        current_filter : `str`
            The name of the current filter. (expect one of u, g, r, i, z, y).
        mounted_filters : `list` [`str`]
            The filters that are currently mounted and thus available
            (expect 5 of u, g, r, i, z, y for LSSTCam).
        night : `int`
            The current night number (days). Probably starts at 1.
        skybrightness : `dict` {`str: `np.ndarray`, (N,)}
            Dictionary keyed by filter name.
            Values are healpix arrays with the sky brightness at each
            healpix center (mag/acsec^2)
        fwhm_eff : `dict` {`str: `np.ndarray`, (N,)}
            Dictionary keyed by filtername.
            Values are the effective seeing FWHM at each healpix
            center (arcseconds)
        moon_alt : `float`
            The altitude of the Moon (radians)
        moon_az : `float`
            The Azimuth of the moon (radians)
        moon_ra : `float`
            RA of the moon (radians)
        moon_dec : `float`
            Declination of the moon (radians)
        moon_phase : `float`
            The Phase of the moon. (percent, 0=new moon, 100=full moon)
        sun_alt : `float`
            The altitude of the sun (radians).
        sun_az : `float`
            The Azimuth of the sun (radians).
        sun_ra : `float`
            The RA of the sun (radians).
        sun_dec : `float`
            The Dec of the sun (radians).
        tel_ra : `float`
            The current telescope RA pointing (radians).
        tel_dec : `float`
            The current telescope Declination (radians).
        tel_alt : `float`
            The current telescope altitude (radians).
        tel_az : `float`
            The current telescope azimuth (radians).
        wind_speed : `float`
            Wind speed (m/s).
        wind_direction : `float`
            Direction from which the wind originates. A direction of
            0.0 degrees means the wind originates from the north and 90.0
            degrees from the east (radians).
        sun_n12_setting : `float`
            The MJD of when the sun is at -12 degrees altitude and
            setting during the current night. From interpolation.
        sun_n18_setting : `float`
            The MJD when the sun is at -18 degrees altitude and setting
            during the current night. From interpolation.
        sun_n18_rising : `float`
            The MJD when the sun is at -18 degrees altitude and rising
            during the current night. From interpolation.
        sun_n12_rising : `float`
            The MJD when the sun is at -12 degrees altitude and rising
            during the current night. From interpolation.
        moonrise : `float`
            The MJD of moonrise during the current night. From interpolation.
        moonset : `float`
            The MJD of moon set during the current night. From interpolation.
        moon_phase_sunset : `float`
            The phase of the moon (0-100 illuminated) at sunset.
            Useful for setting which filters should be loaded.
        targets_of_opportunity : `list` [`rubin_scheduler.scheduler.targetoO`]
            targetoO objects.
        planet_positions : `dict` {`str`: `float`}
            Dictionary of planet name and coordinate e.g., 'venus_RA',
            'mars_dec'
        sky_az_limits : `list` [[`float`, `float`]]
            A list of lists giving valid azimuth ranges. e.g.,
            [0, 2*np.pi] would mean all azimuth values are valid, while
            [[0, np.pi/2], [3*np.pi/2, 2*np.pi]] would mean anywhere in
            the south is invalid.  Radians.
        sky_alt_limits : `list` [[`float`, `float`]]
            A list of lists giving valid altitude ranges. Radians.
        tel_az_limits : `list` [`float`, `float`]
            A simple two-element list giving the valid azimuth ranges
            for the telescope movement. Radians.
        tel_alt_limits : `list` [`float`, `float`]
            A simple two-element list giving the valid altitude ranges
            for the telescope movement. Radians
        """
        self._init_attributes()

        maintel_args = signature(self.set_maintel_info)
        loc = locals()
        for key in maintel_args.parameters.keys():
            setattr(self, key, loc[key])

        potential_attrs = dir(self)
        for key in kwargs:
            if key not in potential_attrs:
                warnings.warn("Setting unexpected Conditions attribute %s" % key)
            setattr(self, key, kwargs[key])

    def set_attrs(self, **kwargs):
        """Convience function for setting lots of attributes at once.
        See __init__ docstring for full list of potential attributes."""
        potential_attrs = dir(self)
        for key in kwargs:
            if key not in potential_attrs:
                warnings.warn("Setting unexpected Conditions attribute %s" % key)
            setattr(self, key, kwargs[key])

    def __repr__(self):
        return f"<{self.__class__.__name__} mjd='{self.mjd}' at {hex(id(self))}>"

    def __str__(self):
        # If dependencies of to_markdown are not installed, fall back on repr
        try:
            pd.DataFrame().to_markdown()
        except ImportError:
            return repr(self)

        if self.mjd is None:
            # Many elements of the pretty str representation rely on
            # the time beging set. If it is not, just return the ugly form.
            return repr(self)

        output = StringIO()
        print(f"{self.__class__.__qualname__} at {hex(id(self))}", file=output)
        print("============================", file=output)
        print("nside: ", self.nside, "  ", file=output)
        print("site: ", self.site.name, "  ", file=output)
        print("exptime: ", self.exptime, "  ", file=output)
        print("lmst: ", self.lmst, "  ", file=output)
        print("clouds: ", self.clouds, "  ", file=output)
        print("current_filter: ", self.current_filter, "  ", file=output)
        print("mounted_filters: ", self.mounted_filters, "  ", file=output)
        print("night: ", self.night, "  ", file=output)
        print("wind_speed: ", self.wind_speed, "  ", file=output)
        print("wind_direction: ", self.wind_direction, "  ", file=output)
        print(
            "len(scheduled_observations): ",
            len(self.scheduled_observations),
            "  ",
            file=output,
        )
        print(
            "len(queue): ",
            None if self.queue is None else len(self.queue),
            "  ",
            file=output,
        )
        print("moonPhase: ", self.moon_phase, "  ", file=output)
        print("bulk_cloud: ", self.bulk_cloud, "  ", file=output)
        print("targets_of_opportunity: ", self.targets_of_opportunity, "  ", file=output)
        print("cumulative_azimuth_rad: ", self.cumulative_azimuth_rad, "  ", file=output)

        positions = [
            {
                "name": "sun",
                "alt": self.sun_alt,
                "az": self.sun_az,
                "RA": self.sun_ra,
                "decl": self.sun_dec,
            }
        ]
        positions.append(
            {
                "name": "moon",
                "alt": self.moon_alt,
                "az": self.moon_az,
                "RA": self.moon_ra,
                "decl": self.moon_dec,
            }
        )
        for planet_name in ("venus", "mars", "jupiter", "saturn"):
            positions.append(
                {
                    "name": planet_name,
                    "RA": float(np.max(self.planet_positions[planet_name + "_RA"])),
                    "decl": float(np.max(self.planet_positions[planet_name + "_dec"])),
                }
            )
        positions.append(
            {
                "name": "telescope",
                "alt": np.max(self.tel_alt),
                "az": np.max(self.tel_az),
                "RA": np.max(self.tel_ra),
                "decl": np.max(self.tel_dec),
                "rot": np.max(self.rot_tel_pos),
            }
        )
        positions = pd.DataFrame(positions).set_index("name")
        print(file=output)
        print("Positions (radians)", file=output)
        print("-------------------", file=output)
        print(positions.to_markdown(), file=output)

        positions_deg = np.degrees(positions)
        print(file=output)
        print("Positions (degrees)", file=output)
        print("-------------------", file=output)
        print(positions_deg.to_markdown(), file=output)

        events = (
            "mjd",
            "sunset",
            "sun_n12_setting",
            "sun_n18_setting",
            "sun_n18_rising",
            "sun_n12_rising",
            "sunrise",
            "moonrise",
            "moonset",
            "sun_0_setting",
            "sun_0_rising",
        )
        event_rows = []
        for event in events:
            try:
                mjd = getattr(self, event)
                time = pd.to_datetime(float(mjd) + 2400000.5, unit="D", origin="julian")
                event_rows.append({"event": event, "MJD": mjd, "date": time})
            except AttributeError:
                pass

        event_df = pd.DataFrame(event_rows).set_index("event").sort_values(by="MJD")
        print("", file=output)
        print("Events", file=output)
        print("------", file=output)
        print(event_df.to_markdown(), file=output)

        map_stats = []
        for map_name in self.global_maps - set(["zeros_map", "nan_map"]):
            try:
                values = getattr(self, map_name)
                map_stats.append(
                    {
                        "map": map_name,
                        "nside": hp.npix2nside(len(values)),
                        "min": np.nanmin(values),
                        "max": np.nanmax(values),
                        "median": np.nanmedian(values),
                    }
                )
            except AttributeError:
                pass

        for base_map_name in self.by_band_maps:
            for band in "ugrizy":
                try:
                    values = getattr(self, base_map_name)[band]
                    map_name = f"{base_map_name}_{band}"
                    map_stats.append(
                        {
                            "map": map_name,
                            "nside": hp.npix2nside(len(values)),
                            "min": np.nanmin(values),
                            "max": np.nanmax(values),
                            "median": np.nanmedian(values),
                        }
                    )
                except AttributeError:
                    pass

        maps_df = pd.DataFrame(map_stats).set_index("map")
        print("", file=output)
        print("Maps", file=output)
        print("----", file=output)
        print(maps_df.to_markdown(), file=output)

        result = output.getvalue()
        return result

    def _repr_markdown_(self):
        return str(self)
