#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Simulate exoplanet transmission and/or emission spectroscopy without
using the coronagraph routines. This uses the same telesope and detector
parameters as the coronagraph model, but does not suppress the star's light.
As a result, stellar photons dominate the noise budget.

For transmission spectroscopy calculations use :class:`TransitNoise`,
and for emission spectroscopy use :class:`EclipseNoise`. You may also get an
example transmission and emission spectrum of the Earth by calling
:func:`get_earth_trans_spectrum`.

"""

from __future__ import (division as _, print_function as _,
                absolute_import as _, unicode_literals as _)

import numpy as np
import astropy.units as u
import matplotlib.pyplot as plt
import sys, os

from .noise_routines import *
from .degrade_spec import *
from .observe import random_draw
from .teleplanstar import *

__all__ = ["TransitNoise", "EclipseNoise", "get_earth_trans_spectrum"]

h = 6.62607004e-34
c = 2.998e8

class EclipseNoise(object):
    """
    Simulate exoplanet secondary eclipse emission spectroscopy with a next-generation
    telescope.

    Parameters
    ----------
    telescope : Telescope
        Initialized object containing ``Telescope`` parameters
    planet : Planet
        Initialized object containing ``Planet`` parameters
    star : Star
        Initialized object containing ``Star`` parameters
    tdur : float
        Transit duration [s]
    ntran : float
        Number of transits/eclipses
    nout : float
        Number of out-of-eclipse transit durations to observe
    wantsnr : float, optional
        Desired signal-to-noise ratio in each pixel
    FIX_OWA : bool, optional
        Set to fix OWA at ``OWA*lammin/D``, as would occur if lenslet array is
        limiting the OWA
    COMPUTE_LAM : bool, optional
        Set to compute lo-res wavelength grid, otherwise the grid input as
        variable ``lam`` is used
    SILENT : bool, optional
        Set to suppress print statements
    NIR : bool, optional
        Re-adjusts pixel size in NIR, as would occur if a second instrument
        was designed to handle the NIR
    THERMAL : bool, optional
        Set to compute thermal photon counts due to telescope temperature
    GROUND : bool, optional
        Set to simulate ground-based observations through atmosphere
    vod : bool, optional
        "Valley of Death" red QE parameterization from Robinson et al. (2016)

    """
    def __init__(self, tdur    = 3432.,  # TRAPPIST-1e
                       telescope = Telescope(),
                       planet = Planet(),
                       star = Star(),
                       ntran   = 1,
                       nout    = 1,
                       wantsnr = 1000.0,
                       NIR     = True,
                       THERMAL = True,
                       GROUND  = False,
                       vod     = False,
                       IMAGE   = False):
        self.telescope = telescope
        self.planet    = planet
        self.star      = star
        self.tdur      = tdur
        self.ntran     = ntran
        self.nout      = nout
        self.wantsnr   = wantsnr
        self.NIR       = NIR
        self.THERMAL   = THERMAL
        self.GROUND    = GROUND
        self.vod       = vod
        self.IMAGE     = IMAGE

        self._computed = False

        return

    def run_count_rates(self, lamhr = None, Fphr = None, Fshr = None):
        """
        Calculate the photon count rates and signal to noise on a secondary
        eclipse spectrum observation

        Parameters
        ----------
        lamhr : numpy.ndarray
            Wavelength [$\mu$m]
        Fphr : numpy.ndarray
            Dayside exoplanet TOA flux spectrum [W/m$^2$/$\mu$]
        Fshr : numpy.ndarray
            Stellar flux incident at the planet's TOA [W/m$^2$/$\mu$]

        Calling ``run_count_rates()`` creates the following attributes for
        the ``EclipseNoise`` instance:

        Attributes
        ----------
        lamhr : array
            Wavelength [$\mu$m]
        Fphr : array
            Dayside exoplanet TOA flux spectrum [W/m$^2$/$\mu$]
        Fshr : array
            Stellar flux incident at the planet's TOA [W/m$^2$/$\mu$]
        cs : array
            Stellar photon count rate [photons/s]
        cback : array
            Background photon count rate [photons/s]
        cz : array
            Zodi photon count rate [photons/s]
        cez : array
            Exo-zodi photon count rate [photons/s]
        cth : array
            Thermal photon count rate [photons/s]
        cD : array
            Dark current photon count rate [photons/s]
        cR : array
            Read noise photon count rate [photons/s]
        cmiss : array
            Occulted stellar photon count rate [photons/s]
        SNR1 : array
            S/N for one eclipse
        SNRn : array
            S/N for ``ntran`` eclipses
        tSNR : array
            Exposure time to ``wantsnr`` [s]
        nSNR : array
            Number of eclipses to ``wantsnr``
        lam : array
            Observed wavelength grid [$\mu$m]
        dlam : array
            Observed wavelength grid widths [$\mu$m]
        FpFslr : array
            Low-res planet/star flux ratio
        FpFshr : array
            High-res planetr/star flux ratio
        """

        self.lamhr = lamhr
        self.Fphr = Fphr
        self.Fshr = Fshr

        if self.telescope.A_collect is None:
            diam_collect = self.telescope.diameter
        else:
            diam_collect = 2. * (self.telescope.A_collect / np.pi)**0.5

        # Set the convolution function
        convolution_function = downbin_spec

        # Does the telescope object already have a wavelength grid?
        if (self.telescope.lam is None) or (self.telescope.dlam is None):
            # Create wavelength grid
            lam, dlam = construct_lam(self.telescope.lammin,
                                      self.telescope.lammax,
                                      self.telescope.resolution)
        else:
            # Use existing grids
            lam = self.telescope.lam
            dlam = self.telescope.dlam

        # Set Quantum Efficiency
        q = set_quantum_efficiency(lam,
                                   self.telescope.qe,
                                   NIR=self.NIR,
                                   vod=self.vod)

        # Set Dark current and Read noise
        De = set_dark_current(lam,
                              self.telescope.darkcurrent,
                              self.telescope.lammax,
                              self.telescope.Tdet,
                              NIR=self.NIR)
        Re = set_read_noise(lam,
                            self.telescope.readnoise,
                            NIR=self.NIR)

        # Set Angular size of lenslet
        theta = set_lenslet(lam,
                            self.telescope.lammin,
                            diam_collect,
                            self.telescope.X,
                            NIR=self.NIR)

        # Set throughput
        #sep  = r/d*np.sin(alpha*np.pi/180.)*np.pi/180./3600. # separation in radians
        #T = set_throughput(lam, Tput, diam, sep, IWA, OWA, lammin, FIX_OWA=FIX_OWA, SILENT=SILENT)
        T = self.telescope.throughput * np.ones_like(lam)

        # Apply wavelength-dependent throuput, if needed
        if self.telescope.Tput_lam is not None:
            # Bin input throughput curve to native res
            Tlam = np.interp(lam, self.telescope.Tput_lam[0], self.telescope.Tput_lam[1])
            # Multiply into regular throughput
            T = T * Tlam

        # Apply wavelength-dependent quantum efficiency, if needed
        if self.telescope.qe_lam is not None:
            # Bin input QE curve to native res
            qlam = np.interp(lam, self.telescope.qe_lam[0], self.telescope.qe_lam[1])
            # Multiply into regular QE
            q = q * qlam

        # Modify throughput by atmospheric transmission if GROUND-based
        if self.GROUND:
            # Use SMART calc
            Tatmos = set_atmos_throughput(lam, dlam, convolution_function)
            # Multiply telescope throughput by atmospheric throughput
            T = T * Tatmos

        # Calculate intensity of the planet [W/m^2/um/sr]
        if Fphr is None:
            # Using a blackbody
            Bplan = planck(self.planet.Tplan, lamhr)
        else:
            # Using provided TOA planet flux
            Bplan = Fphr / np.pi

        # Calculate intensity of the star [W/m^2/um/sr]
        if Fshr is None:
            # Using a blackbody
            Bstar = planck(self.star.Teff, lamhr)
        else:
            # Using provided TOA stellar flux
            Bstar = Fshr / ( np.pi*(self.star.Rs*u.Rsun.in_units(u.km)/\
                           (self.planet.a*u.AU.in_units(u.km)))**2. )

        # Solid angle in steradians
        omega_star = np.pi*(self.star.Rs*u.Rsun.in_units(u.km)/\
                           (self.planet.distance*u.pc.in_units(u.km)))**2.
        omega_planet = np.pi*(self.planet.Rp*u.Rearth.in_units(u.km)/\
                             (self.planet.distance*u.pc.in_units(u.km)))**2.

        # Fluxes at earth [W/m^2/um]
        Fs = Bstar * omega_star
        Fp = Bplan * omega_planet
        FpFs = Fp/Fs

        # Degrade planet and stellar spectrum to instrument res
        Fplr = convolution_function(Fp, lamhr, lam, dlam=dlam)
        Fslr = convolution_function(Fs, lamhr, lam, dlam=dlam)
        FpFslr = convolution_function(FpFs, lamhr, lam, dlam=dlam)

        # Fraction of planetary signal in Airy pattern
        fpa = 1.0   # No fringe pattern here --> all of stellar psf falls on CCD

        ########## Calculate Photon Count Rates ##########

        # Planet photon count rate
        cp = cplan(q, fpa, T, lam, dlam, Fplr, diam_collect)

        # Stellar photon count rate
        cs = cstar(q, fpa, T, lam, dlam, Fslr, diam_collect)

        # Solar System Zodi count rate
        cz =  czodi(q, self.telescope.X, T, lam, dlam,
                    diam_collect, self.planet.MzV)

        # Exo-Zodi count rate
        cez =  cezodi(q, self.telescope.X, T, lam, dlam, diam_collect,
                      self.planet.a,
                      Fstar(lam, self.star.Teff, self.star.Rs, 1., AU=True),
                      self.planet.Nez, self.planet.MezV)

        # Dark current count rate
        cD =  cdark(De, self.telescope.X, lam,
                    diam_collect, theta,
                    self.telescope.DNHpix, IMAGE=self.IMAGE)

        # Read noise count rate
        cR =  cread(Re, self.telescope.X, lam, diam_collect,
                    theta, self.telescope.DNHpix, self.telescope.Dtmax,
                    IMAGE=self.IMAGE)

        # Thermal background count rate
        if self.THERMAL:
            # telescope internal thermal count rate
            cth =  ctherm(q, self.telescope.X, T, lam, dlam,
                          diam_collect, self.telescope.Tsys,
                          self.telescope.emissivity)
        else:
            cth = np.zeros_like(cs)

        # Additional background from sky for ground-based observations
        if self.GROUND:

            if self.GROUND == "ESO":
                # Use ESO SKCALC
                wl_sky, Isky = get_sky_flux()
                # Convolve to instrument resolution
                Itherm = convolution_function(Isky, wl_sky, lam, dlam=dlam)
            else:
                # Get SMART computed surface intensity due to sky background
                Itherm  = get_thermal_ground_intensity(lam, dlam, convolution_function)

            # Compute Earth thermal photon count rate
            cthe = ctherm_earth(q, self.telescope.X, T, lam, dlam,
                                diam_collect, Itherm)

            # Add earth thermal photon counts to telescope thermal counts
            cth = cth + cthe

        # Calculate background photon count rate
        cback = cz + cez + cth + cD + cR

        # Save count rates as attributes
        self.cp = cp
        self.cs = cs
        self.cback = cback
        self.cz = cz
        self.cez = cez
        self.cth = cth
        self.cD = cD
        self.cR = cR

        # Flip the switch
        self._computed = True

        ########## Calculate SNR-like Quantities ##########

        # Count PLANET photons per eclipse
        Nplan = self.tdur * 1 * cp

        # Count STELLAR photons per eclipse
        Nstar = self.tdur * 1 * cs

        # Count BACKGROUND photons per eclipse
        Nback = self.tdur * 1 * cback

        # Calculate SNR on missing planet photons in one eclipse
        #   This formula assumes a homogeneous planet disk (i.e. no limb darkening / hot-spots),
        #   and comes from standard error propigation on the missing photons due to the
        #   star occulting the planet calculation in terms of observables
        SNR1 = Nplan / np.sqrt( (1+1./self.nout)*Nstar + 1./self.nout*Nplan+(1+1./self.nout)*Nback)

        # Calculate SNR on missing planet photons in ntran eclipses
        SNRn =  np.sqrt(self.ntran) * SNR1

        # Calculate the SECONDS required to observe a given SNR as a function of the spectral res
        tSNR = self.wantsnr**2 * ( (1+1./self.nout)*cs + 1./self.nout*cp+(1+1./self.nout)*cback ) / cp**2

        # Calculate the NUMBER OF ECLIPSES required to observe a given SNR as a function of the spectral res
        nSNR = self.wantsnr**2 * ( (1+1./self.nout) * self.tdur * cs + 1./self.nout * self.tdur * cp + (1+1./self.nout)*self.tdur*cback ) / (self.tdur * cp)**2

        # Save SNR quantities as attributes
        self.SNR1 = SNR1
        self.SNRn = SNRn
        self.tSNR = tSNR
        self.nSNR = nSNR

        # Save additional stuff
        self.lam = lam
        self.dlam = dlam
        self.FpFslr = FpFslr
        self.FpFshr = FpFs

        # Create fake data
        self.make_fake_data()

        return

    def make_fake_data(self):
        """
        Make a fake dataset by sampling from a Gaussian.

        Attributes
        ----------
        SNRn : array
            S/N in ``ntran`` eclipses
        obs : array
            Observed emission specrum with noise
        sig : array
            Observed uncertainties on emission spectrum
        """

        # Ensure that simulation has been run
        assert self._computed

        # Calculate SNR on missing planet photons in ntran eclipses
        self.SNRn =  np.sqrt(self.ntran) * self.SNR1

        # Generate synthetic observations
        self.sig = self.FpFslr / self.SNRn
        self.obs = random_draw(self.FpFslr, self.sig)

    def recalc_wantsnr(self, wantsnr = None):
        """
        Recalculate the time and number of eclipses required to achieve a
        user specified SNR via `wantsnr`.

        Attributes
        ----------
        tSNR : array
            Exposure time to ``wantsnr`` [s]
        nSNR : array
            Number of eclipses to ``wantsnr``
        """

        assert self._computed

        if wantsnr is not None:
            self.wantsnr = wantsnr

        # Calculate the SECONDS required to observe a given SNR as a function of the spectral res
        self.tSNR = self.wantsnr**2 * ( (1+1./self.nout)*self.cs \
                                  + 1./self.nout*self.cp+(1+1./self.nout)*self.cback )\
                                  / self.cp**2

        # Calculate the NUMBER OF ECLIPSES required to observe a given SNR as a function of the spectral res
        self.nSNR = self.wantsnr**2 * ( (1+1./self.nout) * self.tdur * self.cs \
                                  + 1./self.nout * self.tdur * self.cp \
                                  + (1+1./self.nout)*self.tdur*self.cback ) \
                                  / (self.tdur * self.cp)**2

        return

    def plot_spectrum(self, SNR_threshold = 0.0, Nsig = None, ax0 = None,
                      err_kws = {"fmt" : ".", "c" : "k", "alpha" : 1},
                      plot_kws = {"lw" : 1.0, "c" : "C4", "alpha" : 0.5},
                      draw_box = True):
        """
        Plot noised emission spectrum.

        Parameters
        ----------
        SNR_threshold : float
            Threshold SNR below which do not plot
        Nsig : float
            Number of standard deviations about median observed points to set
            yaxis limits
        ax0 : `matplotlib.axes`
            Optional axis to provide
        err_kws : dic
            Keyword arguments for `errorbar`
        plot_kws : dic
            Keyword arguments for `plot`
        draw_box : bool
            Draw important quantities in a box?

        Returns
        -------
        fig : `matplotlib.figure.Figure`
            Returns a figure if `ax0` is `None`
        ax : `matplotlib.axes`
            Returns an axis if `ax0` is `None`

        Note
        ----
        Only returns `fig` and `ax` is ``ax0 is None``
        """

        m = [self.SNRn > SNR_threshold]

        scale = 1e6

        if ax0 is None:
            # Create Plot
            fig, ax = plt.subplots(figsize = (10,8))
            ax.set_xlabel(r"Wavelength [$\mu$m]")
            ax.set_ylabel(r"Eclipse Depth $(F_p / F_{\star})$ [ppm]")
        else:
            ax = ax0

        #ax.plot(lam, scale*RpRs2, alpha = 1.0, ls = "steps-mid")
        ax.errorbar(self.lam[m], scale*self.obs[m], yerr=scale*self.sig[m], zorder = 100, **err_kws)
        #ax.set_yscale("log")

        # Set ylim
        if Nsig is not None:
            mederr = scale*np.median(self.sig)
            medy = scale*np.median(self.obs)
            ax.set_ylim([medy - Nsig*mederr, medy + Nsig*mederr])

        ylims = ax.get_ylim()
        xlims = ax.get_xlim()

        ax.plot(self.lamhr, scale*self.FpFshr, **plot_kws)

        ax.set_ylim(ylims)
        ax.set_xlim(xlims)

        if draw_box:
            text = "%i eclipses \n %i m \n %i\%% throughput" %(self.ntran, self.telescope.diameter, 100*self.telescope.throughput)
            ax.text(0.02, 0.975, text, transform=ax.transAxes, ha = "left", va = "top",
                    bbox=dict(boxstyle="square", fc="w", ec="k", alpha=0.9), zorder=101)

        #ax.legend()

        if ax0 is None:
            return fig, ax
        else:
            return

    def plot_SNRn(self, ax0 = None, plot_kws = {"ls" : "steps-mid"}):
        """
        Plot the S/N on the Eclipse Depth as a function of wavelength.

        Parameters
        ----------
        ax0 : `matplotlib.axes`
            Optional axis to provide
        plot_kws : dic
            Keyword arguments for `plot`

        Returns
        -------
        fig : `matplotlib.figure.Figure`
            Returns a figure if `ax0` is `None`
        ax : `matplotlib.axes`
            Returns an axis if `ax0` is `None`

        Note
        ----
        Only returns `fig` and `ax` is ``ax0 is None``
        """
        if ax0 is None:
            # Create Plot
            fig, ax = plt.subplots(figsize = (10,8))
        else:
            ax = ax0

        ax.plot(self.lam, self.SNRn, **plot_kws)
        #ax.set_yscale("log")
        ax.set_xlabel(r"Wavelength [$\mu$m]")
        ax.set_ylabel("S/N on Eclipse Depth")
        #ax.legend()

        if ax0 is None:
            return fig, ax
        else:
            return

    def plot_ntran_to_wantsnr(self, ax0 = None,
                              plot_kws = {"ls" : "steps-mid", "alpha" : 1.0}):
        """
        Plot the number of eclipses to get a SNR on the eclipse depth as
        a function of wavelength.

        Parameters
        ----------
        ax0 : `matplotlib.axes`
            Optional axis to provide
        plot_kws : dic
            Keyword arguments for `plot`

        Returns
        -------
        fig : `matplotlib.figure.Figure`
            Returns a figure if `ax0` is `None`
        ax : `matplotlib.axes`
            Returns an axis if `ax0` is `None`

        Note
        ----
        Only returns `fig` and `ax` is ``ax0 is None``
        """

        if ax0 is None:
            # Create Plot
            fig, ax = plt.subplots(figsize = (10,8))
            ax.set_xlabel(r"Wavelength [$\mu$m]")
            ax.set_ylabel("Eclipses to S/N = %i on Eclipse Depth" %self.wantsnr)
            ax.set_yscale("log")
        else:
            ax = ax0

        ax.plot(self.lam, self.nSNR, **plot_kws)

        if ax0 is None:
            return fig, ax
        else:
            return

    def plot_time_to_wantsnr(self, ax0 = None, plot_kws = {"ls" : "steps-mid", "alpha" : 1.0}):
        """
        Plot the time to get a SNR on the eclipse depth as
        a function of wavelength.

        Parameters
        ----------
        ax0 : `matplotlib.axes`
            Optional axis to provide
        plot_kws : dic
            Keyword arguments for `plot`

        Returns
        -------
        fig : `matplotlib.figure.Figure`
            Returns a figure if `ax0` is `None`
        ax : `matplotlib.axes`
            Returns an axis if `ax0` is `None`

        Note
        ----
        Only returns `fig` and `ax` is ``ax0 is None``
        """

        if ax0 is None:
            # Create Plot
            fig, ax = plt.subplots(figsize = (10,8))
            ax.set_xlabel(r"Wavelength [$\mu$m]")
            ax.set_ylabel("Time to S/N = %i on Eclipse Depth [s]" %self.wantsnr)
            ax.set_yscale("log")
        else:
            ax = ax0

        ax.plot(self.lam, self.tSNR, **plot_kws)

        if ax0 is None:
            return fig, ax
        else:
            return

    def plot_count_rates(self, ax0 = None):
        """
        Plot the photon count rate for all sources.

        Parameters
        ----------
        ax0 : `matplotlib.axes`
            Optional axis to provide

        Returns
        -------
        fig : `matplotlib.figure.Figure`
            Returns a figure if `ax0` is `None`
        ax : `matplotlib.axes`
            Returns an axis if `ax0` is `None`

        Note
        ----
        Only returns `fig` and `ax` is ``ax0 is None``
        """

        if ax0 is None:
            # Create Plot
            fig, ax = plt.subplots(figsize = (10,8))
            ax.set_xlabel(r"Wavelength [$\mu$m]")
            ax.set_ylabel("Photons / s")
            ax.set_yscale("log")
        else:
            ax = ax0

        ax.plot(self.lam, self.cp, label = "Planet", ls = "dashed")
        ax.plot(self.lam, self.cs, label = "Star")
        ax.plot(self.lam, self.cback, label = "Total Bkg")
        ax.plot(self.lam, self.cz, label = "SS Zodi")
        ax.plot(self.lam, self.cez, label = "Exo-Zodi")
        ax.plot(self.lam, self.cth, label = "Thermal Bkg")
        ax.plot(self.lam, self.cD, label = "Dark")
        ax.plot(self.lam, self.cR, label = "Read")

        if ax0 is None:
            leg = ax.legend(fontsize = 14, ncol = 2)
            return fig, ax
        else:
            return

class TransitNoise(object):
    """
    Simulate exoplanet transit transmission spectroscopy with a next-generation
    telescope.

    Parameters
    ----------
    telescope : Telescope
        Initialized object containing ``Telescope`` parameters
    planet : Planet
        Initialized object containing ``Planet`` parameters
    star : Star
        Initialized object containing ``Star`` parameters
    tdur : float
        Transit duration [s]
    ntran : float
        Number of transits
    nout : float
        Number of out-of-transit transit durations to observe
    wantsnr : float, optional
        Desired signal-to-noise ratio in each pixel
    FIX_OWA : bool, optional
        Set to fix OWA at ``OWA*lammin/D``, as would occur if lenslet array is
        limiting the OWA
    COMPUTE_LAM : bool, optional
        Set to compute lo-res wavelength grid, otherwise the grid input as
        variable ``lam`` is used
    SILENT : bool, optional
        Set to suppress print statements
    NIR : bool, optional
        Re-adjusts pixel size in NIR, as would occur if a second instrument
        was designed to handle the NIR
    THERMAL : bool, optional
        Set to compute thermal photon counts due to telescope temperature
    GROUND : bool, optional
        Set to simulate ground-based observations through atmosphere
    vod : bool, optional
        "Valley of Death" red QE parameterization from Robinson et al. (2016)

    """
    def __init__(self, tdur    = 3432.,  # TRAPPIST-1e
                       telescope = Telescope(),
                       planet = Planet(),
                       star = Star(),
                       ntran   = 1,
                       nout    = 1,
                       wantsnr = 1000.0,
                       NIR     = True,
                       THERMAL = True,
                       GROUND  = False,
                       vod     = False,
                       IMAGE   = False):
        self.telescope = telescope
        self.planet    = planet
        self.star      = star
        self.tdur      = tdur
        self.ntran     = ntran
        self.nout      = nout
        self.wantsnr   = wantsnr
        self.NIR       = NIR
        self.THERMAL   = THERMAL
        self.GROUND    = GROUND
        self.vod       = vod
        self.IMAGE     = IMAGE

        self._computed = False

        return

    def run_count_rates(self, lamhr = None, tdhr = None, Fshr = None):
        """
        Calculate the photon count rates and signal to noise on a
        transmission spectrum observation

        Parameters
        ----------
        lamhr : numpy.ndarray
            Wavelength [$\mu$m]
        tdhr : numpy.ndarray
            Transit Depth $(Rp/Rs)^2$
        Fshr : numpy.ndarray
            Flux density incident at the planet's TOA [W/m$^2$/$\mu$]

        Calling ``run_count_rates()`` creates the following attributes for
        the ``TransitNoise`` instance:

        Attributes
        ----------
        lamhr : array
            Wavelength [$\mu$m]
        tdhr : array
            Transit Depth $(Rp/Rs)^2$
        Fshr : array
            Flux density incident at the planet's TOA [W/m$^2$/$\mu$]
        cs : array
            Stellar photon count rate [photons/s]
        cback : array
            Background photon count rate [photons/s]
        cz : array
            Zodi photon count rate [photons/s]
        cez : array
            Exo-zodi photon count rate [photons/s]
        cth : array
            Thermal photon count rate [photons/s]
        cD : array
            Dark current photon count rate [photons/s]
        cR : array
            Read noise photon count rate [photons/s]
        cmiss : array
            Occulted stellar photon count rate [photons/s]
        SNR1 : array
            S/N for one transit
        SNRn : array
            S/N for ``ntran`` transits
        tSNR : array
            Exposure time to ``wantsnr`` [s]
        nSNR : array
            Number of transits to ``wantsnr``
        lam : array
            Observed wavelength grid [$\mu$m]
        dlam : array
            Observed wavelength grid widths [$\mu$m]
        RpRs2 : array
            Low-res transit depth

        """

        self.lamhr = lamhr
        self.tdhr = tdhr
        self.Fshr = Fshr

        if self.telescope.A_collect is None:
            diam_collect = self.telescope.diameter
        else:
            diam_collect = 2. * (self.telescope.A_collect / np.pi)**0.5

        # Set the convolution function
        convolution_function = downbin_spec

        # Does the telescope object already have a wavelength grid?
        if (self.telescope.lam is None) or (self.telescope.dlam is None):
            # Create wavelength grid
            lam, dlam = construct_lam(self.telescope.lammin,
                                      self.telescope.lammax,
                                      self.telescope.resolution)
        else:
            # Use existing grids
            lam = self.telescope.lam
            dlam = self.telescope.dlam

        # Set Quantum Efficiency
        q = set_quantum_efficiency(lam,
                                   self.telescope.qe,
                                   NIR=self.NIR,
                                   vod=self.vod)

        # Set Dark current and Read noise
        De = set_dark_current(lam,
                              self.telescope.darkcurrent,
                              self.telescope.lammax,
                              self.telescope.Tdet,
                              NIR=self.NIR)
        Re = set_read_noise(lam,
                            self.telescope.readnoise,
                            NIR=self.NIR)

        # Set Angular size of lenslet
        theta = set_lenslet(lam,
                            self.telescope.lammin,
                            diam_collect,
                            self.telescope.X,
                            NIR=self.NIR)

        # Set throughput
        #sep  = r/d*np.sin(alpha*np.pi/180.)*np.pi/180./3600. # separation in radians
        #T = set_throughput(lam, Tput, diam, sep, IWA, OWA, lammin, FIX_OWA=FIX_OWA, SILENT=SILENT)
        T = self.telescope.throughput * np.ones_like(lam)

        # Apply wavelength-dependent throuput, if needed
        if self.telescope.Tput_lam is not None:
            # Bin input throughput curve to native res
            Tlam = np.interp(lam, self.telescope.Tput_lam[0], self.telescope.Tput_lam[1])
            # Multiply into regular throughput
            T = T * Tlam

        # Apply wavelength-dependent quantum efficiency, if needed
        if self.telescope.qe_lam is not None:
            # Bin input QE curve to native res
            qlam = np.interp(lam, self.telescope.qe_lam[0], self.telescope.qe_lam[1])
            # Multiply into regular QE
            q = q * qlam

        # Modify throughput by atmospheric transmission if GROUND-based
        if self.GROUND:
            # Use SMART calc
            Tatmos = set_atmos_throughput(lam, dlam, convolution_function)
            # Multiply telescope throughput by atmospheric throughput
            T = T * Tatmos

        # Degrade transit and stellar spectrum
        RpRs2 = convolution_function(tdhr,lamhr,lam,dlam=dlam)

        # Calculate intensity of the star [W/m^2/um/sr]
        if Fshr is None:
            # Using a blackbody
            Bstar = planck(self.star.Teff, lam)
        else:
            # Using provided TOA stellar flux
            Fslr = convolution_function(Fshr, lamhr, lam, dlam=dlam)
            Bstar = Fslr / ( np.pi*(self.star.Rs*u.Rsun.in_units(u.km)/\
                           (self.planet.a*u.AU.in_units(u.km)))**2. )

        # Solid angle in steradians
        omega_star = np.pi*(self.star.Rs*u.Rsun.in_units(u.km)/\
                           (self.planet.distance*u.pc.in_units(u.km)))**2.
        omega_planet = np.pi*(self.planet.Rp*u.Rearth.in_units(u.km)/\
                             (self.planet.distance*u.pc.in_units(u.km)))**2.

        # Fluxes at earth [W/m^2/um]
        Fs = Bstar * omega_star
        #Fback = jwst_background(lam)
        Fstar_miss = Fs * RpRs2

        # Fraction of planetary signal in Airy pattern
        fpa = 1.0   # No fringe pattern here --> all of stellar psf falls on CCD

        ########## Calculate Photon Count Rates ##########

        # Stellar photon count rate
        cs = cstar(q, fpa, T, lam, dlam, Fs, diam_collect)

        # Missing photon count rate (is this a thing? it is now!)
        cmiss = Fstar_miss*dlam*(lam*1e-6)/(h*c)*T*(np.pi * (0.5*diam_collect)**2)

        # Solar System Zodi count rate
        cz =  czodi(q, self.telescope.X, T, lam, dlam,
                    diam_collect, self.planet.MzV)

        # Exo-Zodi count rate
        cez =  cezodi(q, self.telescope.X, T, lam, dlam, diam_collect,
                      self.planet.a,
                      Fstar(lam, self.star.Teff, self.star.Rs, 1., AU=True),
                      self.planet.Nez, self.planet.MezV)

        # Dark current count rate
        cD =  cdark(De, self.telescope.X, lam,
                    diam_collect, theta,
                    self.telescope.DNHpix, IMAGE=self.IMAGE)

        # Read noise count rate
        cR =  cread(Re, self.telescope.X, lam, diam_collect,
                    theta, self.telescope.DNHpix, self.telescope.Dtmax,
                    IMAGE=self.IMAGE)

        # Thermal background count rate
        if self.THERMAL:
            # telescope internal thermal count rate
            cth =  ctherm(q, self.telescope.X, T, lam, dlam,
                          diam_collect, self.telescope.Tsys,
                          self.telescope.emissivity)
        else:
            cth = np.zeros_like(cs)

        # Additional background from sky for ground-based observations
        if self.GROUND:

            if self.GROUND == "ESO":
                # Use ESO SKCALC
                wl_sky, Isky = get_sky_flux()
                # Convolve to instrument resolution
                Itherm = convolution_function(Isky, wl_sky, lam, dlam=dlam)
            else:
                # Get SMART computed surface intensity due to sky background
                Itherm  = get_thermal_ground_intensity(lam, dlam, convolution_function)

            # Compute Earth thermal photon count rate
            cthe = ctherm_earth(q, self.telescope.X, T, lam, dlam,
                                diam_collect, Itherm)

            # Add earth thermal photon counts to telescope thermal counts
            cth = cth + cthe

        # Calculate background photon count rate
        cback = cz + cez + cth + cD + cR

        # Save count rates as attributes
        self.cs = cs
        self.cback = cback
        self.cz = cz
        self.cez = cez
        self.cth = cth
        self.cD = cD
        self.cR = cR
        self.cmiss = cmiss

        # Flip the switch
        self._computed = True

        ########## Calculate SNR-like Quantities ##########

        # Count STELLAR photons per transit
        Nstar = self.tdur * 1 * cs

        # Count BACKGROUND photons per transit
        Nback = self.tdur * 1 * cback

        # Calculate SNR on missing stellar photons in one transit
        #   This formula assumes a homogeneous stellar disk (i.e. no limb darkening),
        #   and comes from standard error propigation on the missing photons due to the
        #   planet occulting the star calculation in terms of observables
        SNR1 = (Nstar * RpRs2) / np.sqrt((1 + 1./self.nout - RpRs2) * Nstar + (1 + 1./self.nout) * Nback)

        # Calculate SNR on missing stellar photons in ntran transits
        SNRn =  np.sqrt(self.ntran) * SNR1

        # Calculate the SECONDS required to observe a given SNR as a function of the spectral res
        tSNR = self.wantsnr**2 * ((1 + 1./self.nout - RpRs2) * cs + (1 + 1./self.nout) * cback) / (cs * RpRs2)**2

        # Calculate the NUMBER OF TRANSITS required to observe a given SNR as a function of the spectral res
        nSNR = self.wantsnr**2 * ((1 + 1./self.nout - RpRs2) *  self.tdur * cs + (1 + 1./self.nout) * self.tdur * cback) / (self.tdur * cs * RpRs2)**2

        # Save SNR quantities as attributes
        self.SNR1 = SNR1
        self.SNRn = SNRn
        self.tSNR = tSNR
        self.nSNR = nSNR

        # Save additional stuff
        self.lam = lam
        self.dlam = dlam
        self.RpRs2 = RpRs2

        # Create fake data
        self.make_fake_data()

        return

    def make_fake_data(self):
        """
        Make a fake dataset by sampling from a Gaussian.

        Attributes
        ----------
        SNRn : array
            S/N in ``ntran`` transits
        obs : array
            Observed transit depth with noise
        sig : array
            Observed uncertainties on transit depth
        """

        # Ensure that simulation has been run
        assert self._computed

        # Calculate SNR on missing stellar photons in ntran transits
        self.SNRn =  np.sqrt(self.ntran) * self.SNR1

        # Generate synthetic observations
        self.sig = self.RpRs2 / self.SNRn
        self.obs = random_draw(self.RpRs2, self.sig)

    def recalc_wantsnr(self, wantsnr = None):
        """
        Recalculate the time and number of transits required to achieve a
        user specified SNR via `wantsnr`.

        Attributes
        ----------
        tSNR : array
            Exposure time to ``wantsnr`` [s]
        nSNR : array
            Number of transits to ``wantsnr``
        """

        assert self._computed

        if wantsnr is not None:
            self.wantsnr = wantsnr

        # Calculate the SECONDS required to observe a given SNR as a function of the spectral res
        self.tSNR = self.wantsnr**2 * ((1 + 1./self.nout - self.RpRs2) \
                                       * self.cs + (1 + 1./self.nout) \
                                       * self.cback) / (self.cs * self.RpRs2)**2

        # Calculate the NUMBER OF TRANSITS required to observe a given SNR as a function of the spectral res
        self.nSNR = self.wantsnr**2 * ((1 + 1./self.nout - self.RpRs2) \
                                       *  self.tdur * self.cs + (1 + 1./self.nout) \
                                       * self.tdur * self.cback) / (self.tdur * self.cs * self.RpRs2)**2

        return

    def plot_spectrum(self, SNR_threshold = 1.0, Nsig = 6.0, ax0 = None,
                      err_kws = {"fmt" : ".", "c" : "k", "alpha" : 1},
                      plot_kws = {"lw" : 1.0, "c" : "C4", "alpha" : 0.5},
                      draw_box = True):
        """
        Plot noised transmission spectrum.

        Parameters
        ----------
        SNR_threshold : float
            Threshold SNR below which do not plot
        Nsig : float
            Number of standard deviations about median observed points to set
            yaxis limits
        ax0 : `matplotlib.axes`
            Optional axis to provide
        err_kws : dic
            Keyword arguments for `errorbar`
        plot_kws : dic
            Keyword arguments for `plot`
        draw_box : bool
            Draw important quantities in a box?

        Returns
        -------
        fig : `matplotlib.figure.Figure`
            Returns a figure if `ax0` is `None`
        ax : `matplotlib.axes`
            Returns an axis if `ax0` is `None`

        Note
        ----
        Only returns `fig` and `ax` is ``ax0 is None``
        """

        m = [self.SNRn > SNR_threshold]

        scale = 1e6

        if ax0 is None:
            # Create Plot
            fig, ax = plt.subplots(figsize = (10,8))
            ax.set_xlabel(r"Wavelength [$\mu$m]")
            ax.set_ylabel("Transit Depth $(R_p / R_{\star})^2$ [ppm]")
        else:
            ax = ax0

        #ax.plot(lam, scale*RpRs2, alpha = 1.0, ls = "steps-mid")
        ax.errorbar(self.lam[m], scale*self.obs[m], yerr=scale*self.sig[m], zorder = 100, **err_kws)
        #ax.set_yscale("log")

        # Set ylim
        mederr = scale*np.median(self.sig)
        medy = scale*np.median(self.obs)
        ax.set_ylim([medy - Nsig*mederr, medy + Nsig*mederr])

        ylims = ax.get_ylim()
        xlims = ax.get_xlim()

        ax.plot(self.lamhr, scale*self.tdhr, **plot_kws)

        ax.set_ylim(ylims)
        ax.set_xlim(xlims)

        if draw_box:
            text = "%i transits \n %i m \n %i\%% throughput" %(self.ntran, self.telescope.diameter, 100*self.telescope.throughput)
            ax.text(0.02, 0.975, text, transform=ax.transAxes, ha = "left", va = "top",
                    bbox=dict(boxstyle="square", fc="w", ec="k", alpha=0.9), zorder=101)

        #ax.legend()

        if ax0 is None:
            return fig, ax
        else:
            return

    def plot_SNRn(self, ax0 = None, plot_kws = {"ls" : "steps-mid"}):
        """
        Plot the S/N on the Transit Depth as a function of wavelength.

        Parameters
        ----------
        ax0 : `matplotlib.axes`
            Optional axis to provide
        plot_kws : dic
            Keyword arguments for `plot`

        Returns
        -------
        fig : `matplotlib.figure.Figure`
            Returns a figure if `ax0` is `None`
        ax : `matplotlib.axes`
            Returns an axis if `ax0` is `None`

        Note
        ----
        Only returns `fig` and `ax` is ``ax0 is None``
        """
        if ax0 is None:
            # Create Plot
            fig, ax = plt.subplots(figsize = (10,8))
        else:
            ax = ax0

        ax.plot(self.lam, self.SNRn, **plot_kws)
        #ax.set_yscale("log")
        ax.set_xlabel(r"Wavelength [$\mu$m]")
        ax.set_ylabel("S/N on Transit Depth")
        #ax.legend()

        if ax0 is None:
            return fig, ax
        else:
            return

    def plot_ntran_to_wantsnr(self, ax0 = None,
                              plot_kws = {"ls" : "steps-mid", "alpha" : 1.0}):
        """
        Plot the number of transits to get a SNR on the transit depth as
        a function of wavelength.

        Parameters
        ----------
        ax0 : `matplotlib.axes`
            Optional axis to provide
        plot_kws : dic
            Keyword arguments for `plot`

        Returns
        -------
        fig : `matplotlib.figure.Figure`
            Returns a figure if `ax0` is `None`
        ax : `matplotlib.axes`
            Returns an axis if `ax0` is `None`

        Note
        ----
        Only returns `fig` and `ax` is ``ax0 is None``
        """

        if ax0 is None:
            # Create Plot
            fig, ax = plt.subplots(figsize = (10,8))
            ax.set_xlabel(r"Wavelength [$\mu$m]")
            ax.set_ylabel("Transits to S/N = %i on Transit Depth" %self.wantsnr)
            ax.set_yscale("log")
        else:
            ax = ax0

        ax.plot(self.lam, self.nSNR, **plot_kws)

        if ax0 is None:
            return fig, ax
        else:
            return

    def plot_time_to_wantsnr(self, ax0 = None, plot_kws = {"ls" : "steps-mid", "alpha" : 1.0}):
        """
        Plot the time to get a SNR on the transit depth as
        a function of wavelength.

        Parameters
        ----------
        ax0 : `matplotlib.axes`
            Optional axis to provide
        plot_kws : dic
            Keyword arguments for `plot`

        Returns
        -------
        fig : `matplotlib.figure.Figure`
            Returns a figure if `ax0` is `None`
        ax : `matplotlib.axes`
            Returns an axis if `ax0` is `None`

        Note
        ----
        Only returns `fig` and `ax` is ``ax0 is None``
        """

        if ax0 is None:
            # Create Plot
            fig, ax = plt.subplots(figsize = (10,8))
            ax.set_xlabel(r"Wavelength [$\mu$m]")
            ax.set_ylabel("Time to S/N = %i on Transit Depth [s]" %self.wantsnr)
            ax.set_yscale("log")
        else:
            ax = ax0

        ax.plot(self.lam, self.tSNR, **plot_kws)

        if ax0 is None:
            return fig, ax
        else:
            return

    def plot_count_rates(self, ax0 = None):
        """
        Plot the photon count rate for all sources.

        Parameters
        ----------
        ax0 : `matplotlib.axes`
            Optional axis to provide

        Returns
        -------
        fig : `matplotlib.figure.Figure`
            Returns a figure if `ax0` is `None`
        ax : `matplotlib.axes`
            Returns an axis if `ax0` is `None`

        Note
        ----
        Only returns `fig` and `ax` is ``ax0 is None``
        """

        if ax0 is None:
            # Create Plot
            fig, ax = plt.subplots(figsize = (10,8))
            ax.set_xlabel(r"Wavelength [$\mu$m]")
            ax.set_ylabel("Photons / s")
            ax.set_yscale("log")
        else:
            ax = ax0

        ax.plot(self.lam, self.cmiss, label = "Occulted", ls = "dashed")
        ax.plot(self.lam, self.cs, label = "Star")
        ax.plot(self.lam, self.cback, label = "Total Bkg")
        ax.plot(self.lam, self.cz, label = "SS Zodi")
        ax.plot(self.lam, self.cez, label = "Exo-Zodi")
        ax.plot(self.lam, self.cth, label = "Thermal Bkg")
        ax.plot(self.lam, self.cD, label = "Dark")
        ax.plot(self.lam, self.cR, label = "Read")

        if ax0 is None:
            leg = ax.legend(fontsize = 14, ncol = 2)
            return fig, ax
        else:
            return

def get_earth_trans_spectrum():
    '''
    Get the transmission spectrum of the Earth around the Sun.

    Returns
    -------
    lam : `numpy.ndarray`
        Wavelength grid [um]
    tdepth : `numpy.ndarray`
        Transit depth (Rp/Rs)^2
    fplan : `numpy.ndarray`
        TOA planet flux [W/m^2/um]
    fstar : `numpy.ndarray`
        Stellar flux at planet [W/m^2/um]
    '''

    # Read in transit data
    here = os.path.join(os.path.dirname(__file__))
    plus = "planets/earth_avg_hitran2012_300_100000cm.trnst"
    data = np.loadtxt(os.path.join(here, plus))

    # Parse
    lam = data[:,0]
    tdepth = data[:,3]

    # Read in flux data
    plus = "planets/earth_avg_hitran2012_300_100000cm_toa.rad"
    data = np.loadtxt(os.path.join(here, plus))

    # Parse
    fplan = data[:,3]
    fstar = data[:,2]

    return lam, tdepth, fplan, fstar


# Licensed under a 3-clause BSD style license - see LICENSE.rst
# -*- coding: utf-8 -*-
"""
fiberassign.hardware
=======================

Functions for loading information about the telescope hardware.

"""
from __future__ import absolute_import, division, print_function

from datetime import datetime, timezone

import numpy as np

from scipy.interpolate import interp1d

import desimodel.io as dmio

from .utils import Logger

from ._internal import (
    Hardware,
    FIBER_STATE_OK,
    FIBER_STATE_UNASSIGNED,
    FIBER_STATE_STUCK,
    FIBER_STATE_BROKEN,
    FIBER_STATE_RESTRICT,
    Circle,
    Segments,
    Shape,
)

def expand_closed_curve(xx, yy, margin):
    '''
    For the --margin-{pos,petal,gfa} options, we can add a buffer zone
    around the positioner keep-out polygons.  This function implements
    the geometry to achieve this.

    Given a RIGHT-HANDED closed polygon xx,yy and margin, returns new
    x,y coordinates expanded by a margin of `margin`.

    (By right-handed, I mean that the points are listed
    counter-clockwise, and if you walk the boundary, the inside of the
    shape is to the left; the expanded points will be to the right.)

    If the order of the polygon is reversed, the "expanded" points
    will actually be on the *inside* of the polygon.  Setting the
    margin negative counteracts this.

    Note that we strictly require a closed curve.  Collinear polygon
    segments will cause problems!

    '''
    ex, ey = [],[]

    # These are closed curves (last point = first point)
    # (this isn't strictly required by the fundamental algorithm, but is assumed
    # in the way we select previous and next points in the loop below.)
    if (xx[0] != xx[-1]) or (yy[0] != yy[-1]):
        log = Logger.get()
        log.warning('Expected exclusion polygons to be closed curves; got x, y = %s, %s' % (str(xx), str(yy)))
        return xx, yy
    assert(xx[0] == xx[-1])
    assert(yy[0] == yy[-1])

    N = len(xx)

    for j in range(N):
        # We go through the points, and for each point we consider the previous
        # and next point.  The "expanded" point will be defined according to the
        # two edges (vector) coming from the point.
        i = j - 1
        if i == -1:
            # wrap around, skipping repeated point
            i = N-2
        k = j + 1
        if k == N:
            k = 1

        x1 = xx[i]
        y1 = yy[i]
        x2 = xx[j]
        y2 = yy[j]
        x3 = xx[k]
        y3 = yy[k]

        # Vectors to and from the central point.
        vx1 = x2 - x1
        vy1 = y2 - y1
        vx2 = x3 - x2
        vy2 = y3 - y2
        # We can't handle repeated points!
        assert(not(vx2 == 0. and vy2 == 0.))

        # Get the angle between the vectors -- our expanded point is going to
        # be halfway between these two vectors.
        cross1 = vx1 * vy2 - vx2 * vy1
        vv1 = np.hypot(vx1,vy1)
        vv2 = np.hypot(vx2,vy2)
        theta = np.arcsin(np.clip(cross1 / (vv1 * vv2), -1., 1.))
        # Detect sharp (>90 degree) turns
        dot = vx1*vx2 + vy1*vy2
        # the angle of the expanded point is relative to vector 1
        a = np.arctan2(vy1, vx1)
        if dot < 0:
            # sharp turn -- the theta=arcsin aliases the angle, which is outside
            # the range of [-pi/2, +pi/2]; adjust theta to the aliased angle.
            if theta > 0:
                theta =  np.pi - theta
            else:
                theta = -np.pi - theta
        da = np.pi/2. + theta/2.
        # This places the point further from the original keeps the vectors parallel to their originals
        stretch = 1./np.cos(theta/2.)
        dx = -margin * stretch * np.cos(a + da)
        dy = -margin * stretch * np.sin(a + da)

        ex.append(x2 + dx)
        ey.append(y2 + dy)

    return ex, ey

def load_hardware(focalplane=None, rundate=None,
                  add_margins={}):
    """Create a hardware class representing properties of the telescope.

    Args:
        focalplane (tuple):  Override the focalplane model.  If not None, this
            should be a tuple of the same data types returned by
            desimodel.io.load_focalplane()
        rundate (str):  ISO 8601 format time stamp as a string in the
            format YYYY-MM-DDTHH:MM:SS+-zz:zz.  If None, uses current time.

    Returns:
        (Hardware):  The hardware object.

    """
    log = Logger.get()

    # The timestamp for this run.
    runtime = None
    if rundate is None:
        runtime = datetime.now(tz=timezone.utc)
    else:
        try:
            runtime = datetime.strptime(rundate, "%Y-%m-%dT%H:%M:%S%z")
        except ValueError:
            runtime = datetime.strptime(rundate, "%Y-%m-%dT%H:%M:%S")
            msg = "Requested run date '{}' is not timezone-aware.  Assuming UTC.".format(runtime)
            log.warning(msg)
            runtime = runtime.replace(tzinfo=timezone.utc)
    runtimestr = None
    try:
        runtimestr = runtime.isoformat(timespec="seconds")
    except TypeError:
        runtimestr = runtime.isoformat()

    # Get the focalplane information
    fp = None
    exclude = None
    state = None
    create_time = "UNKNOWN"
    if focalplane is None:
        fp, exclude, state, create_time = dmio.load_focalplane(runtime)
    else:
        fp, exclude, state = focalplane

    # Get the plate scale
    platescale = dmio.load_platescale()

    # We are going to do a quadratic interpolation to the platescale on a fine grid,
    # and then use that for *linear* interpolation inside the compiled code.  The
    # default platescale data is on a one mm grid spacing.  We also do the same
    # interpolation of the arclength S(R).

    fine_radius = np.linspace(
        platescale["radius"][0], platescale["radius"][-1], num=10000, dtype=np.float64
    )
    fn = interp1d(platescale["radius"], platescale["theta"], kind="quadratic")
    fine_theta = fn(fine_radius).astype(np.float64)
    fn = interp1d(platescale["radius"], platescale["arclength"], kind="quadratic")
    fine_arc = fn(fine_radius).astype(np.float64)

    # We are only going to keep rows for LOCATIONs that are assigned to a
    # science or sky monitor positioner.

    log.info("Loaded focalplane for time stamp {}".format(runtime))

    pos_rows = np.where(fp["DEVICE_TYPE"].astype(str) == "POS")[0]
    etc_rows = np.where(fp["DEVICE_TYPE"].astype(str) == "ETC")[0]
    keep_rows = np.unique(np.concatenate((pos_rows, etc_rows)))

    nloc = len(keep_rows)
    log.debug("  focalplane table keeping {} rows for POS and ETC devices".format(nloc))

    device_type = np.full(nloc, "OOPSBUG", dtype="a8")
    device_type[:] = fp["DEVICE_TYPE"][keep_rows]

    locations = np.copy(fp["LOCATION"][keep_rows])

    # Map location to row in the table

    loc_to_fp = dict()
    for rw, loc in enumerate(fp["LOCATION"]):
        loc_to_fp[loc] = rw

    # FIXME:  Here we assume that the 32bit STATE column has the same bit
    # definitions as what is used by fiberassign (defined in hardware.h):
    # If this is not true, then re-map those values here inside the "state"
    # table loaded above.

    # Map location to row of the state table

    loc_to_state = dict()
    for rw, loc in enumerate(state["LOCATION"]):
        loc_to_state[loc] = rw

    # Slightly reformat the 'add_margins' dict: 'pos' gets copied to
    # 'theta' and 'phi'.
    margins = {}
    if 'pos' in add_margins:
        margins['theta'] = add_margins['pos']
        margins['phi']   = add_margins['pos']
    if 'gfa' in add_margins:
        margins['gfa'] = add_margins['gfa']
    # PLUS -- and this is a HACK --
    # because the 'petal' exclusion polygons are listed in the db dump files
    # in "left-handed" order, unlike the other polygons, we must NEGATE the
    # margin!
    if 'petal' in add_margins:
        margins['petal'] = -add_margins['petal']

    # Convert the exclusion polygons into shapes (as required)
    excl = dict()
    # cache expanded polygons (because many of the polygons are actually duplicates)
    expanded = {}
    def get_exclusions(exclname):
        e = excl.get(exclname)
        if e is not None:
            return e
        shp = exclude[exclname]
        excl[exclname] = dict()
        for obj in shp.keys():
            cr = list()
            for crc in shp[obj]["circles"]:
                cr.append(Circle(crc[0], crc[1]))
            sg = list()
            for sgm in shp[obj]["segments"]:
                if obj in margins:
                    key = (obj, tuple(tuple(xy) for xy in sgm))
                    if key in expanded:
                        sgm = expanded[key]
                    else:
                        sx = [x for x,y in sgm]
                        sy = [y for x,y in sgm]
                        ex,ey = expand_closed_curve(sx, sy, margins[obj])
                        sgm = list(zip(ex, ey))
                        expanded[key] = sgm
                sg.append(Segments(sgm))
            fshp = Shape((0.0, 0.0), cr, sg)
            excl[exclname][obj] = fshp
        return excl[exclname]

    # For each positioner, select the exclusion polynomials.
    positioners = dict()

    for loc in locations:
        exclname = state["EXCLUSION"][loc_to_state[loc]]
        positioners[loc] = dict()
        posexcl = get_exclusions(exclname)
        positioners[loc]["theta"] = Shape(posexcl["theta"])
        positioners[loc]["phi"] = Shape(posexcl["phi"])
        if "gfa" in posexcl:
            positioners[loc]["gfa"] = Shape(posexcl["gfa"])
        else:
            positioners[loc]["gfa"] = Shape()
        if "petal" in posexcl:
            positioners[loc]["petal"] = Shape(posexcl["petal"])
        else:
            positioners[loc]["petal"] = Shape()

    hw = None
    if "MIN_P" in state.colnames:
        # This is a new-format focalplane model (after desimodel PR #143)
        hw = Hardware(
            runtimestr,
            locations,
            fp["PETAL"][keep_rows],
            fp["DEVICE"][keep_rows],
            fp["SLITBLOCK"][keep_rows],
            fp["BLOCKFIBER"][keep_rows],
            fp["FIBER"][keep_rows],
            device_type,
            fp["OFFSET_X"][keep_rows],
            fp["OFFSET_Y"][keep_rows],
            np.array([state["STATE"][loc_to_state[x]] for x in locations]),
            np.array([fp["OFFSET_T"][loc_to_fp[x]] for x in locations]),
            np.array([state["MIN_T"][loc_to_state[x]] for x in locations]),
            np.array([state["MAX_T"][loc_to_state[x]] for x in locations]),
            np.array([state["POS_T"][loc_to_state[x]] for x in locations]),
            np.array([fp["LENGTH_R1"][loc_to_fp[x]] for x in locations]),
            np.array([fp["OFFSET_P"][loc_to_fp[x]] for x in locations]),
            np.array([state["MIN_P"][loc_to_state[x]] for x in locations]),
            np.array([state["MAX_P"][loc_to_state[x]] for x in locations]),
            np.array([state["POS_P"][loc_to_state[x]] for x in locations]),
            np.array([fp["LENGTH_R2"][loc_to_fp[x]] for x in locations]),
            fine_radius,
            fine_theta,
            fine_arc,
            [positioners[x]["theta"] for x in locations],
            [positioners[x]["phi"] for x in locations],
            [positioners[x]["gfa"] for x in locations],
            [positioners[x]["petal"] for x in locations],
            add_margins
        )
    else:
        # This is an old-format focalplane model (prior to desimodel PR #143).  For
        # stuck positioners, we want to specify a default POS_T / POS_P to use.
        # These old models did not include any information about that, so we use the
        # minimum Theta value and either the maximum Phi value or PI, whichever is
        # smaller
        fake_pos_p = np.zeros(len(locations), dtype=np.float64)
        fake_pos_t = np.zeros(len(locations), dtype=np.float64)
        for ilid, lid in enumerate(locations):
            pt = fp["MIN_T"][loc_to_fp[lid]] + fp["OFFSET_T"][loc_to_fp[lid]]
            pp = fp["MAX_P"][loc_to_fp[lid]] + fp["OFFSET_P"][loc_to_fp[lid]]
            if pp > 180.0:
                pp = 180.0
            fake_pos_p[ilid] = pp
            fake_pos_t[ilid] = pt
        hw = Hardware(
            runtimestr,
            locations,
            fp["PETAL"][keep_rows],
            fp["DEVICE"][keep_rows],
            fp["SLITBLOCK"][keep_rows],
            fp["BLOCKFIBER"][keep_rows],
            fp["FIBER"][keep_rows],
            device_type,
            fp["OFFSET_X"][keep_rows],
            fp["OFFSET_Y"][keep_rows],
            np.array([state["STATE"][loc_to_state[x]] for x in locations]),
            np.array([fp["OFFSET_T"][loc_to_fp[x]] for x in locations]),
            np.array([fp["MIN_T"][loc_to_fp[x]] for x in locations]),
            np.array([fp["MAX_T"][loc_to_fp[x]] for x in locations]),
            fake_pos_t,
            np.array([fp["LENGTH_R1"][loc_to_fp[x]] for x in locations]),
            np.array([fp["OFFSET_P"][loc_to_fp[x]] for x in locations]),
            np.array([fp["MIN_P"][loc_to_fp[x]] for x in locations]),
            np.array([fp["MAX_P"][loc_to_fp[x]] for x in locations]),
            fake_pos_p,
            np.array([fp["LENGTH_R2"][loc_to_fp[x]] for x in locations]),
            fine_radius,
            fine_theta,
            fine_arc,
            [positioners[x]["theta"] for x in locations],
            [positioners[x]["phi"] for x in locations],
            [positioners[x]["gfa"] for x in locations],
            [positioners[x]["petal"] for x in locations],
            add_margins
        )
    return hw

def radec2xy(hw, tile_ra, tile_dec, tile_obstime, tile_obstheta, tile_obsha,
             ra, dec, use_cs5, threads=0):
    '''
    For the tile pointed at (tilera, tiledec), project the (ra, dec)
    value into X/Y mm.

    Args:
      hw: Hardware object
      tile_ra (float): Tile RA
      tile_dec (float): Tile Dec
      tile_obstime (string): Tile observation time, YYYY-MM-DDTHH:MM:SS / Astropy "isot" format.
      tile_obstheta (float): Tile "fieldrot" rotation angle.
      tile_obsha (float): Tile designed Hour Angle, in degrees.
      ra (numpy array): RA to project, in degrees
      dec (numpy array): Dec to project, in degrees
      use_CS5 (bool):  If True, return CS5 coordinates, else curved.
      threads=0 (int): currently unused; for backward compatibility.

    Returns:
      x, y: numpy arrays: the (X, Y) projected locations.
    '''
    #xy = hw.radec2xy_multi(
    #    tile_ra, tile_dec, tile_obstheta, ra, dec, use_cs5, threads=0
    #)
    #x = np.array([x for x,y in xy])
    #y = np.array([y for x,y in xy])
    from astropy.time import Time
    from desimeter.fiberassign import fiberassign_radec2xy_cs5, fiberassign_radec2xy_flat
    # Note that MJD is only used for precession, so no need for
    # high precision.
    t = Time(tile_obstime, format='isot')
    mjd = t.mjd

    # Don't pass adc[12]: Let desimeter use its pm-alike routines
    if use_cs5:
        x, y = fiberassign_radec2xy_cs5(ra, dec, tile_ra, tile_dec, mjd,
                                        tile_obsha, tile_obstheta)
    else:
        x, y = fiberassign_radec2xy_flat(ra, dec, tile_ra, tile_dec, mjd,
                                         tile_obsha, tile_obstheta)
    return x,y

def xy2radec(hw, tile_ra, tile_dec, tile_obstime, tile_obstheta, tile_obsha,
             x, y, use_cs5, threads=0):
    '''
    For the tile pointed at (tilera, tiledec), compute the RA,Dec
    pointing of the specified X/Y location in millimeters.

    Args:
      hw: Hardware object
      tile_obstime (string): Tile observation time, YYYY-MM-DDTHH:MM:SS / Astropy "isot" format.
      tile_obstheta (float): Tile "fieldrot" rotation angle.
      tile_obsha (float): Tile designed Hour Angle, in degrees.
      x (numpy array): X position in mm.
      y (numpy array): Y position in mm.
      use_CS5 (bool):  If True, assume X,Y are CS5 coordinates, else curved.
      threads=0 (int): currently unused; for backward compatibility.

    Returns:
      ra, dec (numpy arrays): the (RA, Dec) values of the focalplane locations,
                              in degrees.
    '''
    # radec = hw.xy2radec_multi(
    #     tile_ra, tile_dec, tile_obstheta, x, y, use_cs5, threads
    #     )
    # ra  = np.array([r for r,d in radec])
    # dec = np.array([d for r,d in radec])
    from desimeter.fiberassign import fiberassign_cs5_xy2radec, fiberassign_flat_xy2radec
    from astropy.time import Time
    t = Time(tile_obstime, format='isot')
    mjd = t.mjd
    if use_cs5:
        ra,dec = fiberassign_cs5_xy2radec(x, y, tile_ra, tile_dec, mjd,
                                          tile_obsha, tile_obstheta)
    else:
        ra,dec = fiberassign_flat_xy2radec(x, y, tile_ra, tile_dec, mjd,
                                           tile_obsha, tile_obstheta)
    return ra,dec

def xy2cs5(x, y):
    '''
    Converts from curved focal-plane X,Y coordinates in mm into CS5
    coordinates in mm.

    Args:
    x (numpy array): X coord (mm)
    y (numpy array): Y coord (mm)

    Returns:
    cs5x (numpy array): CS5 X coord (mm)
    cs5y (numpy array): CS5 Y coord (mm)
    '''
    # There's a change in terminology between the focal-plane team and
    # the outside world here...
    from desimeter.transform.pos2ptl import flat2ptl
    return flat2ptl(x, y)

def cs52xy(x, y):
    '''
    Converts from CS5 coordinates (mm) into curved focal-plane X,Y coordinates in mm.

    Args:
    cs5x (numpy array): CS5 X coord (mm)
    cs5y (numpy array): CS5 Y coord (mm)

    Returns:
    x (numpy array): X coord (mm)
    y (numpy array): Y coord (mm)
    '''
    # There's a change in terminology between the focal-plane team and
    # the outside world here...
    from desimeter.transform.pos2ptl import ptl2flat
    return ptl2flat(x, y)