🔍

Science Highlight

 

 

More...
news!

python-script

text/x-python plotobs_cycle11.py — 114.7 KB

File contents

"""
    Version: 1.2

    Author
        John Carpenter (john.carpenter@alma.cl)
    Maintainer
        Gabriel Marinello (gabriel.marinello@alma.cl)

    Change history:
       September 13, 2019 : First release of script (JMC)
       March 2019: Update for Cycle 8.
       March 2022: Update for Cycle 9.
       March 9, 2023: Update for Cycle 10.
       March 6, 2024: Update for Cycle 11 proposals call.

    Purpose
       plotobs_cycle11.py prints and plots the pointing positions of 
       ongoing Grade A projects for Cycle 10. The ALMA Archive should
       be consulted for the list of completed observations over all 
       Cycles.

       The script provides three basic function:
          1) Plot observations around a specified source name or coordinate,
             and overlay a proposed observation (single pointing or 
             rectangular mosaic) on the plot.

          2) List the sources observed for a project

          3) List the details (sensitivity, angular resolution, etc...)
             of an observations

    Support
       plotobs_cycle11.py is user-contributed software that is distributed 
       "as-is". plotobs_cycle11.py is not an offical ALMA product and is 
       not supported by the ALMA Regional Centers (ARCs). Please report any 
       bugs to Gabriel Marinello (gabriel.marinello@alma.cl).

    Dependencies
       1) The plotobs_cycle11.py script requires a data file containing
          the list of ALMA observations. The root name of the file is 
          contained in the variable LIST_OF_OBSERVATIONS, and the data file
          can be found on the ALMA Science Portal in the link 
          http://almascience.org/proposing/duplications.

          The science portal has two versions of the data: an excel spreadsheet
          (.xlsx extension) and a comma-separated-variable (.csv extension) 
          format. Both formats work with this script. To read the excel
          file, the python package "openpyxl" must be installed. This
          package is often not installed, and using the .csv is more likely
          to be successful.

       2) Various python packages must be installed, which are listed below 
          under "Required software".

    To run the program
       # Start python with graphics enabled by the --pylab option
       ipython --pylab

       # Import program
       import plotobs_cycle11 as po

       # Main routines
       po.plot(...)     # Plots ALMA observations and a proposed observation
       po.project(...)  # Show observations for ALMA projects
       po.row(...)      # List observing parameters for rows in spreadsheet

    Examples
       # Plot scheduled observations within 100" x 100" of NGC7496 along 
       # with a proposed pointing at the position of the galaxy
       po.plot('NGC7496', plot_size=200)

       # Plot scheduled observations within 600" x 600" of NGC7496, along with a 
       # proposed mosaic at 345 GHz
       po.plot('NGC7496', plot_size=600, length=240, width=120, 
               pa=60, freq=345., mosaic=True)

       # Plot scheduled observations within 480" of (ra, dec) = (17h20m42s, -35d48m04s)
       po.plot('17h20m42s', '-35d48m04s', plot_size=480, frame='icrs', unit='deg')

       # Plot scheduled observations in a 1 deg region around ra, dec = (150, 2.2) J2000
       po.plot(150, 2.2, plot_size=3600)

       # Plot scheduled observations in a 20 arcmin around galactic center, but mosaics only
       po.plot(0, 0, frame='galactic', plot_size=1200., mosonly=True)

       # Plot scheduled observations within a 120" x 120" of HL Tau and GO Tau
       po.plot('HL Tau, GO Tau', plot_size=120)

       # Plot scheduled observations near coordinates in row 494 of the spreadsheet
       po.plot(494)

       # Plot remaining observations centered on row 494 in the spreadsheet, and only row 494
       po.plot(494, include=494)

       # Plot remaining observations for sources listed in a text file 
       # Contents of input file "input.txt":
HL Tau
GO Tau
plot_size = 480
NGC7496
frame = galactic
plot_size = 60
178.8590 -19.9724
       po.plot(inputFile='input.txt')

       # List scheduled observations for project 2018.1.00035.L
       po.project('2018.1.00035.L')

       # Print the observational parameters for row 1971 in Excel spreadsheet
       po.row(1971)

    Notes
       1) After running "import plotobs as po", additional help can 
          be obtained by typing in python:
              help(po)
              help(po.plot)
              help(po.row)
              help(po.project)

       2) When plotobs_cycle11 is run the first time in a python session, the 
          spreadsheet containing the observations is read into memory and 
          stored in the global variable OBSERVATIONS. The data stored in 
          memory is then used on subsequent calls. 

       3) Target of Opportunity or solar system observations will likely (but 
          not always) have a RA/Dec coordinate of (0 deg, 0 deg). Such 
          observations can be identified by running the script as:
             po.plot(0, 0)

    Cautions
       1) The spreadsheet used by plotobs_cycle11.py contains the sensitivity
          and angular resolution requested by the principal investigator. 
          The achieved sensitivity and angular resolution of the actual 
          observations will differ.

       2) The sensitivity per spectral window is computed using the reference
          frequency and reference bandwidth, but does not take into account
          the variation in the system temperature amongst the various
          spectral windows.

    Software
       Platforms
           1) plotobs_cycle11.py program has been successfully tested on 
              a MacBook Pro and an iMac running OS Sierra.

       The program requires only python 3.8 or greater and some recent numpy,
       pandas and astropy libraries. It has been tested sucessfully since 
       python 3.4 amd pandas 0.20. I tested the code against the most recent 
       versions of these libraries to ensure no deprecation has affect it.
       The prgram has successfully worked with the following:
           1) python 3.12.2
           2) numpy 1.23.4
           3) pandas 2.2.0 version ; see http://pandas.pydata.org .
           4) astropy version 6.0.0; see http://www.astropy.org
           5) If you want to read the excel (.xlsx) instead of the csv version 
              of the spreadsheet, the openpyxl python package needs to be installed.

       Internet connection
           plotobs_cycle11.py does not require a internet connection to run.
           plotobs_cycle11.py does use the Sesame database to resolve the 
           coordinates for source names. If an internet connection is 
           not available, the script cannot use Sesame, and will to determine 
           the coordinates by looking for the source name in the spreadsheet.
"""

# Load packages
# from cgitb import text
import matplotlib.pylab as py
import numpy as np
import pandas as pd
import re
import os
import astropy
from astropy.coordinates import SkyCoord
import astropy.constants as G
from matplotlib.patches import Circle, Polygon

# Seaborn package is not required, but it makes the plot looks nicer
try:
    import seaborn as sns
except:
    pass

# Name of columns in observations
ANG_RES = "Req. Ang. Res."
BAND = "Band"
DEC_DEG = "Dec"
DEC_DEG_ORIG = "Dec_orig"
DEC_DMS = "Dec_DMS"
EXCEL_LINE = "excel"  # Created in readObservations
FWHM_PB = "FWHM PB"  # Created in readObservations
IS_ACA_STANDALONE = "standAlone_ACA"
IS_LP = "Is Large Program"  # Created in readObservations
IS_MOSAIC = "Mosaic"
IS_SOLAR = "Solar Observing"
IS_SPECTRAL = "isSpectralScan"  # Created in readObservations
IS_SPW_SKY_FREQ = "Is Sky Freq?"
IS_VLBI = "VLBI"
LAS = "Req. LAS"
LAT_OFFSET = "Lat Offset"
LON_OFFSET = "Long Offset"
MOS_AREA = "mosaic area"  # Created in readObservations
MOS_LENGTH = "Mos. Length"
MOS_PA = "Mos. PA"
MOS_WIDTH = "Mos. Width"
MAX_SIZE = "Max size"  # Created in readObservations
MOS_SPACING = "Mos. Spacing"
MOS_COORD = "Mos. Coord."
MOVING_OBJECT = "Moving object"  # Set in readObservation
PINAME = "PI"
POLARIZATION = "Polarization"
PROJECT = "Project Code"
RA_DEG = "RA"
RA_DEG_ORIG = "RA_orig"
RA_HMS = "RA_HMS"
REF_FREQ = "Ref.Frequency"
REF_BW = "Ref.Freq.Width"
REF_BW_MEASURE = "repBandWidth_Measure"
REF_BW_UNIT = "repBandwidthSG_unit"
REP_WIN_BW = "bandwidth_SPW_rep"
REP_WIN_RES = "spectralRes_SPW_rep"
REQ_SENS = "Req.Sensitivity"
REQ_SENS_UNIT = "sensitivity_unit"
SG_FULLNAME = "Science Goal"
SG_NAME = "SG_Number"
SPS_BW = "SPS Bandwidth"
SPS_END = "SPS End Freq."
SPS_SPW_RES = "SPS Spec. Res."
SPS_START = "SPS Start Freq."
SPW_FREQ = {}
SPW_BW = {}
SPW_RES = {}
TARGET = "Target Name"
TARGET_STRIPPED = "Target Name stripped"  # Add by readObservations()
TIME_TOTAL = "estimatedTime"
TIME_12M = "est12Time"
TIME_ACA = "estACATime"
TIME_7M = "est7Time"
TIME_TP = "eTPTime"
USE_12M = "Use 12m?"  # Added by readObservations()
USE_7M = "Use 7-m?"
USE_TPA = "Use TP?"
VELOCITY = "Velocity"
VELOCITY_UNIT = "Velocity unit"  # added by readObservations()
VELOCITY_REF = "Vel. Frame"
VELOCITY_DOP = "Vel. Convention"

# Set array keywords
NWINDOWS = 16
for win in range(1, NWINDOWS + 1):
    SPW_FREQ[win] = "Freq SPW %d" % win
    SPW_BW[win] = "Bandwidth SPW %d" % win
    SPW_RES[win] = "Spec.Res. SPW %d" % win

# List of observations
LIST_OF_OBSERVATIONS = "dup_input_cycle11"
SKIPROWS = None

# Store observations in the spreadsheet in global variable
OBSERVATIONS = None

# List of entries in the observations, their label, and unit
OBS_ITEMS = dict()
OBS_ITEMS[ANG_RES] = ["Angular resolution", "arcsec"]
OBS_ITEMS[BAND] = ["Band", None]
OBS_ITEMS[DEC_DEG] = ["Declination", "dms"]
OBS_ITEMS[FWHM_PB] = ["12m primary beam size", "arcsec"]
OBS_ITEMS[IS_MOSAIC] = ["Is mosaic?", None]
OBS_ITEMS[IS_SPECTRAL] = ["Is spectral scan?", None]
OBS_ITEMS[LAS] = ["Largest angular scale", "arcsec"]
OBS_ITEMS[LAT_OFFSET] = ["Latitude offset", "arcsec"]
OBS_ITEMS[LON_OFFSET] = ["Latitude offset", "arcsec"]
OBS_ITEMS[MAX_SIZE] = ["Maximum field of view", "arcsec"]
OBS_ITEMS[MOS_AREA] = ["Mosaic area", "sq. arcmin"]
OBS_ITEMS[MOS_COORD] = ["Coordinate system", None]
OBS_ITEMS[MOS_LENGTH] = ["Length", "arcsec"]
OBS_ITEMS[MOS_PA] = ["Position angle", "deg"]
OBS_ITEMS[MOS_SPACING] = ["Pointing spacings", "arcsec"]
OBS_ITEMS[MOS_WIDTH] = ["Width", "arcsec"]
OBS_ITEMS[POLARIZATION] = ["Polarization", None]
OBS_ITEMS[PROJECT] = ["Project code", None]
OBS_ITEMS[RA_DEG] = ["Right ascension", "hms"]
OBS_ITEMS[REF_FREQ] = ["Reference frequency", "GHz"]
OBS_ITEMS[REF_BW] = ["Reference bandwidth", "MHz"]
OBS_ITEMS[REQ_SENS] = ["Reference sensitivity", "mJy"]
OBS_ITEMS[SPS_START] = ["Spectral scan start", "GHz"]
OBS_ITEMS[SPS_END] = ["Spectral scan end", "GHz"]
OBS_ITEMS[SPS_BW] = ["Spectral scan bandwidth", "MHz"]
OBS_ITEMS[SPS_SPW_RES] = ["Spectral scan resolution", "MHz"]
OBS_ITEMS[TARGET] = ["Target name", None]
OBS_ITEMS[VELOCITY] = ["Center velocity", None]
OBS_ITEMS[VELOCITY_UNIT] = ["Unit for velocity", None]
OBS_ITEMS[VELOCITY_REF] = ["Velocity frame", None]
OBS_ITEMS[VELOCITY_DOP] = ["Velocity convention", None]
OBS_ITEMS[USE_7M] = ["Use 7m array", None]
OBS_ITEMS[USE_TPA] = ["Use Total Power Array?", None]

# Keywords for dictionary in readObservations only
DATA = "data"

# Keywords for dictionary in getSourceCoordinates()
RA = "ra"
DEC = "dec"
ORIGINAL = "original"
PLOTSIZE = "plotsize"
ISCOORD = "iscoord"
NOCOORD = "nocoord"

# Miscellaneous keywords
COORDS = "c"
MOSAIC_TYPE_RECTANGLE = "Rectangle"
MOSAIC_TYPE_CUSTOM = "Custom"
MOSAIC_TYPES = [MOSAIC_TYPE_RECTANGLE, MOSAIC_TYPE_CUSTOM]

# Miscellaneous parameters
EPSILON = 1e-4

# Set band frequencies
# http://www.almaobservatory.org/en/about-alma/how-does-alma-work/technology/front-end
BAND_UNKNOWN = "unknown"
BAND_FREQ = dict()
BAND_FREQ["ALMA_RB_01"] = [31, 50]
BAND_FREQ["ALMA_RB_02"] = [67, 90]
BAND_FREQ["ALMA_RB_03"] = [84, 116]
BAND_FREQ["ALMA_RB_04"] = [125, 163]
BAND_FREQ["ALMA_RB_05"] = [158, 211]
BAND_FREQ["ALMA_RB_06"] = [211, 275]
BAND_FREQ["ALMA_RB_07"] = [275, 373]
BAND_FREQ["ALMA_RB_08"] = [385, 500]
BAND_FREQ["ALMA_RB_09"] = [602, 720]
BAND_FREQ["ALMA_RB_10"] = [787, 950]
BAND_FREQ[BAND_UNKNOWN] = [0, 0]


def getSkipRows(filename):
    """Rows to skip in LIST_OF_OBSERVATIONS file
    since they contain header information.
    """
    if filename.find(LIST_OF_OBSERVATIONS) == 0:
        skiprows = list(range(39))
        if len(skiprows) == 0:
            skiprows.append(1)
        else:
            skiprows.append(max(skiprows) + 2)
    elif filename.find("archive") == 0:
        skiprows = []
    elif filename.find("proposals_cycle9") == 0:
        skiprows = []
    elif filename.find("proposals_cycle8") == 0:
        skiprows = []
    elif filename.find("proposals_cycle7") == 0:
        skiprows = []
    else:
        raise Exception("Unknown spreadsheet: %s" % filename)
    return skiprows


def getUsableBandwidth(bw, indx=None, throwException=True):
    """
    Returns the usable bandwidth in MHz.

    Inputs:
        bw  : nominal bandwidth in MHz.
        indx: Row number in the data structure. This is used for informational
                purposes only if there is an error in the input bw.
        throwException : If True, then throw an exception if bandwidth is invalid.
                        Otherwise, return None as the usable bandwidth.

    Output:
        usable bandwidth in MHz

    Notes:
        The usable bandwidths are given in Table 5.3 of the
        Cycle 3 ALMA Technical Handbook.
    """
    msg = None

    if abs(bw - 62.5) < EPSILON or abs(bw - 58.6) < 0.1:
        use_bw = 58.6
    elif abs(bw - 125.0) < EPSILON or abs(bw - 117.2) < 0.1:
        use_bw = 117.2
    elif abs(bw - 250.0) < EPSILON or abs(bw - 234.4) < 0.1:
        use_bw = 234.4
    elif abs(bw - 500.0) < EPSILON or abs(bw - 468.8) < 0.1:
        use_bw = 468.8
    elif abs(bw - 1000.0) < EPSILON or abs(bw - 937.5) < 0.1:
        use_bw = 937.5
    elif (
        abs(bw - 1875) < EPSILON
        or abs(bw - 2000) < EPSILON
        or (bw > 1875 and bw < 2000)
    ):
        use_bw = 1875.0
    else:
        if throwException:
            msg = "Do not recognize bandwidth value: %.1f" % bw
            if indx is not None:
                msg = "Do not recognize bandwidth value (%.1f) on row %d" % (
                    bw,
                    getExcelFromIndex(indx),
                )
        else:
            use_bw = None

    if msg is not None:
        raise Exception(msg)
    else:
        return use_bw


def checkEphemerisNames(data):
    """
    Check the names for sources that have a name "ephemeris". They
    should have been replaced with the actual source names from
    the proposals.
    """
    #  print 'WARNING: Not checking ephemeris objects'
    return

    # Set codes/sources that are moving objects and names need to be modified.
    tuples = []

    # Separate tuples
    found_tuples = [False] * len(tuples)
    codes = []
    sgnames = []
    for i in range(len(tuples)):
        codes.append(tuples[i][0])
        sgnames.append(tuples[i][1])
    codes = np.array(codes)
    sgnames = np.array(sgnames)

    # Loop over rows in data structure
    for indx in data.index:
        # Get information
        code = data[PROJECT][indx]
        name = str(
            data[TARGET][indx]
        )  # str is needed since some source names are numeric

        # Loop over cases
        if (
            name.find("Ephemeris") == 0
            and abs(data[RA_DEG][indx]) < EPSILON
            and abs(data[DEC_DEG][indx]) < EPSILON
        ):
            # Find entry
            j = np.where((codes == code) & (sgnames == name))[0]
            if len(j) == 0:
                raise Exception(
                    "Could not find project code %s and source %s: see row=%d"
                    % (code, name, getExcelFromIndex(indx))
                )
            elif len(j) > 1:
                raise Exception(
                    "Found multiple entries for project code %s and source %s"
                    % (code, name)
                )

            # Modify name
            data.loc[indx, (TARGET)] = tuples[j][2]
            found_tuples[j] = True

    # All rows should have been identified
    if found_tuples.count(False) > 0:
        print("Did not find the following ephemeris sources:")
        for j, found in enumerate(found_tuples):
            if not found:
                print("%s %s" % (tuples[j][0], tuples[j][1]))
        raise Exception("Not all ephemeris sources were identified.")


def getIndexFromExcel(excel, check=True):
    """
    Return the index number of the data structure from the excel row number.

    If check = True, the resultant values is checked for errors.
    """
    # Set index to account for header row and the index=1 in excel
    indx = excel - 2

    # Account for skipped rows. Assumes all skipped rows are before data
    if SKIPROWS is not None:
        indx -= len(SKIPROWS)

    # Check for errors
    lindex = makeList(indx)
    for l in lindex:
        if (
            check
            and OBSERVATIONS is not None
            and (l < 0 or l > OBSERVATIONS[DATA].index.max())
        ):
            minRow = getExcelFromIndex(0, check=False)
            maxRow = getExcelFromIndex(OBSERVATIONS[DATA].index.max(), check=False)
            raise Exception(
                "Excel row (%d) is out of range. Allowed range is between %d and %d"
                % (excel, minRow, maxRow)
            )

    return indx


def getExcelFromIndex(indx, check=True):
    """
    Return the row number in the excel spreadsheet from the index number
    in the data structure.

    If check = True, the resultant valueis checked for errors.
    """
    # Set index to account for header row and the index=1 in excel
    excel = indx + 2

    # Account for skipped rows. Assumes all skipped rows are before data
    if SKIPROWS is not None:
        excel += len(SKIPROWS)

    # Check for errors
    if check and OBSERVATIONS is not None:
        minIndex = 0
        maxIndex = OBSERVATIONS[DATA].index.max()
        minExcel = getExcelFromIndex(minIndex, check=False)
        maxExcel = getExcelFromIndex(maxIndex, check=False)
        if excel < minExcel or excel > maxExcel:
            raise Exception(
                "Index (%d) is out of range. Allowed range is between %d and %d"
                % (indx, minIndex, maxIndex)
            )

    return excel


def getBandNumber(band):
    """
    Returns band number from the band string in the spreadsheet.
    This assumes the band format is in ALMA_RB_NN.

    If there is an error, then 0 is return.
    """

    try:
        bn = int(band.split("_")[-1])
    except:
        bn = 0

    return bn


def makeList(sources, parse=True, delimiter=","):
    """Convert the variable "sources" into a list.

    Input : sources   - a variable or list
            parse     - if True, parse elements with delimiter separator
            delimiter - the delimiter when parsing strings
    Output: Return value is a list.
            If input is a single value, then output is [sources]
            If input is a list, the result is copied

    Examples:
            (1) l = makeList('3c273')
            (2) l = makeList('3c273, 3c279')
            (3) l = makeList(['3c273','3c279'])
    """

    # Return if sources is empty
    if sources is None:
        return None

    # Convert to arrays
    l = list()
    t = [type(sources)]
    if str in t or str in t:
        l = [sources]
    else:
        try:
            i = len(sources)
            l = np.array(sources)
        except:
            l = np.array([sources])
    if len(l) == 0:
        return None

    # Parse string
    if parse:
        t = list()
        for z in l:
            if str in [type(z)]:
                for zz in z.split(delimiter):
                    t.append(re.sub(" +", " ", zz.strip()))
            else:
                t.append(z)
        l = np.array(t[:])
    return l


def convertHmsString(value, ndec=0, showSeconds=True, delimiter=":"):
    """
    Converts floating point to HH:MM:[SS.S]

    Inputs
        value       : floating point number
        ndec        : number of decimal points to print seconds
        showSeconds : If True, show seconds, otherwise just
                        use HH:MM. Default:True
        delimiter   : the characters to be used between the numbers.

    Output
        a string of format hh:mm:[ss.s]

    Examples:
        convertHmsString(15.0)
        convertHmsString(15.0, ndec=2)
        convertHmsString(15.0, ndec=2, delimiter='hms')
        convertHmsString(15.0, ndec=2, delimiter='dms')
    """

    # Set delimiter
    spacing = delimiter
    if len(spacing) == 1:
        spacing = [spacing] * 3

    # Construct string
    t = value
    st = str(type(value))
    if st.find("int") >= 0 or st.find("float") >= 0:
        x = abs(value)
        h = int(x)
        m = int(60 * (x - h))
        sec = 3600.0 * (x - h - m / 60.0)

        t = str("%.2d" % h) + spacing[0] + str("%.2d" % m)

        if showSeconds:
            # Add seconds
            t += spacing[1]
            format = "%0" + str(ndec + 3) + "." + str(ndec) + "f"
            if ndec <= 0:
                format = "%.2d"
            t += str(format % sec)
            if spacing[2] not in [" ", ":"]:
                t += spacing[2]

        if value < 0.0:
            t = "-" + t
    return t


def computeZ(data, indx):
    """
    Computes redshift based on velocity for row indx in data.
    """

    # Get velocity
    velocity = data[VELOCITY][indx]
    velocity_unit = data[VELOCITY_UNIT][indx]
    doppler = data[VELOCITY_DOP][indx]

    # Convert velocity to km/s
    if velocity_unit == "km/s":
        v_kms = velocity
    elif velocity_unit == "m/s":
        v_kms = velocity / 1e3
    else:
        raise Exception("Velocity unit not recognized: %s" % velocity_unit)

    # Compute redshift
    c_kms = G.c.value / 1e3
    if doppler == "OPTICAL":
        z = v_kms / c_kms
    elif doppler == "RADIO":
        z = v_kms / c_kms / (1.0 - v_kms / c_kms)
    elif doppler == "RELATIVISTIC":
        z = np.sqrt((1.0 + v_kms / c_kms) / (1.0 - v_kms / c_kms)) - 1.0
    else:
        raise Exception(
            "Doppler frame not recognized (%s) on row %d"
            % (doppler, getExcelFromIndex(indx))
        )

    # Done
    return z


def fwhmPB(freqGHz, diameter):
    """
    Returns primary FWHM in arcsec

    freqGHz : frequency in GHz
    diameter: telescope diameter in meters

    See the knowledgebase article for more information:
        https://help.almascience.org/index.php?/Knowledgebase/Article/View/90/0/how-do-i-model-the-alma-primary-beam-and-how-can-i-use-that-model-to-obtain-the-sensitivity-profile-for-an-image-mosaic
    """
    return 1.13 * G.c.value / (freqGHz * 1e9) / diameter / np.pi * 180.0 * 3600.0


def plotMosaic(ax, corners, fc="black", ec="None", linewidth=2, alpha=0.5, hatch=None):
    """
    Plot a rectangular region for a mosaic.

    alpha     : transperancy of the plot rectangle
    ax        : pylab plot handle
    corners   : dictionary containing the corners of the mosaic, specific
                in RA,Dec offsets in arcseconds relative to the (0,0). It
                is best to use getMosaicCorners() to compute corner
                positions.
    ec        : edge color of the plot rectangle
    fc        : solid face color of the rectangle
    hatch     : hatch pattern for the rectangle
    linewidth : linewidth of the plot edge
    """
    UL = corners["UL"]
    UR = corners["UR"]
    BL = corners["BL"]
    BR = corners["BR"]

    xypolygon = np.zeros((4, 2))
    xypolygon[:, 0] = np.array([UL[0], UR[0], BR[0], BL[0]])
    xypolygon[:, 1] = np.array([UL[1], UR[1], BR[1], BL[1]])
    p = Polygon(
        xypolygon,
        closed=True,
        fc=fc,
        ec=ec,
        alpha=alpha,
        linewidth=linewidth,
        hatch=hatch,
    )
    result = ax.add_artist(p)

    #  ax.plot([UL[0], UR[0], BR[0], BL[0], UL[0]],
    #          [UL[1], UR[1], BR[1], BL[1], UL[1]],
    #          color=ec, linewidth=linewidth)

    return result


def plotPrimaryBeam(
    ax, xy, freq, diameter, fc="black", ec="black", alpha=1, linewidth=2
):
    """
    Plot the primary beam FWHM for an observation at frequency freq in GHz
    for a telescope diameter in meters.

    ax        : pylab plot handle
    xy        : center of the primary beam
    fc        : face color of the plot circle
    ec        : edge color of the plot circle
    alpha     : transperancy of the plot circle
    linewidth : linewidth of the plot edge
    """
    radius = 0.5 * fwhmPB(freq, diameter)
    c = Circle(xy, radius=radius, fc=fc, ec=ec, alpha=alpha, linewidth=linewidth)
    result = ax.add_artist(c)
    #  color = 'yellow'
    #  e = Ellipse( (0, 0), width=2*radius, height=2*radius, angle=0,
    #               color=color, fc=color, ec='black')
    #  result = ax.add_artist(e)

    return result


def getBandColor(freq):
    """
    Set a plot color based on the input frequency (GHz),
    which a different color is chosen per ALMA band.
    """
    # Set band colors for plotting
    band_colors = dict()
    band_colors["ALMA_RB_01"] = "gray"
    band_colors["ALMA_RB_02"] = "silver"
    band_colors["ALMA_RB_03"] = "blue"
    band_colors["ALMA_RB_04"] = "peru"
    band_colors["ALMA_RB_05"] = "yellow"
    band_colors["ALMA_RB_06"] = "black"
    band_colors["ALMA_RB_07"] = "green"
    band_colors["ALMA_RB_08"] = "orange"
    band_colors["ALMA_RB_09"] = "magenta"
    band_colors["ALMA_RB_10"] = "red"
    band_colors[BAND_UNKNOWN] = "tan"

    # Find color
    band_input = None
    for key, freq_range in BAND_FREQ.items():
        if freq >= freq_range[0] and freq <= freq_range[1]:
            band_input = key
            break
    if band_input is None:
        print("Warning: Frequency outside of ALMA bands.")
        band_input = BAND_UNKNOWN

    # Done
    return band_colors[band_input]


def checkObservations(obsdata, isdata=False):
    """
    Check that the user-supplied observation seems reasonable.
    Only a light check is done.

    obsdata is either "observations" from readObservations (isdata=False)
    or observations[DATA] (if isdata=True)
    """
    # Checks if isdata=False
    if isdata:
        # Must be panda structure
        if pd.core.frame.DataFrame not in [type(obsdata)]:
            raise Exception("observations must be a dictionary")

        # Get keys
        keys = obsdata.keys().tolist()
        msg = "data"
    else:
        # Must have two keywords, if observations
        if dict not in [type(obsdata)]:
            raise Exception("observations must be a dictionary")

        # Check keys
        for key in [DATA, COORDS]:
            if key not in obsdata.has_key(key):
                raise Exception("%s keyword not found in observations" % key)

        # Get keys
        keys = obsdata[DATA].keys().tolist()
        msg = "observations[%s]" % DATA

    # Check entries in OBS_ITEMS
    for key in OBS_ITEMS.keys():
        if keys.count(key) != 1:
            raise Exception('Keyword "%s" not found in %s' % (key, msg))

    # Check spectral keywords
    for w in SPW_FREQ.keys():
        if SPW_FREQ[w] not in keys:
            raise Exception('Keyword "%s" not found in %s' % (SPW_FREQ[w], msg))
        if SPW_BW[w] not in keys:
            raise Exception('Keyword "%s" not found in %s' % (SPW_BW[w], msg))
        if SPW_RES[w] not in keys:
            raise Exception('Keyword "%s" not found in %s' % (SPW_RES[w], msg))

    # Done
    return True


def checkData(data, verbose=True, spreadsheet=None):
    """
    Run checks on the data read from the Excel spreadsheet to
    catch any obvious anomalies.
    """
    # Message
    if verbose:
        print("Running checks on data")

    # Initialize
    error = False

    # Check keywords
    if verbose:
        print("   ... checking keywords in data structure")
    checkObservations(data, isdata=True)

    # Check RA
    if verbose:
        print("   ... checking right ascensions")
    j = np.where((data[RA_DEG] < 0) | (data[RA_DEG] > 360.0))[0]
    if len(j) > 0:
        error = True
        if verbose:
            print("        --- invalid right ascension in %d rows" % len(j))

    # Check Declination
    if verbose:
        if verbose:
            print("   ... checking declinations")
    j = np.where((data[DEC_DEG] < -90.0) | (data[DEC_DEG] > 90.0))[0]
    if len(j) > 0:
        error = True
        if verbose:
            print("        --- invalid declination in %d rows" % len(j))

    # Check mosaic offsets
    if verbose:
        print("   ... checking mosaic parameters are present for rectangular mosaics")
    # Check mosaic system
    j = (data[MOS_COORD].isnull() == True) & (data[IS_MOSAIC] == MOSAIC_TYPE_RECTANGLE)
    if np.sum(j) > 0:
        error = True
        if verbose:
            print("        --- found %d mosaics where system was not set" % len(j))

    # Check PA
    j = (data[MOS_PA].isnull() == True) & (data[IS_MOSAIC] == MOSAIC_TYPE_RECTANGLE)
    if np.sum(j) > 0:
        error = True
        if verbose:
            print("        --- found %d mosaics where PA was not set" % np.sum(j))

    # Check length
    j = (data[MOS_LENGTH].isnull() == True) & (data[IS_MOSAIC] == MOSAIC_TYPE_RECTANGLE)
    if np.sum(j) > 0:
        error = True
        if verbose:
            print("        --- found %d mosaics where length was not set" % np.sum(j))
    j = (data[MOS_LENGTH] <= 0) & (data[IS_MOSAIC] == MOSAIC_TYPE_RECTANGLE)
    if np.sum(j) > 0:
        error = True
        if verbose:
            print("        --- found %d mosaics where length was zero" % np.sum(j))

    # Check width
    j = (data[MOS_WIDTH].isnull() == True) & (data[IS_MOSAIC] == MOSAIC_TYPE_RECTANGLE)
    if np.sum(j) > 0:
        error = True
        if verbose:
            print("        --- found %d mosaics where width was not set" % np.sum(j))
    j = (data[MOS_WIDTH] <= 0) & (data[IS_MOSAIC] == MOSAIC_TYPE_RECTANGLE)
    if np.sum(j) > 0:
        error = True
        if verbose:
            print("        --- found %d mosaics where width was zero" % np.sum(j))

    # Check if reference sensitivity is valid
    if verbose:
        print("   ... checking values of requested sensitivity are non-zero")
    mask = (data[REQ_SENS] <= 0.0) | (np.isnan(data[REQ_SENS]) == True)
    if mask.sum() > 0:
        error = True
        if verbose:
            print("        --- invalid sensitivity values in %d rows" % len(j))
            data.loc[mask, (REQ_SENS)] = 1.0

    # Check if reference bandwidth is valid
    if verbose:
        print("   ... checking values of reference bandwidth are non-zero")
    j = (data[REF_BW] <= 0.0) | (data[REF_BW].isnull() == True)
    if np.sum(j) > 0:
        error = True
        print(data[PROJECT][j])
        if verbose:
            print(
                "        --- invalid reference bandwidth values in %d rows" % np.sum(j)
            )

    # Check spectral scans
    if verbose:
        print("   ... checking spectral scan frequencies and bandwidths")
    for keys in [
        [SPS_START, "starting frequency"],
        [SPS_END, "ending frequency"],
        [SPS_BW, "bandwidth"],
        [SPS_SPW_RES, "spectral resolution"],
    ]:
        j = ((data[keys[0]].isnull() == True) | (data[keys[0]] <= 0)) & (
            data[IS_SPECTRAL] == True
        )
        if np.sum(j) > 0:
            error = True
            if verbose:
                print(
                    "        --- spectral scan %s is not set in %d rows"
                    % (keys[1], np.sum(j))
                )

    # Check that the frequencies match the bands
    if verbose:
        print("   ... checking values of frequencies and bandwidths")
    for indx in data.index:
        # Get range of frequencies defined this ALMA band
        freq_range = BAND_FREQ[data[BAND][indx]]

        # Check reference frequency
        if data[REF_FREQ][indx] < freq_range[0] or data[REF_FREQ][indx] > freq_range[1]:
            error = True
            if verbose:
                print(
                    "        --- reference frequency out of range in row %d"
                    % getExcelFromIndex(indx, check=False)
                )

        # Check spectral scan frequencies
        if data[IS_SPECTRAL][indx]:
            # Starting frequency
            if (
                data[SPS_START][indx] < freq_range[0]
                or data[SPS_START][indx] > freq_range[1]
            ):
                error = True
                if verbose:
                    print(
                        "        --- spectral scan starting frequency out of range in row %d"
                        % getExcelFromIndex(indx, check=False)
                    )

            # Ending frequency
            if (
                data[SPS_END][indx] < freq_range[0]
                or data[SPS_END][indx] > freq_range[1]
            ):
                error = True
                if verbose:
                    print(
                        "        --- spectral scan ending   frequency out of range in row %d"
                        % getExcelFromIndex(indx)
                    )
        else:
            # Check each window
            for w in list(SPW_FREQ.keys()):
                # Check frequencies
                nu = data[SPW_FREQ[w]][indx]
                if np.isfinite(nu) and (nu < freq_range[0] or nu > freq_range[1]):
                    error = True
                    if verbose:
                        print(
                            "        --- spectral window frequency out of range in window %d on row %d"
                            % (w, getExcelFromIndex(indx, check=False))
                        )

                # Both frequencies and bandwidths need to be set
                bw = data[SPW_BW[w]][indx]
                if (np.isfinite(nu) and np.isnan(bw)) or (
                    np.isfinite(bw) and np.isnan(nu)
                ):
                    error = True
                    if verbose:
                        print(
                            "        --- frequencies and bandwidths are not both set in window %d on row %d"
                            % (w, getExcelFromIndex(indx, check=False))
                        )

                # Check usable bandwidth
                if np.isfinite(nu) and getUsableBandwidth(bw, indx=indx) is None:
                    error = True
                    if verbose:
                        print(
                            "        --- bandwidth is not recognized in window %d on row %d"
                            % (w, getExcelFromIndex(indx, check=False))
                        )

    # Exit if there were any errors
    if verbose:
        print("   ... done")
    if error:
        if spreadsheet is None:
            msg = "The spreadsheet contains unexpected errors."
        else:
            msg = "The spreadsheet %s contains unexpected errors." % spreadsheet
        if not verbose:
            msg += "\nTry with verbose=True to see further information"
        raise Exception(msg)

    # Done
    return


def setSensitivitymJy(data, indx):
    """Compute sensitivity in mJy"""

    # Initialize
    unit = data[REQ_SENS_UNIT][indx]
    value = None

    # Loop over possible units
    if unit == "mJy":
        value = data[REQ_SENS][indx]
    elif unit == "Jy":
        value = data[REQ_SENS][indx] * 1e3
    elif unit in ["mK", "K"]:
        # Set scale factor to convert to mK
        scale = None
        if unit == "mK":
            scale = 1.0
        elif unit == "K":
            scale = 1e3
        else:
            raise Exception(
                "Un-recognized unit (%s) on row %d" % (unit, getExcelFromIndex(indx))
            )

        # Get values needed to convert sensitivity to mJy
        tb_mK = data[REQ_SENS][indx] * scale
        angres = data[ANG_RES][indx]
        freq = data[REF_FREQ][indx]

        # Check values
        if tb_mK <= 0 or angres <= 0 or freq <= 0:
            print(indx)
            print(tb_mK, angres, freq)
            raise Exception(
                "Error computing sensitivity on row %s" % (getExcelFromIndex(indx))
            )

        # Compute wavelength
        value = tb_mK * mjypermk(freq, angres, freq)
    else:
        raise Exception(
            "Un-recognized unit (%s) on row %d" % (unit, getExcelFromIndex(indx))
        )

    # Check
    if value is None or value <= 0 or np.isnan(value):
        raise Exception(
            "Error setting sensitivity (%s) for row %d"
            % (str(value), getExcelFromIndex(indx))
        )

    # Done
    return value


def getFinestResolution(data, indx):
    """Return finest resolution in the spectral setup in MHz"""
    if data[IS_SPECTRAL][indx]:
        resolution = data[SPS_SPW_RES][indx]
    else:
        # Get frequencies of each window
        resolution = None
        for w in list(SPW_FREQ.keys()):
            # Is this a valid window?
            if np.isnan(data[SPW_FREQ[w]][indx]):
                continue

            # Set resolution
            res = data[SPW_RES[w]][indx]

            # Get minimum
            if resolution is None or res < resolution:
                resolution = res

    # Check
    if resolution is None or resolution <= 0 or np.isnan(resolution):
        raise Exception("Error setting resolution for row %d" % getExcelFromIndex(indx))

    # Done
    return resolution


def getLargestBandwidth(data, indx):
    """Return largest bandwidth in the spectral setup in MHz"""
    if data[IS_SPECTRAL][indx]:
        bandwidth = data[SPS_BW][indx]
    else:
        # Get frequencies of each window
        bandwidth = None
        for w in list(SPW_FREQ.keys()):
            # Is this a valid window?
            if np.isnan(data[SPW_FREQ[w]][indx]):
                continue

            # Set resolution
            bw = data[SPW_BW[w]][indx]

            # Get minimum
            if bandwidth is None or bw > bandwidth:
                bandwidth = bw

    # Check
    if bandwidth is None or bandwidth <= 0 or np.isnan(bandwidth):
        raise Exception(
            "Error setting largest bandwidth for row %d" % getExcelFromIndex(indx)
        )

    # Done
    return bandwidth


def setReferenceBandwidth(data, indx):
    """Set reference bandwidth in Hz"""

    # Initialize
    measure = data[REF_BW_MEASURE][indx]
    value = data[REF_BW][indx]

    # Loop over possible measures
    if measure == "AggregateBandWidth":
        value = computeAggregateBandwidth(data, indx)
    elif measure == "FinestResolution":
        value = getFinestResolution(data, indx)
    elif measure == "LargestWindowBandWidth":
        value = getLargestBandwidth(data, indx)
    elif measure == "RepresentativeWindowBandWidth":
        value = data[REP_WIN_BW][indx]
    elif measure == "RepresentativeWindowResolution":
        value = data[REP_WIN_RES][indx]
    elif measure == "User":
        pass
    else:
        raise Exception(
            "Un-recognized bandwidth measure (%s) on row %d"
            % (measure, getExcelFromIndex(indx))
        )

    # Check
    if value is None or value <= 0 or np.isnan(value):
        raise Exception(
            "Error setting reference bandwidth for row %d" % getExcelFromIndex(indx)
        )

    # Done
    return value


def computeCoords(data):
    """Computes astropy coordinates"""
    coords = SkyCoord(
        data[RA_DEG].values * astropy.units.degree,
        data[DEC_DEG].values * astropy.units.degree,
        frame="icrs",
    )

    return coords


def computeMeanFrequency(data, indx):
    """
    Computes mean frequency of a correlator setup.
    In computing the mean frequency, the frequency is weighted by
    the bandwidth.
    """
    if data[IS_SPECTRAL][indx]:
        meanfreq = 0.5 * (data[SPS_START][indx] + data[SPS_END][indx])
    else:
        # Initialize
        sumf = 0.0
        sumbw = 0.0

        # Loop over spectral windows
        for win, key in SPW_FREQ.items():
            # Skip if not a valid window
            if np.isnan(data[key][indx]):
                continue

            # Sum
            nu = data[key][indx]
            bw = data[SPW_BW[win]][indx]
            sumf += nu * bw
            sumbw += bw

        # Compute mean frequency
        if sumbw == 0:
            raise Exception(
                "Bug for indx=%d, %s, %s..." % (indx, data[PROJECT][indx], data[TARGET])
            )
        meanfreq = sumf / sumbw

    # Done
    return meanfreq


def readObservations(
    input=LIST_OF_OBSERVATIONS,
    verbose=True,
    removeSolar=True,
    removeVLBI=True,
    onlySolar=False,
    correctMosaicSpacing=True,
    clearSolar=False,
):
    """Wrapper to readObservation()"""

    # Initialize data structure
    data = None

    # Loop over input files
    for fn in makeList(input):
        # Read
        tmp = readObservingFile(
            fn,
            verbose=verbose,
            removeSolar=removeSolar,
            removeVLBI=removeVLBI,
            onlySolar=onlySolar,
            correctMosaicSpacing=correctMosaicSpacing,
            clearSolar=clearSolar,
        )

        # Concatenate files
        if data is None:
            data = tmp
        else:
            data = data.append(tmp, ignore_index=True)

    # Compute RA/DEC
    if verbose:
        print("   ... converting RA/DEC to astropy sky coordinates")
    coords = computeCoords(data)

    # Done
    print("NOTE: This script only checks on-going observations.")
    print("      Completed observations are listed in the ALMA archive.")
    return {DATA: data, COORDS: coords}


def readObservingFile(
    input=LIST_OF_OBSERVATIONS,
    verbose=True,
    removeSolar=True,
    removeVLBI=True,
    onlySolar=False,
    correctMosaicSpacing=False,
    clearSolar=False,
):
    """
    Read in observations using pandas.

    A global variable called SKIPROWS is set indicating which rows
    were skipped in reading the data file.

    Inputs:
        input : root name for the file containing the existing and
                scheduled observations. The .csv or .xlsx extension can
                be omitted.
        correctMosaicSpacing : If True, corrects an error in Ignacio's
                spreadsheet where the mosaic spacing was too fine.
        clearSolar : If True, set coordinates of solar observations to (0,0) and set name to Sun.

    Output:
        A python dictionary with the following entries:
            DATA      : a panda data set containing the spreadsheet
            COORDS    : astropy sky coordinates for each source in DATA
    """
    # Read in observations. Input list may either be an excel spreadsheet or
    # a csv file. Both are tried, with the .csv first and then the .xlsx.
    data = None
    input_without_extension = (
        input.replace(".xlsx", "").replace(".xls", "").replace(".csv", "")
    )
    if verbose:
        print("Reading observations")
    possible_inputs_files = [
        "%s.%s" % (input_without_extension, ext) for ext in ["csv", "xlsx"]
    ]

    # Try reading file
    for inputFile in possible_inputs_files:
        if ".csv" in inputFile:
            print("Using csv file...")
            if os.path.exists(inputFile):
                # Get number of rows to skip
                skiprows = getSkipRows(inputFile)
                data = pd.read_csv(
                    inputFile, skiprows=skiprows, header=0, low_memory=False
                )
        elif ".xls" in inputFile:
            print("Using excel file...")
            if os.path.exists(inputFile):
                # Get number of rows to skip
                skiprows = getSkipRows(inputFile)
                data = pd.read_excel(inputFile, skiprows=skiprows, header=0)
        else:
            raise Exception("Do not recognize extension for file %s" % input)

        # Did we succeed?
        if data is not None:
            if verbose:
                print("   ... read %d rows in %s" % (data.shape[0], inputFile))
                global SKIPROWS
                SKIPROWS = skiprows
            break

    # Check if data were read
    if data is None:
        raise Exception(
            "Could not read data. Check that the input file %s exists with either a .csv for .xls extension."
            % input
        )

    # Delete column containing RA/DEC in string form since RA/DEC may
    # be modified below.
    del data[RA_HMS]
    del data[DEC_DMS]

    # Copy RA/DEC to original values before apply mosaic
    data[RA_DEG_ORIG] = data[RA_DEG]
    data[DEC_DEG_ORIG] = data[DEC_DEG]

    # Set variable indicating if observation is a spectral scan
    data[IS_SPECTRAL] = data[SPS_START].notnull() == True

    # Add flag if ACA standalone
    if not IS_ACA_STANDALONE in data.columns:
        print("   ... WARNING: assuming these are not ACA standalone observations")
        data[IS_ACA_STANDALONE] = np.array([False] * data.shape[0])
    #  else:
    #     data[USE_12M] = np.array([True] * data.shape[0])

    # Remove (or include) solar observations, if needed. If we are not removing
    # them, then we need to check sensitivities and bandwidth are non-zero.
    if IS_SOLAR in data.columns:
        if removeSolar:
            # Find how many there are
            nrows = data[data[IS_SOLAR] == True].shape[0]
            nprojects = data[PROJECT][data[IS_SOLAR] == True].unique().shape[0]
            print(
                "   ... removing %d solar observations in %d projects"
                % (nrows, nprojects)
            )
            data = data[data[IS_SOLAR] == False]
        else:
            # Reset if needed
            if clearSolar:
                # Message
                print("   ... resetting coordinates and names for solar observations")

                # Get solar observations
                mask = data[IS_SOLAR]

                # Reset name
                #           data.loc[mask, TARGET] = 'Sun'

                # Reset coordinates
                data.loc[mask, RA_DEG] = 0
                data.loc[mask, DEC_DEG] = 0
                data.loc[mask, RA_DEG_ORIG] = 0
                data.loc[mask, DEC_DEG_ORIG] = 0

            # Check sensitivity
            mask = (data[IS_SOLAR] == True) & (data[REQ_SENS] == 0)
            data.loc[mask, REQ_SENS] = 0.1

            # Check bandwidth
            mask = (data[IS_SOLAR] == True) & (data[REF_BW] == 0)
            data.loc[mask, REF_BW] = 7500.0

            # Keep only solar if needed
            if onlySolar:
                print("Only keeping solar observations")
                data = data[data[IS_SOLAR] == True]

    # Remove VLBI observations, if needed. If we are not removing them,
    # then we need to check sensitivities and bandwidth are non-zero.
    if IS_VLBI in data.columns:
        if removeSolar:
            # Find how many there are
            nrows = data[data[IS_VLBI] == True].shape[0]
            nprojects = data[PROJECT][data[IS_VLBI] == True].unique().shape[0]
            print(
                "   ... removing %d VLBI observations in %d projects"
                % (nrows, nprojects)
            )
            data = data[data[IS_VLBI] == False]
        else:
            # Check sensitivity
            j = (data[IS_VLBI] == True) & (data[REQ_SENS] == 0)
            data.loc[j, REQ_SENS] = 0.1

            # Check bandwidth
            j = (data[IS_VLBI] == True) & (data[REF_BW] == 0)
            data.loc[j, REF_BW] = 7500.0

            # Check angular resolution
            j = (data[IS_VLBI] == True) & (data[ANG_RES] == 0)
            data.loc[j, ANG_RES] = 0.00001

    # Convert sensitivity to mJy
    if REQ_SENS_UNIT in data.columns:
        # Initialize
        sens_mjy = np.zeros(data.shape[0])

        # Recompute sensitivity
        for i, indx in enumerate(data.index):
            sens_mjy[i] = setSensitivitymJy(data, indx)

        # Save
        data.loc[:, (REQ_SENS)] = sens_mjy

        # Deletet unit column
        del data[REQ_SENS_UNIT]

    # Convert reference frequency width to MHz
    if REF_BW_UNIT in data.columns:
        # Only support GHz
        nrows = data[data[REF_BW_UNIT] != "GHz"].shape[0]
        if nrows > 0:
            raise Exception(
                "%d values for reference frequency bandwidth are not GHz" % nrows
            )

        # Convert to MHz
        data[REF_BW] *= 1000

        # Delete unit
        data[REF_BW_UNIT]

    # Set reference bandwidth
    if REF_BW_MEASURE in data.columns:
        # Initialize
        ref_bw = np.array(data[REF_BW].values, dtype=float)

        # Set reference bandwidth
        for i, indx in enumerate(data.index):
            #        if ref_bw[i] <= 0:
            ref_bw[i] = setReferenceBandwidth(data, indx)

        # Save
        data.loc[:, (REF_BW)] = ref_bw

        # Deletet measure column
        del data[REF_BW_MEASURE]

    # Set science goal, if needed
    if SG_NAME not in data.columns:
        data[SG_NAME] = np.array([""] * data.shape[0], dtype="str")
    if SG_FULLNAME not in data.columns:
        data[SG_FULLNAME] = np.array([""] * data.shape[0], dtype="str")

    # Set PI name to blank if column is not present
    if PINAME not in data.columns:
        data[PINAME] = np.array([""] * data.shape[0], dtype="str")

    # Set moving objects
    data[MOVING_OBJECT] = (data[RA_DEG_ORIG].abs() < EPSILON) & (
        data[DEC_DEG_ORIG].abs() < EPSILON
    )

    # Set large programs
    data[IS_LP] = data[PROJECT].str.endswith(".L")

    # Set excel line
    cnst = 2
    if skiprows is not None:
        cnst += len(skiprows)
    data[EXCEL_LINE] = data.index + cnst

    # Correct the RA/DEC for sources with an RA/DEC offset. Note the offsets
    # may be given in galactic coordinates for mosaics.
    if verbose:
        print("   ... correcting centroid RA/DEC in mosaics for offsets")
    # Assume J2000 if no coordinate system provided
    j = data[MOS_COORD].isnull() == True
    data.loc[j, MOS_COORD] = "J2000"
    # Get unique coordinate sysmtes
    coord_system = data[MOS_COORD][data[MOS_COORD].notnull() == True].unique()
    # Correction depends on the coordinate system
    for cs in coord_system:
        # Find observations with this system
        j = (
            (data[MOS_COORD] == cs)
            & (data[LAT_OFFSET].notnull() == True)
            & (data[LON_OFFSET].notnull() == True)
            & ((data[LON_OFFSET].abs() > EPSILON) | (data[LAT_OFFSET].abs() > EPSILON))
        )

        # Get data
        x = data.loc[j, RA_DEG].values
        y = data.loc[j, DEC_DEG].values
        dx = data.loc[j, LON_OFFSET].values
        dy = data.loc[j, LAT_OFFSET].values

        # Correct coordinates
        if cs in ["ICRS", "icrs", "J2000"]:
            # Message
            #        if verbose:
            #           print '       --- correcting %4d positions for equatorial offsets' % len(j)

            # Correct RA/Dec
            new_ra = x + dx / 3600.0 / np.cos(y / 180.0 * np.pi)
            new_dec = y + dy / 3600.0
            pass
        elif cs == "galactic":
            # Message
            #        if verbose:
            #           print '       --- correcting %4d mosaic positions for galactic offsets' % np.sum(j)

            # Convert mosaic center to galactic coordinates
            c = SkyCoord(ra=x, dec=y, frame="icrs", unit="degree")
            glon = c.galactic.l.deg
            glat = c.galactic.b.deg

            # Add offset
            glon += dx / 3600.0 / np.cos(glat / 180.0 * np.pi)
            glat += dy / 3600.0

            # Convert back to RA/DEC
            c = SkyCoord(glon, glat, unit="degree", frame="galactic")
            new_ra = c.icrs.ra.deg
            new_dec = c.icrs.dec.deg
        else:
            raise Exception("Mosaic coordinate system not implemented: %s" % cs)

        # Check right ascension
        l = np.where(new_ra < 0)[0]
        if len(l) > 0:
            new_ra[l] += 360.0
        l = np.where(new_ra > 360)[0]
        if len(l) > 0:
            new_ra[l] -= 360.0

        # Check declination
        if np.any(np.abs(new_dec) > 90.0):
            raise Exception("Error setting mosaic declination")

        # Save
        data.loc[j, RA_DEG] = new_ra
        data.loc[j, DEC_DEG] = new_dec

    # Convert RA/DEC to degree in sky coordinates
    #  if verbose:
    #     print '   ... converting RA/DEC to astropy sky coordinates'
    #   coords = SkyCoord(data[RA_DEG]*astropy.units.degree,
    #                    data[DEC_DEG]*astropy.units.degree, frame='icrs')

    # Set the velocity unit.
    # Until now, all SG have used km/s as the velocity unit. but I want to keep
    # the flexibility in case m/s is used in the future.
    if VELOCITY_UNIT in data.columns:
        nrows = data[data[VELOCITY_UNIT] != "km/s"].shape[0]
        if nrows > 0:
            raise Exception("%d values for velocity unit are not km/s" % nrows)
    else:
        data[VELOCITY_UNIT] = np.array(["km/s"] * data.shape[0])

    # Compute redshift
    if verbose:
        print("   ... computing redshifts")
    redshifts = np.zeros(data.shape[0])
    for i, indx in enumerate(data.index):
        redshifts[i] = computeZ(data, indx)

    # Correct frequencies for spectral windows that have rest frequencies
    if verbose:
        print("   ... correcting rest frequencies to sky frequencies")
    jndx = dict()
    znu = dict()
    for w in list(SPW_FREQ.keys()):
        jndx[w] = []
        znu[w] = []
    for i, indx in enumerate(data.index):
        # Determine if windows are doppler corrected or not
        if data[IS_SPECTRAL][indx]:
            if data[IS_SPW_SKY_FREQ][indx] == True:
                pass
            else:
                raise Exception(
                    "Not expecting spectral scan to be rest frequencies: excel line = %d"
                    % getExcelFromIndex(indx, check=False)
                )
        else:
            # Determine if windows are sky or rest frequencies
            if data[IS_SPW_SKY_FREQ][indx] not in [0, 1, False, True]:
                raise Exception(
                    "Error reading IS_SPW_SKY_FREQ on row %d" % getExcelFromIndex(indx)
                )
            elif not data[IS_SPW_SKY_FREQ][indx]:
                # Loop over windows and correct frequencies
                for w in list(SPW_FREQ.keys()):
                    nu = data[SPW_FREQ[w]][indx]
                    if np.isfinite(nu):
                        jndx[w].append(indx)
                        znu[w].append(nu / (1.0 + redshifts[i]))
    for w in list(SPW_FREQ.keys()):
        if len(jndx[w]) > 0:
            data.loc[jndx[w], (SPW_FREQ[w])] = znu[w]

    # Since all windows are now doppler corrected, delete IS_SKY_FREQ columns
    del data[IS_SPW_SKY_FREQ]

    # If reference frequency is zero, then set it to the mean frequency
    # This has to be done AFTER correcting the frequencies for the doppler shift
    j = data[REF_FREQ] == 0
    n = np.sum(j)
    if n > 0:
        # Print message
        print("   ... correcting reference frequency on %d rows" % n)

        # Make sure required variables are present
        meanfreq = np.zeros(n)
        for i, indx in enumerate(j[j == True].index):
            meanfreq[i] = computeMeanFrequency(data, indx)
        data.loc[j, REF_FREQ] = meanfreq

    # Get FWHM primary beam size
    mask = (data[REF_FREQ] < 0) | (data[REF_FREQ].isnull())
    if mask.sum() > 0:
        raise Exception("Invalid frequencies in spreadsheet")
    diameter = np.array([12.0] * data.shape[0])
    j = np.where((data[IS_ACA_STANDALONE] == True))
    diameter[j] = 7.0
    data[FWHM_PB] = fwhmPB(data[REF_FREQ], diameter)

    # Set largest size in arcseconds, including mosaics and FWHM of primary beam
    data[MAX_SIZE] = data[[MOS_LENGTH, MOS_WIDTH, FWHM_PB]].max(axis=1)

    # If IS_MOSAIC is all nan, then convert it to string
    n = data[IS_MOSAIC][data[IS_MOSAIC].notnull()].count()
    if n == 0:
        data[IS_MOSAIC] = np.array(["N/A"] * data.shape[0])

    # Compute mosaic area
    if verbose:
        print("   ... computing area of rectangular mosaics")
    data[MOS_AREA] = np.zeros_like(data[MOS_LENGTH])
    mask = data[IS_MOSAIC] == MOSAIC_TYPE_RECTANGLE
    if mask.sum() > 0:
        print("   ... computing mosaic area of %d mosaics" % mask.sum())
        data.loc[mask, MOS_AREA] = (
            data[MOS_LENGTH][mask] * data[MOS_WIDTH][mask] / 3600.0
        )

    # Get source name with mosaic extension for custom mosaics
    names = [""] * data.shape[0]
    for j, indx in enumerate(data.index):
        # Get target name in spread sheet
        name = str(data[TARGET][indx])

        # Is this a custom mosaic?
        if isObsMosaic(data, indx, mtype=MOSAIC_TYPE_CUSTOM) and name.find("None") != 0:
            # Get name without the extension
            i = name.rfind("-")
            #  if i <= 0 or not name.endswith('(cm)'):
            #     raise Exception('Unknown extension for custom mosaic: %s on row %d' % (name, getExcelFromIndex(indx)))
            name = name[:i]

        # Save
        names[j] = name
    data[TARGET_STRIPPED] = names

    # Correct mosaic spacings.
    # The ACA mosaic spacings are incorrect in Ignacio's spreadsheet. I do not
    # the correct spacing, so this is potentially in error.
    if correctMosaicSpacing:
        # Message
        print("   ... correcting ACA mosaic spacings")

        # Select data
        mask = (data[IS_MOSAIC] == MOSAIC_TYPE_RECTANGLE) & (data[IS_ACA_STANDALONE])

        # Correct
        data.loc[mask, MOS_SPACING] = 0.51093 * data[FWHM_PB][mask]

    # Modify ephemeris names
    if verbose:
        print("   ... checking names for ephemeris sources")
    checkEphemerisNames(data)

    # Message
    if verbose:
        print("   ... done")

    # Run checks on the data structure
    checkData(data, verbose=verbose, spreadsheet=input)

    # Done
    #  return {DATA: data, COORDS:coords}
    return data


def computeAggregateBandwidth(data, indx):
    """Compute aggregate bandwidth in MHz for indx"""
    if data[IS_SPECTRAL][indx]:
        sps_bw = getUsableBandwidth(data[SPS_BW][indx])
        nwin = 4
        aggregateBandwidth = nwin * sps_bw
    else:
        # Get frequencies of each window
        windows = list(SPW_FREQ.keys())
        nu1 = np.zeros(len(windows))
        nu2 = np.zeros(len(windows))
        use = np.zeros(len(windows), dtype=bool)
        for i, w in enumerate(windows):
            # Is this a valid window?
            if np.isnan(data[SPW_FREQ[w]][indx]):
                use[i] = False
                continue
            freq = data[SPW_FREQ[w]][indx] * 1e3
            bw = getUsableBandwidth(data[SPW_BW[w]][indx])
            use[i] = True
            f1 = freq - 0.5 * bw
            f2 = freq + 0.5 * bw
            nu1[i] = min([f1, f2])
            nu2[i] = max([f1, f2])

        # Sort by decreasing bandwidth
        j = np.where(nu1 > 0)
        nu1 = nu1[j]
        nu2 = nu2[j]
        bw = nu2 - nu1
        iarg = np.argsort(bw)[::-1]
        nu1 = nu1[iarg]
        nu2 = nu2[iarg]

        # Compute aggregate bandwidth
        j = np.where(nu1 > 0)
        for iw in range(nu1.size):
            # Skip if not using the window
            if not use[iw]:
                continue

            # Check other windows
            for jw in range(iw + 1, nu1.size):
                # Skip name windows
                if not use[jw]:
                    continue

                # Check frequency range
                if nu1[jw] >= nu1[iw] and nu2[jw] <= nu2[iw]:
                    nu1[jw] = 0
                    nu2[jw] = 0
                    use[jw] = False
                elif nu1[jw] < nu1[iw] and nu2[jw] > nu2[iw]:
                    raise Exception(
                        "Should not be here since I sorted by decreasing bandwidth. Indx=%d"
                        % indx
                    )
                elif nu1[jw] >= nu2[iw] or nu2[jw] <= nu1[iw]:
                    pass
                elif nu1[jw] >= nu1[iw] and nu1[jw] <= nu2[iw] and nu2[jw] >= nu2[iw]:
                    nu1[jw] = nu2[iw]
                    if nu1[jw] == nu2[jw]:
                        use[jw] = False
                    elif nu2[jw] < nu1[jw]:
                        raise Exception("Error setting bandwidth")
                elif nu1[jw] <= nu1[iw] and nu2[jw] >= nu1[iw] and nu2[jw] <= nu2[iw]:
                    nu2[jw] = nu1[iw]
                    if nu1[jw] == nu2[jw]:
                        use[jw] = False
                    elif nu2[jw] < nu1[jw]:
                        raise Exception("Error setting bandwidth")
                else:
                    raise Exception("Error computing aggregate bandwidth")

        # Compute aggregate bandwidth in MHz
        aggregateBandwidth = np.sum(nu2 - nu1)

    # Done
    return aggregateBandwidth


def computeContinuumSensitivity(data, indx):
    """
    Compute the continuum sensitivity for a single pointing

    For spectral scans, the sensitivity is computed for one tuning,
    not the full spectral scan.

    Inputs:
    data : data structure from readObservations()
    indx : Index number in data

    Output:
    A dictionary with the following keys:
    'bw'    : aggregrate bandwidth in MHz
    'rmsmJy': continuum rms in mJy
    'rmsmK' : continuum rms in mK for the specified angular
                resolution and reference refrequencin data
    """

    # Gather sensitivity information
    req_sen = data[REQ_SENS][indx]
    ref_freq = data[REF_FREQ][indx]
    ref_bw = data[REF_BW][indx]
    angres = data[ANG_RES][indx]

    # Compute bandwidth if single tuning or spectral scans
    aggregateBandwidth = computeAggregateBandwidth(data, indx)

    # Compute continuum sensitivity
    if ref_bw <= 0 or aggregateBandwidth <= 0:
        rmsContinuum = np.nan
        raise Exception("Error computing continuum sensitivity")
    else:
        rmsContinuum_mJy = req_sen * np.sqrt(ref_bw / aggregateBandwidth)
        rmsContinuum_mK = rmsContinuum_mJy / mjypermk(ref_freq, angres, ref_freq)

    # Done
    return {
        "bw": aggregateBandwidth,
        "rmsmJy": rmsContinuum_mJy,
        "rmsmK": rmsContinuum_mK,
    }


def printRowProject(data, indx, spaces=""):
    """
    Print project information for entry indx in "data".
    """

    # Functions to print data
    def printEntries(obsdata, indx, items):
        for key in items:
            a = OBS_ITEMS[key]
            printEntry(obsdata, indx, key, a[0], a[1])

    def printEntry(obsdata, indx, key, label, unit):
        # Set unit
        sunit = unit
        if sunit is None:
            sunit = ""

        # Set value - there must be a better way!
        if key in [FWHM_PB, REF_FREQ, REF_BW]:
            svalue = "%.1f" % obsdata[key][indx]
        elif key in [MOS_LENGTH, MOS_WIDTH]:
            svalue = "%.1f" % obsdata[key][indx]
        elif key in [MOS_AREA]:
            svalue = "%.3f" % obsdata[key][indx]
        elif key in [REQ_SENS, ANG_RES]:
            svalue = "%.3f" % obsdata[key][indx]
        elif key in [RA_DEG]:
            svalue = convertHmsString(obsdata[key][indx] / 15.0, delimiter=":", ndec=2)
        elif key in [DEC_DEG]:
            svalue = convertHmsString(obsdata[key][indx], delimiter=":", ndec=2)
        elif key == BAND:
            svalue = "%d" % getBandNumber(obsdata[key][indx])
        elif key in [POLARIZATION]:
            svalue = obsdata[key][indx].lower()
        elif key in [MOS_SPACING]:
            svalue = "%.1f" % obsdata[key][indx]
        elif key == IS_MOSAIC:
            if isObsMosaic(obsdata, indx, mtype=MOSAIC_TYPE_RECTANGLE):
                svalue = "Rectangular mosaic"
            elif isObsMosaic(obsdata, indx, mtype=MOSAIC_TYPE_CUSTOM):
                svalue = "Custom mosaic"
            elif obsdata[IS_MOSAIC][indx] == "N/A" or np.isnan(
                obsdata[IS_MOSAIC][indx]
            ):
                svalue = "False"
            else:
                raise Exception("Unknown type of mosaic: %s" % data[IS_MOSAIC][indx])
        else:
            svalue = "%s" % obsdata[key][indx]

        # Print item
        print("%s%-22s  %-13s  %s" % (spaces, label, svalue, sunit))

    # Add some space
    print("")
    print("")

    # Print header
    lineExcel = getExcelFromIndex(indx)
    print(
        "%sSource information for spreadsheet line %d (project = %s)"
        % (spaces, lineExcel, data[PROJECT][indx])
    )

    # Print source information
    items = [
        TARGET,
        RA_DEG,
        DEC_DEG,
    ]
    printEntries(data, indx, items)

    # Print observing parameters
    print("")
    print("%sObserving parameters" % spaces)
    items = [
        BAND,
        FWHM_PB,
        ANG_RES,
        LAS,
    ]
    printEntries(data, indx, items)

    # Print observing modes
    print("")
    print("%sObserving modes" % spaces)
    items = [POLARIZATION, USE_7M, USE_TPA, IS_SPECTRAL]
    printEntries(data, indx, items)

    # Print mosaic information
    print("")
    print("%sMosaic information" % spaces)
    items = [IS_MOSAIC]
    if isObsMosaic(data, indx, mtype=MOSAIC_TYPE_RECTANGLE):
        items.extend([MOS_AREA, MOS_LENGTH, MOS_WIDTH, MOS_PA, MOS_SPACING, MOS_COORD])
    printEntries(data, indx, items)

    # Print continuum sensitivity if not a spectral scan
    if not data[IS_SPECTRAL][indx]:
        print("")
        print("%sEstimated continuum sensitivity" % spaces)
        result = computeContinuumSensitivity(data, indx)
        print(
            "%s%-22s  %-13.0f  %s"
            % (spaces, "Aggregate bandwidth", result["bw"], "MHz")
        )
        if np.isnan(result["rmsmJy"]):
            print("%s%-22s  %-13s    %s" % (spaces, "Continuum RMS", "N/A", ""))
        else:
            print(
                "%s%-22s  %-13.3f  %s"
                % (spaces, "Continuum RMS", result["rmsmJy"], "mJy")
            )
            print(
                "%s%-22s  %-13.1f  %s"
                % (spaces, "Continuum RMS", result["rmsmK"], "mK")
            )


def mjypermk(freq_ghz, angres_ref, freq_ref_ghz):
    """
    Returns the conversion factor to convert mK to mJy.
    """

    omega_ref = np.pi / 4.0 / np.log(2.0) * (angres_ref / 3600.0 / 180.0 * np.pi) ** 2
    omega = omega_ref * (freq_ref_ghz / freq_ghz) ** 2
    wavelength = G.c.value / (freq_ghz * 1e9)
    constant = 2.0 * G.k_B.value * 1e-3 / wavelength**2 * omega * 1e29

    return constant


def printRowCorrelator(data, indx, spaces=""):
    """
    Print correlator configuration for a row entry observations.
    The output will be different for spectral scans and single tunings.

    Inputs:
        data   : Contains data from readObservations() in the DATA key
        indx   : Entry number in observations
        spaces : Spaces to format output
    """

    if data[IS_SPECTRAL][indx]:
        printRowCorrelatorSpectralScan(data, indx, spaces=spaces)
    else:
        printRowCorrelatorSingle(data, indx, spaces=spaces)


def printRowCorrelatorSingle(data, indx, spaces=""):
    """
    Print project information for entry indx in "observations" if it
    is a single tuning.

    Inputs:
        data   : Contains data from readObservations() in the DATA key
        indx   : Entry number in observations
        spaces : Spaces to format output
    """

    # Add some space
    print("")

    # Print header
    lineExcel = getExcelFromIndex(indx)
    print("%sCorrelator setup" % spaces)

    # Print header
    print(
        "%s%3s %8s  %17s  %19s  %17s  %17s"
        % (
            spaces,
            "Win",
            "Sky Freq",
            "Usable Bandwidth",
            "Spectral resolution",
            "RMS/bandwidth",
            "RMS/resolution",
        )
    )
    print(
        "%s%3s %8s  %17s  %19s  %17s  %17s"
        % (
            spaces,
            "---",
            "--------",
            "-----------------",
            "-------------------",
            "----------------",
            "-----------------",
        )
    )
    print(
        "%s%3s %8s  %8s %8s  %9s %9s  %8s %8s  %8s %8s"
        % (
            spaces,
            "",
            "(GHz)",
            "(MHz)",
            "(km/s)",
            "(MHz)",
            "(km/s)",
            "mJy",
            "mK",
            "mJy",
            "K",
        )
    )

    # Gather sensitivity information
    req_sen = data[REQ_SENS][indx]
    ref_freq = data[REF_FREQ][indx]
    ref_bw = data[REF_BW][indx]

    # Get frequencies
    windows = list(SPW_FREQ.keys())
    winfreq = []
    winnumber = []
    for w in windows:
        if np.isfinite(data[SPW_FREQ[w]][indx]):
            winnumber.append(w)
            winfreq.append(data[SPW_FREQ[w]][indx])

    # Sort by frequency
    iarg = np.argsort(winfreq)
    winfreq = np.array(winfreq)[iarg]
    winnumber = np.array(winnumber)[iarg]

    # Loop over windows
    for w in winnumber:
        # Is this a valid window?
        if np.isnan(data[SPW_FREQ[w]][indx]):
            continue

        # Gather spectral window information
        freq = data[SPW_FREQ[w]][indx]
        bw = getUsableBandwidth(data[SPW_BW[w]][indx])
        res = data[SPW_RES[w]][indx]

        # Print window
        print("%s%3d" % (spaces, w), end=" ")

        # Frequency
        print("%8.3f" % freq, end=" ")

        # Bandwidth in MHz
        print(" %8.1f" % bw, end=" ")

        # Bandwidth in km/s
        bw_kms = bw * 1e-3 / freq * G.c.value / 1e3
        print("%8.1f" % bw_kms, end=" ")

        # Channel resolution in MHz
        print(" %9.3f" % res, end=" ")

        # Channel resolution in km/s
        res_kms = res * 1e-3 / freq * G.c.value / 1e3
        print("%9.3f" % res_kms, end=" ")

        # Sensitivity per bandwidth and channel
        if ref_bw == 0:
            srms_bw_mjy = "%8s" % "N/A"
            srms_bw_mk = "%8s" % "N/A"
            srms_res_mjy = "%8s" % "N/A"
            srms_res_k = "%8s" % "N/A"
        else:
            # In mJy
            rms_bandwidth_mJy = req_sen * np.sqrt(ref_bw / bw)
            rms_resolution_mJy = req_sen * np.sqrt(ref_bw / res)

            # In mK
            angres = data[ANG_RES][indx]
            rms_bandwidth_mK = rms_bandwidth_mJy / mjypermk(freq, angres, ref_freq)
            rms_resolution_K = (
                rms_resolution_mJy / mjypermk(freq, angres, ref_freq) / 1e3
            )

            # Save as strings
            srms_bw_mjy = "%8.3f" % rms_bandwidth_mJy
            srms_bw_mk = "%8.3f" % rms_bandwidth_mK
            srms_res_mjy = "%8.3f" % rms_resolution_mJy
            srms_res_k = "%8.3f" % rms_resolution_K
        print(" %s" % srms_bw_mjy, end=" ")
        print("%s" % srms_bw_mk, end=" ")
        print(" %s" % srms_res_mjy, end=" ")
        print("%s" % srms_res_k, end=" ")

        # Done width entry
        print("")


def printRowCorrelatorSpectralScan(data, indx, spaces=""):
    """
    Print project information for entry indx in "observations"
    that is a spectral scan.

    Inputs:
        data   : Contains data from readObservations() in the DATA key
        indx   : Entry number in data
        spaces : Spaces to format output
    """

    # Add some space
    print("")

    # Print header
    lineExcel = getExcelFromIndex(indx)
    print("%sCorrelator information for spectral scan" % spaces)

    # Gather sensitivity information
    req_sen = data[REQ_SENS][indx]
    ref_freq = data[REF_FREQ][indx]
    ref_bw = data[REF_BW][indx]

    # Get spectral window information
    sps_start = data[SPS_START][indx]
    sps_end = data[SPS_END][indx]
    sps_bw = getUsableBandwidth(data[SPS_BW][indx])
    sps_res = data[SPS_SPW_RES][indx]

    # Compute quantities
    sps_res_kms = sps_res / 1e3 / sps_start * G.c.value / 1e3

    # Sensitivity per bandwidth and resolution element
    if ref_bw == 0:
        srms_bw_mjy = "%8s" % "N/A"
        srms_bw_mk = "%8s" % "N/A"
        srms_res_mjy = "%8s" % "N/A"
        srms_res_k = "%8s" % "N/A"
    else:
        # In mJy
        rms_bandwidth_mJy = req_sen * np.sqrt(ref_bw / sps_bw)
        rms_resolution_mJy = req_sen * np.sqrt(ref_bw / sps_res)

        # In mK
        angres = data[ANG_RES][indx]
        rms_bandwidth_mK = rms_bandwidth_mJy / mjypermk(sps_start, angres, ref_freq)
        rms_resolution_K = (
            rms_resolution_mJy / mjypermk(sps_start, angres, ref_freq) / 1e3
        )

        # Save as strings
        srms_bw_mjy = "%8.2f" % rms_bandwidth_mJy
        srms_bw_mk = "%8.2f" % rms_bandwidth_mK
        srms_res_mjy = "%8.3f" % rms_resolution_mJy
        srms_res_k = "%8.3f" % rms_resolution_K

    # Print spectral scale information
    print("%sStarting frequency          : %8.3f GHz" % (spaces, sps_start))
    print("%sEnding   frequency          : %8.3f GHz" % (spaces, sps_end))
    print("%sUsable bandwidth per window : %8.3f MHz" % (spaces, sps_bw))
    print("%sSpectral resolution         : %8.3f MHz" % (spaces, sps_res))
    print(
        "%sSpectral resolution         : %8.3f km/s @ %.1f GHz"
        % (spaces, sps_res_kms, sps_start)
    )
    print("%sRMS per spectral resolution : %s mJy" % (spaces, srms_res_mjy))
    print("%sRMS per spectral resolution : %s K  " % (spaces, srms_res_k))


def isObsMosaic(data, indx, mtype=MOSAIC_TYPES):
    """
    Returns True or False if an observation is a mosaic of a
    type listed in MOSAIC_TYPES.

    data   : Contains data from readObservations() in the DATA key
    indx   : Row number within data
    mtype  : A list of mosaic types

    The type of mosaics are currently MOSAIC_TYPE_CUSTOM and
    MOSAIC_TYPE_RECTANGLE.
    """
    ismosaic = False
    if data[IS_MOSAIC][indx] in MOSAIC_TYPES:
        ismosaic = data[IS_MOSAIC][indx] in makeList(mtype)
    elif data[IS_MOSAIC][indx] == "N/A" or np.isnan(data[IS_MOSAIC][indx]):
        ismosaic = False
    else:
        raise Exception(
            "Unexpected value of IS_MOSAIC on row %d" % getExcelFromIndex(indx)
        )
    return ismosaic


def printSummaryHeader(label, spaces=""):
    """
    Print header for summary table.

    Inputs:
        label  : label to print in the header row
        spaces : spaces used for formatting
    """
    print("")
    print("Summary information for %s" % label)
    print(
        "%s%6s %6s  %-14s %-23s %12s %12s  %8s  %8s  %8s  %7s %7s %4s %4s %5s"
        % (
            spaces,
            "N",
            "Excel",
            "Project code",
            "Target name",
            "RA",
            "Dec",
            "Sky Freq",
            "Ang.Res.",
            "L.A.S.",
            "Polar-",
            "MosArea",
            " 7m?",
            "TPA?",
            "Spec.",
        )
    )
    print(
        "%s%6s %6s  %-14s %-23s %12s %12s  %8s  %8s  %8s  %7s %7s %4s %4s %5s"
        % (
            spaces,
            "",
            "row",
            "",
            "",
            "J2000",
            "J2000",
            "(GHz)",
            "(arcsec)",
            "(arcsec)",
            "ization",
            "amin^2",
            "",
            "",
            "Scan?",
        )
    )


def printSummarySource(number, observations, indx, spaces=""):
    """
    Print summary information for sources with indx of the observations.

    Inputs:
        number       : Index to keep track of number of sources
        observations : The observations data from readObservations()
        indx         : The index number if observations

    Output:
        A printed summary of the sources
    """
    project = observations[DATA][PROJECT][indx]
    target = observations[DATA][TARGET][indx]
    sra = convertHmsString(
        float(observations[DATA][RA_DEG][indx] / 15.0), ndec=1, delimiter="hms"
    )
    sdec = convertHmsString(
        float(observations[DATA][DEC_DEG][indx]), ndec=1, delimiter="dms"
    )
    #  scoords    = observations[COORDS][indx].to_string(style='hmsdms', precision=1).split()
    #  sra        = scoords[0]
    #  sdec       = scoords[1]
    sfreq = "%.1f" % observations[DATA][REF_FREQ][indx]
    sangular = "%.2f" % observations[DATA][ANG_RES][indx]
    slas = "%.1f" % observations[DATA][LAS][indx]
    smosaic = "-"
    saca = "-"
    saca = "-"
    stpa = "-"
    sspectral = "-"
    spol = observations[DATA][POLARIZATION][indx].lower()
    if observations[DATA][IS_SPECTRAL][indx]:
        sspectral = "Yes"
    if isObsMosaic(observations[DATA], indx):
        if isObsMosaic(observations[DATA], indx, mtype=MOSAIC_TYPE_RECTANGLE):
            smosaic = "%.1f" % observations[DATA][MOS_AREA][indx]
        else:
            smosaic = "custom"
    if observations[DATA][USE_7M][indx] == True:
        saca = "Yes"
    if observations[DATA][USE_TPA][indx] == True:
        sata = "Yes"
    print(
        "%s%6d %6d  %-14s %-23s %12s %12s  %8s  %8s  %8s  %7s %7s %4s %4s %5s"
        % (
            spaces,
            number,
            getExcelFromIndex(indx),
            project,
            target,
            sra,
            sdec,
            sfreq,
            sangular,
            slas,
            spol,
            smosaic,
            saca,
            stpa,
            sspectral,
        )
    )


def getMosaicCorners(
    ra_mosaic, dec_mosaic, width, height, angle, frame, center=None, indx=None
):
    """
    Returns the corners of the mosaic in RA/Dec offsets from the mosaic
    center. The function takes into account if the mosaic is specified
    in galactic coordinates.

    Inputs
        angle      : position angle of the rectangle in degrees.
        center     : The center of the plot in RA/Dec coordinates in degrees.
                    Enter as a tuple. e.g., center=[180.0, -10.0]
        dec_mosaic : Declination center of the mosaic
        frame      : coordinate frame of the mosaic (J2000, icrs, or galactic)
        heighto    : height of the rectangle in arcseconds. Note this
                    corresponds to "width" in the spreadsheet.
        ra_mosaic  : RA center of the mosaic
        widtho     : width of the rectangle in arcseconds. Note this
                    corresponds to "height" in the spreadsheet.

    Output:
        A dictionary containing the RA/Dec offsets of the corners of the
        mosaic relative to coords. If center, is none, then the mosaic
        center is used. The dictionary keywords are UL, UR, BL, and BR.
    """

    # Get the center of the mosaic in the native frame
    if frame in ["J2000", "icrs", "ICRS"]:
        xcen_mosaic = ra_mosaic
        ycen_mosaic = dec_mosaic
    elif frame.lower() == "galactic":
        # Convert to ra/dec
        c = SkyCoord(ra=ra_mosaic, dec=dec_mosaic, frame="icrs", unit="degree")
        xcen_mosaic = c.galactic.l.deg
        ycen_mosaic = c.galactic.b.deg
    else:
        msg = "Unknown mosaic frame (%s)" % (frame)
        if indx is not None:
            msg += " on row %d" % (getExcelFromIndex(indx))
        raise Exception(msg)

    # Compute vertices relative to mosaic center
    x = 0
    y = 0
    A = -angle / 180.0 * np.pi
    results = dict()
    results["UL"] = (
        x + (width / 2.0) * np.cos(A) - (height / 2.0) * np.sin(A),
        y + (height / 2.0) * np.cos(A) + (width / 2.0) * np.sin(A),
    )
    results["UR"] = (
        x - (width / 2.0) * np.cos(A) - (height / 2.0) * np.sin(A),
        y + (height / 2.0) * np.cos(A) - (width / 2.0) * np.sin(A),
    )
    results["BL"] = (
        x + (width / 2.0) * np.cos(A) + (height / 2.0) * np.sin(A),
        y - (height / 2.0) * np.cos(A) + (width / 2.0) * np.sin(A),
    )
    results["BR"] = (
        x - (width / 2.0) * np.cos(A) + (height / 2.0) * np.sin(A),
        y - (height / 2.0) * np.cos(A) - (width / 2.0) * np.sin(A),
    )

    # Set plot center.
    # The plot center is either specified in center, or if center=None,
    # the plot center is the mosaic itself.
    ra_center = ra_mosaic
    dec_center = dec_mosaic
    if center is not None:
        ra_center = center[0]
        dec_center = center[1]

    # Set mosaic offsets in arcseconds relative to center
    corners = dict()
    for key, c in results.items():
        # Add offsets to central coordinates in native frame of the mosaic
        xcorner = xcen_mosaic + c[0] / 3600.0 / np.cos(ycen_mosaic / 180.0 * np.pi)
        ycorner = ycen_mosaic + c[1] / 3600.0

        # Check limits on x
        if xcorner < 0:
            xcorner += 360.0
        if xcorner > 360:
            xcorner -= 360.0

        # Check limits on y
        if abs(ycorner) > 90.0:
            msg = "Error setting y-axis mosaic"
            if indx is not None:
                msg += " on row %d" % getExcelFromIndex(indx)
            raise Exception(msg)

        # Convert corner coordinates to ra/dec, if needed
        if frame.lower() == "galactic":
            c = SkyCoord(xcorner, ycorner, frame=frame, unit="degree")
            xcorner = c.icrs.ra.deg
            ycorner = c.icrs.dec.deg

        # Compute offsets relative to mosaic center
        dra = xcorner - ra_center
        ddec = (ycorner - dec_center) * 3600.0
        if abs(dra) > 180.0:
            # we are on either side of RA=0
            if dra > 0:
                dra -= 360.0
            else:
                dra += 360.0
        dalp = dra * 3600.0 * np.cos(dec_center / 180.0 * np.pi)

        # Save corner
        corners[key] = (dalp, ddec)

    # Done
    return corners


def plotSources(
    coords,
    observations,
    diameter=12,
    include=None,
    exclude=None,
    mosaic=False,
    width=60,
    length=60,
    pa=0.0,
    mframe="icrs",
    freq=345.0,
    mosonly=False,
    plotroot="source",
    plot_size=120.0,
    plottype="pdf",
    show_plot=True,
):
    """
    Plot observations that are nearby the specified coordinates.

    Inputs:
        coords       : proposed coordinates from the user set by the
                        function getSourceCoordinates()
        diameter     : diameter of the telescope in meters
        exclude      : if set, it contains a list of spreadsheet row numbers
                        that should not be plotted.
        freq         : proposed observing frequency in GHz
        include      : if set, it contains a list of spreadsheet row
                        numbers that can be plotted if all other criteria
                        are set.
        length       : length of the mosaic in arcseconds
        mframe       : coordinate system of the mosaic (icrs or galactic)
        mosaic       : if True, the proposed observations are a mosaic
        mosonly      : if True, plot/print mosaic observations only
        observations : existing observations from readObservations()
        pa           : position angle of the mosaic in degrees
        plotroot     : root name for the file containing the plot. The root
                        name will be appended with a plot number, which is
                        useful when plotting.
                        multiple sources) and the plottype.
        plot_size    : plot size in arcseconds
        plottype     : Type of plot to generate. The plot type must be
                        supported by your version of python. "pdf" and "png"
                        are usually safe. If plottype=None, then no plot is
                        saved.
                        Default is "pdf".
        show_plot    : if True, show each generated plot if a GUI backend for matplotlib
                        is selected. If False, only a file with the type selected in plottype
                        is generated.
        width        : width of the mosaic in arcseconds
    """
    # Set spacing for formatting purposes
    spaces = ""

    # Make a plot for each source
    for i in range(coords[ORIGINAL].size):
        # Initialize plot
        py.figure(i + 1, figsize=(9, 9))
        py.clf()
        ax = py.subplot(1, 1, 1, aspect="equal")

        # Set plot width
        plotsize = coords[PLOTSIZE][i]
        if plotsize is None:
            plotsize = plot_size

        # Find sources that overlap
        if coords[NOCOORD][i]:
            result = observations[DATA][TARGET][
                observations[DATA][TARGET].str.lower() == coords[ORIGINAL][i].lower()
            ]
        else:
            # Compute separation in degrees
            sep = coords[COORDS][i].separation(observations[COORDS])
            sepArcsec = sep.arcsec

            # Find entries within plot size
            separationArcsec = sepArcsec - 0.5 * observations[DATA][MAX_SIZE]
            result = separationArcsec[separationArcsec <= (0.5 * plotsize)]
        jindices = result.index

        # Print summary of observation
        if len(jindices) == 0:
            sra = convertHmsString(
                float(coords[COORDS][i].ra.deg / 15.0), ndec=1, delimiter="hms"
            )
            sdec = convertHmsString(
                float(coords[COORDS][i].dec.deg), ndec=1, delimiter="dms"
            )
            print("")
            print("Summary information for %s" % coords[ORIGINAL][i])
            print(
                "    No observations found within %g x %g arcsec region centered around (ra, dec) = (%s, %s) J2000"
                % (plotsize, plotsize, sra, sdec)
            )
        else:
            printSummaryHeader(
                "%g x %g arcsec region around %s"
                % (plotsize, plotsize, coords[ORIGINAL][i]),
                spaces=spaces,
            )

        # Set limits based on plotsize
        ax.set_xlim(0.5 * plotsize, -0.5 * plotsize)
        ax.set_ylim(-0.5 * plotsize, 0.5 * plotsize)
        ax.set_xlabel(r"$\Delta\alpha\ \ \mathrm{(arcsec)}$")
        ax.set_ylabel(r"$\Delta\delta\ \ \mathrm{(arcsec)}$")

        # Get row lists to include/exclude
        rows_include = makeList(include)
        rows_exclude = makeList(exclude)

        # Set plot center.
        # Sources will be plotted in offsets relative to this coordinate.
        ra_center = coords[COORDS][i].ra.deg
        dec_center = coords[COORDS][i].dec.deg

        # Loop over observations which overlap the field
        legend_symbols = []
        legend_labels = []
        number = 0
        for indx in jindices:
            # Get excel line
            excelRow = getExcelFromIndex(indx)
            if rows_include is not None and excelRow not in rows_include:
                continue
            if rows_exclude is not None and excelRow in rows_exclude:
                continue

            # If not mosaic and mosonly=True, then skip
            if not isObsMosaic(observations[DATA], indx) and mosonly:
                continue

            # Get ra and dec offset from nominal position in arcseconds
            if coords[NOCOORD][i]:
                dalp = 0
                ddec = 0
            else:
                # Compute offset
                ddec = (observations[DATA][DEC_DEG][indx] - dec_center) * 3600.0
                dra = observations[DATA][RA_DEG][indx] - ra_center
                if abs(dra) > 180.0:
                    # we are on either side of RA=0
                    if dra > 0:
                        dra -= 360.0
                    else:
                        dra += 360.0
                dalp = dra * 3600.0 * np.cos(dec_center / 180.0 * np.pi)

            # Set center as offset coordinates
            xy = (dalp, ddec)

            # Print summary of observation
            number += 1
            printSummarySource(number, observations, indx, spaces=spaces)

            # Set plot color based on band
            color = getBandColor(observations[DATA][REF_FREQ][indx])

            # Plot mosaic or single pointing
            label = "N = %d" % number
            if isObsMosaic(observations[DATA], indx, mtype=MOSAIC_TYPE_RECTANGLE):
                # Get the coordinates of the rectangle in RA/DEC offset units
                mosaicCorners = getMosaicCorners(
                    observations[DATA][RA_DEG][indx],
                    observations[DATA][DEC_DEG][indx],
                    observations[DATA][MOS_LENGTH][indx],
                    observations[DATA][MOS_WIDTH][indx],
                    observations[DATA][MOS_PA][indx],
                    observations[DATA][MOS_COORD][indx],
                    center=[ra_center, dec_center],
                )
                result = plotMosaic(
                    ax, mosaicCorners, fc=color, ec=color, hatch=None, alpha=0.1
                )
            else:
                result = plotPrimaryBeam(
                    ax,
                    xy,
                    observations[DATA][REF_FREQ][indx],
                    diameter,
                    fc="None",
                    ec=color,
                )
            legend_symbols.append(result)
            legend_labels.append(label)

        # Loop over observations which overlap the field
        #     color = getBandColor(freq)
        color = "tan"
        if mosaic:
            corners = getMosaicCorners(ra_center, dec_center, length, width, pa, mframe)
            result = plotMosaic(
                ax, corners, fc=color, ec="None", alpha=0.5, linewidth=3
            )
        else:
            result = plotPrimaryBeam(
                ax, (0, 0), freq, diameter, fc=color, ec="None", alpha=0.5
            )
        legend_symbols.append(result)
        legend_labels.append("Proposed")

        # Plot title with original entry and translation
        sra = convertHmsString(
            float(coords[COORDS][i].ra.deg / 15.0), ndec=1, delimiter="hms"
        )
        sdec = convertHmsString(
            float(coords[COORDS][i].dec.deg), ndec=1, delimiter="dms"
        )
        if coords[NOCOORD][i]:
            labelTranslated = ""
        else:
            labelTranslated = "%s, %s" % (sra, sdec)
        labelOriginal = "%s" % coords[ORIGINAL][i]
        fs = 14
        #     ax.set_title(labelOriginal, loc='left', fontsize=fs)
        #     ax.set_title(labelTranslated, loc='right', fontsize=fs)
        ax.annotate(text="Entered:", xy=(0, 1.05), xycoords="axes fraction", size=fs)
        ax.annotate(
            text=labelOriginal, xy=(0.2, 1.05), xycoords="axes fraction", size=fs
        )
        ax.annotate(text="Translated:", xy=(0, 1.01), xycoords="axes fraction", size=fs)
        ax.annotate(
            text=labelTranslated, xy=(0.2, 1.01), xycoords="axes fraction", size=fs
        )
        #     py.legend(legend_symbols, legend_labels)

        # Save plot to a file

        if plottype is not None:
            # Set name
            root = plotroot
            if root is None:
                root = "source"

            # Try creating plot
            try:
                plotfile = "%s%d.%s" % (root, i + 1, plottype)
                py.savefig(plotfile)
                print("    Plot saved to %s" % plotfile)
            except:
                print("    Warning: Could not create plot %s." % plotfile)
                print("    Is that plot type supported by your python extension?")

        if show_plot:
            py.show()


def isMovingObject(data, indx):
    """
    Returns True/False that object is a moving object.
    """
    return data[RA_DEG][indx] == 0 and data[DEC_DEG][indx] == 0


def getCoordinatesFromName(name, data=None, lookup=True):
    """
    Try to get coordinates for the source name.

    First, it tries using Sesame. If that fails, then we try using
    finding the source in the observations spreadsheet.

    name   : Name of the source
    data   : Contains data from readObservations() in the DATA key
    """
    # Try resolving source name
    found = False
    if lookup:
        result = astropy.coordinates.name_resolve.get_icrs_coordinates(name)
        try:
            result = astropy.coordinates.name_resolve.get_icrs_coordinates(name)
            found = True
        except:
            pass
    if not found:
        # Initialize
        result = None

        # Is source in observation list?
        if data is not None:
            j = np.where(data[TARGET].str.lower() == name.lower())[0]
            if len(j) > 0 and not isMovingObject(data, j[0]):
                result = SkyCoord(
                    data[RA_DEG][j[0]], data[DEC_DEG][j[0]], unit="deg", frame="icrs"
                )

    # Done
    return result


def getSourceCoordinates(
    longitude,
    latitude,
    sources,
    inputFile,
    rows,
    unit="deg",
    frame="icrs",
    data=None,
    spreadsheet=None,
    lookup=True,
):
    """
    Get list of coordinates to search.

    longitude : longitude (equatorial or galactic coordinates. Examples:
                    longitude=180.0
                    longitude='180.0d'
                    longitude='15h00m00s'
                    longitude=['10h12m13s', '180.0d', 180.0]
    latitude  : longitude (equatorial or galactic coordinates. Examples:
                    latitude=-4.0
                    latitude='-4.0d'
                    latitude=['-21d11m12s', '-4d', -4.0]
    sources   : List of source names. Examples:
                    sources='DG Tau'
                    sources='DG Tau, HL Tau, DM Tau'
                    sources=['DG Tau', 'HL Tau', 'DM Tau']
    inputFile : Name of text file containing sources to search. In addition
                to source names, the file may contain commands to change
                plot sizes. The pound symbol (#) is used as a comment marker.
                Example input for the data file.
    # Specify sources to sea
    HL Tau
    GO Tau
    # Change plot size from default for remaining sources
    plotsize = 480
    # Additional source name
    NGC7496
    # Change coordinate frames and plot size
    frame = galactic
    plotsize = 60
    # Search by coordinate
    178.8590 -19.9724
    rows      : List of row numbers in excel spreadsheet
                    rows=1800
                    rows=[1800, 1850]
    unit      : Unit for longitude/latitude if they are entered as floating
                points. Most likely values are
                    a) unit='deg' , which means both longitude and latitude
                        are in degrees.
                    b) unit='hour,deg', which means longitude is in
                        hours and latitude is in degrees.
    frame     : Coordinate frame for longitude/latitude. Accepted values
                are 'icrs' for equatorial or 'galactic'.
    data      : Contains data from readObservations() in the DATA key.
                This is used to search for individual sources if
                a source name is not name resolved.
    lookup    : If True, resolve the source name using Sesame.
                lookup=False can be useful if the name listed in the
                spreadsheet is desired.
    spreadsheet: Name of the spreadsheet containing the observations.
                    This parameter is optional, and only used to
                    customize the output.

    There are four option to search:
    1) Enter list of numpy array of RA/DEC.
            The format is flexible in that RA/Dec be in string, hex, or
            decimal format. If the units are not specified, the default
            units are given by the 'unit' variable.

            For example:
            longitude=['01h05m11s', 1.32]
            latitude=['-10d05m11s', -20.0]

    2) Enter of a list of source names that will be name resolved

    3) Enter a file with coordinates/names, one entry per line
        The comment symbol per line is the pound symbol.
        The entry will name resolved first, and if that is not successful,
        it will be coordinate resolved. If that is not successful, then
        the source is skipped.

    4) Enter list of row numbers
    """

    # Initialize ra/dec list
    ra = []
    dec = []
    original = []
    iscoord = []
    nocoord = []
    plotsize = []

    # Add coordinates
    def addCoordinates(c, psize=None):
        # Save in degrees
        if c is None:
            ra.append(0)
            dec.append(0)
            nocoord.append(True)
            plotsize.append(psize)
        else:
            if type(c.icrs.ra.deg) in [list, np.ndarray]:
                zra = np.array(c.icrs.ra.deg)
            else:
                zra = np.array([c.icrs.ra.deg])
            if type(c.icrs.dec.deg) in [list, np.ndarray]:
                zdec = np.array(c.icrs.dec.deg)
            else:
                zdec = np.array([c.icrs.dec.deg])
            ra.extend(zra)
            dec.extend(zdec)
            nocoord.extend([False] * zra.size)
            plotsize.extend([psize] * zra.size)

    # Process RA/Dec
    if longitude is not None or latitude is not None:
        # Both must be set
        if longitude is None or latitude is None:
            raise Exception(
                "Both longitude/latitude must be set if you are entering coordinates"
            )

        # Convert entries into list so we can loop over them
        llon = makeList(longitude)
        llat = makeList(latitude)

        # Must have same size
        if llon.size != llat.size:
            raise Exception("longitude/latitude must have the same size")
        if llon.ndim != 1:
            raise Exception("Only 1D arrays can be processed for longitude")
        if llat.ndim != 1:
            raise Exception("Only 1D arrays can be processed for latitude")

        # Get coordinates
        c = SkyCoord(llon, llat, unit=unit, frame=frame)

        # Add coordinates
        addCoordinates(c)

        # Save original entries
        iscoord.extend([True] * c.icrs.ra.size)
        s = []
        for t in zip(llon, llat):
            s.append("%s, %s %s" % (t[0], t[1], frame))
        original.extend(s)

    # Process row numbers
    if rows is not None:
        # Convert to list
        lrows = getIndexFromExcel(makeList(rows))

        # Get coordinates
        c = SkyCoord(
            data[RA_DEG][lrows], data[DEC_DEG][lrows], unit="deg", frame="icrs"
        )

        # Add coordinates
        addCoordinates(c)

        # Save original entries
        iscoord.extend([True] * c.icrs.ra.size)
        s = []
        for t in lrows:
            ss = "Row %d (%s)" % (getExcelFromIndex(t), str(data[TARGET][t]))
            if spreadsheet is not None:
                ss += " in %s" % spreadsheet
            s.append(ss)
        original.extend(s)

    # Process source names
    if sources is not None:
        # Convert into list
        names = makeList(sources)

        # Loop over names
        for name in names:
            # Get coordinates
            c = getCoordinatesFromName(name, data=data, lookup=lookup)

            # Add to coordinate list
            if c is None:
                print(
                    "Warning: Could not find coordinates for %s. Searching by object name."
                    % name
                )
            addCoordinates(c)
            iscoord.append(False)
            original.append(name)

    # Process input file
    psize_new = None
    unit_new = unit
    frame_new = frame
    if inputFile is not None:
        # Read file one line at a time
        finp = open(inputFile, "r")

        # Process one line at a time
        nlines = 0
        for line in finp:
            # Strip leading and trailing spaces, as well as multiple spaces
            line = re.sub(" +", " ", line.strip())

            # Remove comment portion of line
            j = line.find("#")
            if j >= 0:
                line = line[0:j]

            # Skip blank lines
            if line.strip() == "":
                continue

            # Is this the plot size?
            if line.lower().find("plotsize") == 0:
                value = line.split("=")[1]
                try:
                    psize_new = float(value)
                except:
                    pass
                continue

            # Is this the unit?
            if line.lower().find("unit") == 0:
                value = line.split("=")[1]
                try:
                    unit_new = value.strip()
                except:
                    pass
                continue

            # Is this the frame?
            if line.lower().find("frame") == 0:
                value = line.split("=")[1]
                try:
                    frame_new = value.strip()
                except:
                    pass
                continue

            # First, try if parse line as coordinates
            try:
                nlines += 1
                c = SkyCoord(line, unit=unit_new, frame=frame_new)
                addCoordinates(c, psize=psize_new)
                iscoord.extend([True] * c.icrs.ra.size)
                original.extend(["%s %s" % (line, frame_new)])
            except:
                # Coordinates was not successful. Try resolving it as a name
                c = getCoordinatesFromName(line, data=data)
                if c is not None:
                    nlines += 1
                    addCoordinates(c, psize=psize_new)
                    iscoord.append(False)
                    original.append(line)
                else:
                    print(
                        "Warning: Could not find coordinates for %s. Searching by object name."
                        % line
                    )
                    addCoordinates(None, psize=psize_new)
                    iscoord.append(False)
                    original.append(line)

        # Message
        print("Read in %d sources from %s" % (nlines, inputFile))

    # Prepare results
    results = dict()
    results[COORDS] = SkyCoord(ra, dec, unit="deg")
    results[RA] = makeList(ra)
    results[DEC] = makeList(dec)
    results[ORIGINAL] = makeList(original)
    results[ISCOORD] = makeList(iscoord)
    results[NOCOORD] = makeList(nocoord)
    results[PLOTSIZE] = makeList(plotsize)

    # Check that all elements have the same size
    keys = list(results.keys())
    for i, k in enumerate(keys):
        if len(results[k]) != len(results[keys[0]]):
            raise Exception(
                "Arrays do not have the same size: %s and %s" % (keys[0], k)
            )

    # Done
    return results


def row(
    rows,
    showProject=True,
    showCorrelator=True,
    inputObs=LIST_OF_OBSERVATIONS,
    verbose=True,
    refresh=False,
):
    """
    Print detailed information about the project and correlator for a
    a line in the spreadsheet.

    Inputs:
    rows          : The row numbers in the excel spreadsheet that
                    should be printed. An integer or a list can be
                    entered.
    showProject   : If True, then print the correlator information for
                    each line
    showCorrelator: If True, then print the project information for
                    each line
    inputObs      : The name of the excel spreadsheet containing the
                    observations.
    verbose       : If True, print out messages while reading spreadsheet
    refresh       : If True, re-read the spreadsheet

    Output:
    A text listing of the project and correlator information.

    Example usage:
    import plotobs as po
    po.row(507)
    po.row([507, 508])
    """
    # Read list of previous or scheduled observations
    global OBSERVATIONS
    if OBSERVATIONS is None or refresh:
        OBSERVATIONS = readObservations(inputObs, verbose=verbose)
    observations = OBSERVATIONS

    # Loop over input excel sheet spread lines
    for excel in makeList(rows):
        # Convert to index number if observation list
        indx = getIndexFromExcel(excel)

        # Print
        if showProject:
            printRowProject(observations[DATA], indx)
        if showCorrelator:
            printRowCorrelator(observations[DATA], indx)


def project(projects, inputObs=LIST_OF_OBSERVATIONS, verbose=True, refresh=False):
    """
    Print information about a project in the spreadsheet.

    Inputs:
    projects : A string or list containing the projects to list.
    inputObs : The name of the excel spreadsheet or csv file
                containing the observations.
    verbose  : If True, print out messages while reading spreadsheet
    refresh  : If True, re-read the spreadsheet

    Output:
    A text listing of the project and correlator information.

    Example usage:
    import plotobs as po
    po.project('2018.1.00035.L')
    po.project(['2018.1.00035.L', '2018.1.00566.S'])
    """
    # Read list of previous or scheduled observations
    global OBSERVATIONS
    if OBSERVATIONS is None or refresh:
        OBSERVATIONS = readObservations(inputObs, verbose=verbose)
    observations = OBSERVATIONS

    # Loop over projects
    for p in makeList(projects):
        # Print header
        printSummaryHeader(p)

        # Find indices
        mask = observations[DATA][PROJECT].str.lower() == p.lower()
        indices = observations[DATA][PROJECT][mask].index

        # Print indices
        if len(indices) == 0:
            print("    No observations found for project %s" % p)
        else:
            for n, indx in enumerate(indices):
                printSummarySource(n + 1, observations, indx)


def test(
    projects=True,
    rows=True,
    plots=True,
    refresh=True,
    inputObs=LIST_OF_OBSERVATIONS,
    verbose=True,
):
    """
    Run through all sources and through the projects to test the scripts
    to make all sources can be processed without run-time errors.

    projects: If True, process all projects
    rows    : If True, process all rows
    plots   : Run the plot() command on each row (very slow)
    refresh : Reload spreadsheet instead of using data in memory
    """

    # Load data
    global OBSERVATIONS
    if OBSERVATIONS is None or refresh:
        OBSERVATIONS = readObservations(inputObs, verbose=verbose)
    observations = OBSERVATIONS

    # Check projects
    if projects:
        # Get unique projects
        projectList = np.unique(observations[DATA][PROJECT])

        # Run each project
        project(projectList)

    # Check rows
    if rows:
        for indx in observations[DATA].index:
            row(getExcelFromIndex(indx))

    # Plots
    if plots:
        for indx in observations[DATA].index:
            plot(rows=getExcelFromIndex(indx), show_plot=False)


def plot(
    longitude=None,
    latitude=None,
    frame="icrs",
    unit="deg",
    sources=None,
    lookup=True,
    inputFile=None,
    rows=None,
    include=None,
    exclude=None,
    freq=345.0,
    width=30.0,
    length=60,
    pa=0.0,
    mframe="icrs",
    mosaic=False,
    refresh=False,
    verbose=True,
    inputObs=LIST_OF_OBSERVATIONS,
    mosonly=False,
    plotroot="source",
    plot_size=120.0,
    plottype="pdf",
    show_plot=True,
):
    """
    Purpose:
        plot() lists and displays existing observations from cycles 8 and 9
        with grade A.

        Each observation is plotted as a single pointing with a circle the
        size of the primary beam, or a rectangle for rectangular mosaics. For
        custom mosaics, each individual pointing is plotted.

        One can specify an observing frequency and mosaic parameters
        (optional), which will also be plotted with a filled circle or
        rectangle.

    Usage:
        1) The user enters coordinates or source names, the observed
            frequency, the proposed mosaic parameters, and the size of the
            plot region (PLOTSIZE)

        2) plot() will list all observation within PLOTSIZE size, as well
            as plot the observations.

    How to input source name or coordinates:
        Parameters:
            exclude   : If set, it contains a list of spreadsheet row numbers
                        that should not be plotted.
            frame     : reference frame for longitude, either "icrs"
                        (i.e., equatorial) or "galactic"
            include   : If set, it contains a list of spreadsheet row numbers
                        that can be plotted if all other criteria are set
            inputFile : Input list sources or positions
            inputObs  : Input excel file containing previous observations
                        and scheduled observations
            latitude  : Latitude coordinate with reference frame specified
                        by "frame". Examples:
                        latitude=-20.0, unit='deg'
                        latitude='-20 deg'
                        latitude=['-20 deg', '20d00m00s']
            longitude : Longitude coordinate with reference frame specified
                        by "frame". Examples:
                        longitude=100.0, unit='deg'
                        longitude='100 deg'
                        longitude=['100 deg', '17h15m:10s']
            lookup    : If True, resolve the source name using Sesame.
                        lookup=False can be useful if the name listed in the
                        spreadsheet is desired.
            mosonly   : If True, print/display only mosaic observations
            refresh   : If True, re-read excel spreadsheet into memory
            rows      : List of row numbers in excel spreadsheet
            sources   : List of source names. The coordinates of the source
                        names are obtain from the Sesame database, and if the
                        name is not resolved, the name is searched
                        (case-insensitive) in the input spreadsheet. If
                        lookup=False, the search in the Sesame database is
                        skipped.
            unit      : units of longitude and latitude if not specified by
                        in longitude/latitude. Examples:
                        unit='deg' implies longitude/latitude are in degrees.

                        unit='hour,deg' implies longitude is in hours
                        and latitude is in degrees.
            verbose   : If True, print out messages when reading the excel
                        spreadsheet

        Plot parameters
            plotroot  : Root name for the file containing the plot. The root
                        name will be appended with a plot number (useful when
                        plotting multiple sources) and the plottype.
            plot_size  : Size of the square region to plot in arcseconds
            plottype  : Type of plot to generate. The plot type must be
                        supported by your version of python. "pdf" and "png"
                        are usually safe. Default is "pdf". Set plottype=None
                        to not save the plots to a file.
            show_plot : if True, show each generated plot if a GUI backend for matplotlib
                        is selected. If False, only a file with the type selected in plottype
                        is generated.

        Proposed observed parameters
            freq      : Proposed observing frequency in GHz
            length    : Proposed length of the mosaic in arcseconds
            mframe    : Coordinate system of the mosaic (icrs or galactic)
            mosaic    : If True, the proposed observations are a mosaic
            pa        : Proposed position angle of the mosaic in degrees
            width     : Proposed width  of the mosaic in arcseconds

        Usage:
            There are three options to search for source names:
            1) Enter list of coordinates.
                The format is flexible in that coordinates may be a string,
                hex, or decimal format. If the units are not specified, the
                default units are given by the 'unit' variable.

                For example:
                    longitude=['01h05m11s', 1.32]
                    latitude=['-10d05m11s', -20.0]

            2) Enter of a list of source names that will be name resolved
                by querying the Sesame database. An internet connection
                must be available for the coordinate lookup

            3) Enter a file with coordinates/names, one entry per line
                The comment symbol per line is the pound symbol.
                The entry will be name resolved first, and if that is not
                successful, it will be coordinate resolved. If that is not
                successful, then the source is skipped.

            4) The source name will be matched to the OBSERVATION list.
                The search will be case insensitive.

        Examples
            # Plot scheduled observations within 100" x 100" of NGC4298 along
            # with a proposed pointing at the position of the galaxy
            po.plot('NGC7496', plotsize=200)

            # Plot scheduled observations within 300" x 300" of NGC4298,
            # along with a proposed mosaic at 345 GHz
            po.plot('NGC7496', plotsize=300, length=240, width=120,
                    pa=60, freq=345., mosaic=True)

            # Plot remaining observations within 480" of (ra, dec) = (17h20m42s, -35d48m04s)
            po.plot('17h20m42s', '-35d48m04s', plotsize=480, frame='icrs', unit='deg')

            # Plot scheduled observations in a 1 deg region around ra, dec = (150, 2.2) J2000
            po.plot(150, 2.2, plotsize=3600)

            # Plot scheduled observations in a 1 deg around galactic center, but mosaics only
            po.plot(0, 0, frame='galactic', plotsize=3600., mosonly=True)

            # Plot scheduled observations within a 120" x 120" of HL Tau and GO Tau
            po.plot('HL Tau, GO Tau', plotsize=120)

            # Plot scheduled observations near coordinates in row 494 of the spreadsheet
            po.plot(494)

            # Plot scheduled observations centered on row 494 in the spreadsheet, and only row 494
            po.plot(494, include=494)

            # Plot remaining observations for sources listed in a text file
            # Contents of input file "input.txt":
                HL Tau
                GO Tau
                plotsize = 480
                NGC7496
                frame = galactic
                plotsize = 60
                178.8590 -19.9724
            po.plot(inputFile='input.txt')
    """
    # If longitude is set but no other source parameters are set, then
    # assume the following:
    # 1) If longitude is a string, then assume it is a source name
    # 2) If it is an integer, then it is a row number
    if (
        longitude is not None
        and latitude is None
        and rows is None
        and sources is None
        and inputFile is None
    ):
        if str in [type(longitude)]:
            sources = longitude
            longitude = None
        elif int in [type(longitude)]:
            rows = longitude
            longitude = None

    # Check inputs
    error = False
    # Longitude/latitude must both be set or not at all
    if (longitude is not None and latitude is None) or (
        longitude is None and latitude is not None
    ):
        error = True
        print("Error: Longitude and latitude must both be set to enter coordinates")
    # Some coordinates must be set
    if (
        (longitude is None or latitude is None)
        and rows is None
        and inputFile is None
        and sources is None
    ):
        error = True
        print("Error: longitude/latitude, sources, rows, or inputFile must be set")
    # Check frame of coordinates
    if frame.lower() not in ["icrs", "galactic"]:
        error = True
        print('Error: frame must be set to "icrs" or "galactic"')
    # Check plot_size
    if plot_size <= 0:
        error = True
        print("Error: plot_size must be > 0")
    # Check frequency
    if freq <= 0:
        error = True
        print("Error: frequency must be > 0")
    # Check mosaic parameters, if mosaic is set
    if mosaic:
        if length <= 0:
            error = True
            print("Error: length must be > 0")
        if width <= 0:
            error = True
            print("Error: width must be > 0")
        if mframe.lower() not in ["icrs", "galactic"]:
            error = True
            print('Error: mframe must be set to "icrs" or "galactic"')
    if error:
        print("")
        print("Error starting plotobs_cycle11")
        return

    # Read list of previous or scheduled observations, if not entered
    # on command line
    global OBSERVATIONS
    if OBSERVATIONS is None or refresh:
        OBSERVATIONS = readObservations(input=inputObs, verbose=verbose)
    observations = OBSERVATIONS

    # Get coordinates of source(s)
    coords = getSourceCoordinates(
        longitude,
        latitude,
        sources,
        inputFile,
        rows,
        unit=unit,
        frame=frame,
        data=observations[DATA],
        spreadsheet=inputObs,
        lookup=lookup,
    )

    # Plot results
    plotSources(
        coords,
        observations,
        include=include,
        exclude=exclude,
        freq=freq,
        mosonly=mosonly,
        mosaic=mosaic,
        mframe=mframe,
        width=width,
        length=length,
        pa=pa,
        plotroot=plotroot,
        plottype=plottype,
        plot_size=plot_size,
        show_plot=show_plot,
    )