#!/usr/bin/env python
"""
killMS, a package for calibration in radio interferometry.
Copyright (C) 2013-2017  Cyril Tasse, l'Observatoire de Paris,
SKA South Africa, Rhodes University

This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
"""
"""sidereal.py: A Python module for astronomical calculations.

  For documentation, see:
    http://www.nmt.edu/tcc/help/lang/python/examples/sidereal/ims/
"""
#================================================================
# Imports
#----------------------------------------------------------------
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function

from math import *
import re
import datetime
#================================================================
# Manifest constants
#----------------------------------------------------------------

FIRST_GREGORIAN_YEAR  =  1583
TWO_PI  =  2.0 * pi
PI_OVER_12  =  pi / 12.0
JULIAN_BIAS  =  2200000    # 2,200,000
SIDEREAL_A  =  0.0657098
FLOAT_PAT  =  re.compile (
    r'\d+'          # Matches one or more digits
    r'('            # Start optional fraction
      r'[.]'          # Matches the decimal point
      r'\d+'          # Matches one or more digits
    r')?' )          # End optional group
D_PAT  =  re.compile ( r'[dD]' )
M_PAT  =  re.compile ( r'[mM]' )
S_PAT  =  re.compile ( r'[sS]' )
H_PAT  =  re.compile ( r'[hH]' )
NS_PAT  =  re.compile ( r'[nNsS]' )
EW_PAT  =  re.compile ( r'[eEwW]' )
# - - -   h o u r s T o R a d i a n s

def hoursToRadians ( hours ):
    """Convert hours (15 degrees) to radians.
    """
    return  hours * PI_OVER_12
# - - -   r a d i a n s T o H o u r s

def radiansToHours ( radians ):
    """Convert radians to hours (15 degrees).
    """
    return  radians / PI_OVER_12
# - - -   h o u r A n g l e T o R A

def hourAngleToRA ( h, ut, eLong ):
    """Convert hour angle to right ascension.

      [ (h is an hour angle in radians as a float) and
        (ut is a timestamp as a datetime.datetime instance) and
        (eLong is an east longitude in radians) ->
          return the right ascension in radians corresponding
          to that hour angle at that time and location ]
    """
    #-- 1 --
    # [ gst  :=  the Greenwich Sidereal Time equivalent to
    #            ut, as a SiderealTime instance ]
    gst  =  SiderealTime.fromDatetime ( ut )
    #-- 2 --
    # [ lst  :=  the local time corresponding to gst at
    #            longitude eLong ]
    lst  =  gst.lst ( eLong )
    #-- 3 --
    # [ alpha  :=  lst - h, normalized to [0,2*pi) ]
    alpha  =  (lst.radians - h) % TWO_PI

    #-- 4 --
    return alpha
# - - -   r a T o H o u r A n g l e

def raToHourAngle ( ra, ut, eLong ):
    """Convert right ascension to hour angle.

      [ (ra is a right ascension in radians as a float) and
        (ut is a timestamp as a datetime.datetime instance) and
        (eLong is an east longitude in radians) ->
          return the hour angle in radians at that time and
          location corresponding to that right ascension ]
    """
    #-- 1 --
    # [ gst  :=  the Greenwich Sidereal Time equivalent to
    #            ut, as a SiderealTime instance ]
    gst  =  SiderealTime.fromDatetime ( ut )

    #-- 2 --
    # [ lst  :=  the local time corresponding to gst at
    #            longitude eLong ]
    lst  =  gst.lst ( eLong )
    #-- 3 --
    # [ h  :=  lst - ra, normalized to [0,2*pi) ]
    h  =  (lst.radians - ra) % TWO_PI

    #-- 4 --
    return h
# - - -   d a y N o

def dayNo ( dt ):
    """Compute the day number within the year.

      [ dt is a date as a datetime.datetime or datetime.date ->
          return the number of days between dt and Dec. 31 of
          the preceding year ]
    """
    #-- 1 --
    # [ dateOrd  :=  proleptic Gregorian ordinal of dt
    #   jan1Ord  :=  proleptic Gregorian ordinal of January 1
    #                of year (dt.year) ]
    dateOrd  =  dt.toordinal()
    jan1Ord  =  datetime.date ( dt.year, 1, 1 ).toordinal()

    #-- 2 --
    return  dateOrd - jan1Ord + 1
# - - -   p a r s e D a t e t i m e

T_PATTERN = re.compile ( '[tT]' )

def parseDatetime ( s ):
    """Parse a date with optional time.

      [ s is a string ->
          if s is a valid date with optional time ->
            return that timestamp as a datetime.datetime instance
          else -> raise SyntaxError ]
    """
    #-- 1 --
    # [ if s contains "T" or "t" ->
    #     rawDate  :=  s up to the first such character
    #     rawTime  :=  s from just after the first such
    #                  character to the end
    #   else ->
    #     rawDate  :=  s
    #     rawTime  :=  None ]
    m = T_PATTERN.search ( s )
    if  m is None:
        rawDate, rawTime  =  s, None
    else:
        rawDate  =  s[:m.start()]
        rawTime  =  s[m.end():]
    #-- 2 --
    # [ if rawDate is a valid date ->
    #     datePart  :=  rawDate as a datetime.datetime instance
    #   else -> raise SyntaxError ]
    datePart  =  parseDate ( rawDate )
    #-- 3 --
    # [ if rawTime is None ->
    #     timePart  :=  00:00 as a datetime.time
    #   else if rawTime is valid ->
    #     timePart  :=  rawTime as a datetime.time
    #   else -> raise SyntaxError ]
    if  rawTime is None:
        timePart  =  datetime.time ( 0, 0 )
    else:
        timePart  =  parseTime ( rawTime )
    #-- 4 --
    return  datetime.datetime.combine ( datePart, timePart )
# - - -   p a r s e D a t e

YEAR_FIELD  =  "Y"
MONTH_FIELD  =  "M"
DAY_FIELD  =  "D"

dateRe  =  (
    r'('            # Begin YEAR_FIELD
      r'?P<%s>'       # Name this group YEAR_FIELD
      r'\d{4}'        # Match exactly four digits
    r')'            # End YEAR_FIELD
    r'\-'           # Matches one hyphen
    r'('            # Begin MONTH_FIELD
      r'?P<%s>'       # Name this group MONTH_FIELD
      r'\d{1,2}'      # Matches one or two digits
    r')'            # End MONTH_FIELD
    r'\-'           # Matches "-"
    r'('            # Begin DAY_FIELD
      r'?P<%s>'       # Name this group DAY_FIELD
      r'\d{1,2}'      # Matches one or two digits
    r')'            # End DAY_FIELD
    r'$'            # Make sure all characters match
    ) % (YEAR_FIELD, MONTH_FIELD, DAY_FIELD)
DATE_PAT  =  re.compile ( dateRe )

def parseDate ( s ):
    """Validate and convert a date in external form.

      [ s is a string ->
          if s is a valid external date string ->
            return that date as a datetime.date instance
          else -> raise SyntaxError ]
    """
    #-- 1 --
    # [ if DATE_PAT matches s ->
    #     m  :=  a match instance describing the match
    #   else -> raise SyntaxError ]
    m  =  DATE_PAT.match ( s )
    if  m is None:
        raise SyntaxError, ( "Date does not have pattern YYYY-DD-MM: "
                             "'%s'" % s )
    #-- 2 --
    year   =  int ( m.group ( YEAR_FIELD ) )
    month  =  int ( m.group ( MONTH_FIELD ) )
    day    =  int ( m.group ( DAY_FIELD ) )

    #-- 3 --
    return  datetime.date ( year, month, day )
# - - -   p a r s e T i m e

def parseTime ( s ):
    """Validate and convert a time and optional zone.

      [ s is a string ->
          if s is a valid time with optional zone suffix ->
            return that time as a datetime.time
          else -> raise SyntaxError ]
    """
    #-- 1 -
    # [ if s starts with FLOAT_PAT ->
    #     decHour  :=  matching part of s as a float
    #     minuteTail  :=  part s past the match
    #   else -> raise SyntaxError ]
    decHour, minuteTail  =  parseFloat ( s, "Hour number"  )
    #-- 2 --
    # [ if minuteTail starts with ":" followed by FLOAT_PAT ->
    #     decMinute  :=  part matching FLOAT_PAT as a float
    #     secondTail  :=  part of minuteTail after the match
    #   else if minuteTail starts with ":" not followed by
    #   FLOAT_PAT ->
    #     raise SyntaxError
    #   else ->
    #     decMinute  :=  0.0
    #     secondTail  :=  minuteTail ]
    if  minuteTail.startswith(':'):
        m  =  FLOAT_PAT.match ( minuteTail[1:] )
        if  m is None:
            raise SyntaxError, ( "Expecting minutes: '%s'" %
                                 minuteTail )
        else:
            decMinute  =  float(m.group())
            secondTail  =  minuteTail[m.end()+1:]
    else:
        decMinute  =  0.0
        secondTail  =  minuteTail
    #-- 3 --
    # [ if secondTail starts with ":" followed by FLOAT_PAT ->
    #     decSecond  :=  part matching FLOAT_PAT as a float
    #     zoneTail  :=  part of secondTail after the match
    #   else if secondTail starts with ":" not followed by
    #   FLOAT_PAT ->
    #     raise SyntaxError
    #   else ->
    #     decSecond  :=  0.0
    #     zoneTail  :=  secondTail ]
    if  secondTail.startswith(':'):
        m  =  FLOAT_PAT.match ( secondTail[1:] )
        if  m is None:
            raise SyntaxError, ( "Expecting seconds: '%s'" %
                                 secondTail )
        else:
            decSecond  =  float(m.group())
            zoneTail  =  secondTail[m.end()+1:]
    else:
        decSecond  =  0.0
        zoneTail  =  secondTail
    #-- 4 --
    # [ if zoneTail is empty ->
    #     tz  :=  None
    #   else if zoneTail is a valid zone suffix ->
    #     tz  :=  that zone information as an instance of a class
    #             that inherits from datetime.tzinfo
    #   else -> raise SyntaxError ]
    if  len(zoneTail) == 0:
        tz  =  None
    else:
        tz  =  parseZone ( zoneTail )
    #-- 5 --
    # [ hours  :=  decHour + decMinute/60.0 + decSecond/3600.0 ]
    hours  =  dmsUnits.mixToSingle ( (decHour, decMinute, decSecond) )
    #-- 6 --
    # [ return a datetime.time representing hours ]
    hh, mm, seconds = dmsUnits.singleToMix ( hours )
    wholeSeconds, fracSeconds = divmod ( seconds, 1.0 )
    ss = int(wholeSeconds)
    usec = int ( fracSeconds * 1e6 )
    return  datetime.time ( hh, mm, ss, usec, tz )
# - - -   p a r s e Z o n e

def parseZone ( s ):
    """Validate and convert a time zone suffix.

      [ s is a string ->
          if s is a valid time zone suffix ->
            return that zone's information as an instance of
            a class that inherits from datetime.tzinfo
          else -> raise SyntaxError ]
    """
    #-- 1 --
    # [ if s starts with "+" or "-" and is a valid fixed-offset
    #   time zone suffix ->
    #     return that zone's information as a datetime.tzinfo instance
    #   else if is starts with "+" or "-" but is not a valid
    #   fixed-offset time zone suffix ->
    #     raise SyntaxError
    #   else -> I ]
    if  s.startswith("+") or s.startswith("-"):
        return  parseFixedZone ( s )

    #-- 2 --
    # [ if s.upper() is a key in zoneCodeMap ->
    #     return the corresponding value
    #   else -> raise SyntaxError ]
    try:
        tz  =  zoneCodeMap[s.upper()]
        return tz
    except KeyError:
        raise SyntaxError, ( "Unknown time zone code: '%s'" % s )
# - - -   p a r s e F i x e d Z o n e

HHMM_PAT  =  re.compile (
    r'\d{4}'    # Matches exactly four digits
    r'$' )        # Be sure everything is matched

def parseFixedZone ( s ):
    """Convert a +hhmm or -hhmm zone suffix.

      [ s is a string ->
          if s is a time zone suffix of the form "+hhmm" or "-hhmm" ->
            return that zone information as an instance of a class
            that inherits from datetime.tzinfo
          else -> raise SyntaxError ]
    """
    #-- 1 --
    if  s.startswith('+'):    sign  =  1
    elif  s.startswith('-'):  sign  =  -1
    else:
        raise SyntaxError, ( "Expecting zone modifier as %shhmm: "
                             "'%s'" % (s[0], s) )
    #-- 2 --
    # [ if s[1:] matches HHMM_PAT ->
    #     hours  :=  the HH part as an int
    #     minutes  :=  the MM part as an int
    #   else -> raise SyntaxError ]
    rawHHMM  =  s[1:]
    m  =  HHMM_PAT.match ( rawHHMM )
    if  m is None:
        raise SyntaxError, ( "Expecting zone modifier as %sHHMM: "
                             "'%s'" % (s[0], s) )
    else:
        hours  =  int ( rawHHMM[:2] )
        minutes  =  int ( rawHHMM[2:] )

    #-- 3 --
    return  FixedZone ( sign*hours, sign*minutes, s )

# - - - - -   c l a s s   F i x e d Z o n e

DELTA_ZERO  =  datetime.timedelta(0)
DELTA_HOUR  =  datetime.timedelta(hours=1)

class FixedZone(datetime.tzinfo):
    """Represents a time zone with a fixed offset east of UTC.

      Exports:
        FixedZone ( hours, minutes, name ):
          [ (hours is a signed offset in hours as an int) and
            (minutes is a signed offset in minutes as an int) ->
              return a new FixedZone instance representing
              those offsets east of UTC ]
      State/Invariants:
        .__offset:
          [ a datetime.timedelta representing self's offset
            east of UTC ]
        .__name:
          [ as passed to the constructor's name argument ]
    """
    def __init__ ( self, hh, mm, name ):
        """Constructor for FixedZone.
        """
        self.__offset  =  datetime.timedelta ( hours=hh, minutes=mm )
        self.__name  =  name
    def utcoffset(self, dt):
        """Return self's offset east of UTC.
        """
        return  self.__offset
    def  tzname(self, dt):
        """Return self's name.
        """
        return  self.__name
    def  dst(self, dt):
        """Return self's daylight time offset.
        """
        return  DELTA_ZERO
def firstSundayOnOrAfter ( dt ):
    """Find the first Sunday on or after a given date.

      [ dt is a datetime.date ->
          return a datetime.date representing the first Sunday
          on or after dt ]
    """
    daysToGo  =  dt.weekday()
    if  daysToGo:
        dt  +=  datetime.timedelta ( daysToGo )
    return dt

# - - - - -   c l a s s   U S T i m e Z o n e

class USTimeZone(datetime.tzinfo):
    """Represents a U.S. time zone, with automatic daylight time.

      Exports:
        USTimeZone ( hh, mm, name, stdName, dstName ):
          [ (hh is an offset east of UTC in hours) and
            (mm is an offset east of UTC in minutes) and
            (name is the composite zone name) and
            (stdName is the non-DST name) and
            (dstName is the DST name) ->
              return a new USTimeZone instance with those values ]

      State/Invariants:
        .__offset:
          [ self's offset east of UTC as a datetime.timedelta ]
        .__name:      [ as passed to constructor's name ]
        .__stdName:   [ as passed to constructor's stdName ]
        .__dstName:   [ as passed to constructor's dstName ]
    """
    DST_START_OLD  =  datetime.datetime ( 1, 4, 1, 2 )
    DST_END_OLD  =  datetime.datetime ( 1, 10, 25, 2 )
    DST_START_2007  =  datetime.datetime ( 1, 3, 8, 2 )
    DST_END_2007  =  datetime.datetime ( 1, 11, 1, 2 )
    def __init__ ( self, hh, mm, name, stdName, dstName ):
        self.__offset   =  datetime.timedelta ( hours=hh, minutes=mm )
        self.__name     =  name
        self.__stdName  =  stdName
        self.__dstname  =  dstName
    def tzname(self, dt):
        if  self.dst(dt):   return self.__dstName
        else:               return self.__stdName
    def utcoffset(self, dt):
        return self.__offset + self.dst(dt)
    def dst(self, dt):
        """Return the current DST offset.

          [ dt is a datetime.date ->
              if  daylight time is in effect in self's zone on
              date dt ->
                return +1 hour as a datetime.timedelta
              else ->
                return 0 as a datetime.delta ]
        """
        #-- 1 --
        # [ dtStart  :=  Sunday when DST starts in year dt.year
        #   dtEnd    :=  Sunday when DST ends in year dt.year ]
        if  dt.year >= 2007:
            startDate  =  self.DST_START_2007.replace ( year=dt.year )
            endDate  =  self.DST_END_2007.replace ( year=dt.year )
        else:
            startDate  =  self.DST_START_OLD.replace ( year=dt.year )
            endDate  =  self.DST_END_OLD.replace ( year=dt.year )
        dtStart  =  firstSundayOnOrAfter ( startDate )
        dtEnd    =  firstSundayOnOrAfter ( endDate )
        #-- 2 --
        # [ naiveDate  :=  dt with its tzinfo member set to None ]
        naiveDate  =  dt.replace ( tzinfo=None )
        #-- 3 --
        # [ if naiveDate is in the interval (dtStart, dtEnd) ->
        #     return DELTA_HOUR
        #   else ->
        #     return DELTA_ZERO ]
        if  dtStart <= naiveDate < dtEnd:
            return  DELTA_HOUR
        else:
            return  DELTA_ZERO
utcZone  =  FixedZone(0, 0, "UTC")

estZone  =  FixedZone(-5, 0, "EST")
edtZone  =  FixedZone(-4, 0, "EDT")
etZone   =  USTimeZone(-5, 0, "ET", "EST", "EDT")

cstZone  =  FixedZone(-6, 0, "CST")
cdtZone  =  FixedZone(-5, 0, "CDT")
ctZone   =  USTimeZone(-6, 0, "CT", "CST", "CDT")

mstZone  =  FixedZone(-7, 0, "MST")
mdtZone  =  FixedZone(-6, 0, "MDT")
mtZone   =  USTimeZone(-7, 0, "MT", "MST", "MDT")

pstZone  =  FixedZone(-8, 0, "PST")
pdtZone  =  FixedZone(-7, 0, "PDT")
ptZone   =  USTimeZone(-8, 0, "PT", "PST", "PDT")

zoneCodeMap  =  {
    "UTC": utcZone,
    "EST": estZone,    "EDT": edtZone,    "ET":  etZone,
    "CST": cstZone,    "CDT": cdtZone,    "CT":  ctZone,
    "MST": mstZone,    "MDT": mdtZone,    "MT":  mtZone,
    "PST": pstZone,    "PDT": pdtZone,    "PT":  ptZone }
# - - -   p a r s e A n g l e

def parseAngle ( s ):
    """Validate and convert an external angle.

      [ s is a string ->
          if s is a valid external angle ->
            return s in radians
          else -> raise SyntaxError ]
    """
    #-- 1 --
    minute  =  second  =  0.0
    #-- 2 --
    # [ if s starts with a float followed by 'd' or 'D' ->
    #     degree    :=  that float as type float
    #     minTail  :=  s after that float and suffix
    #   else -> raise SyntaxError ]
    degree, minTail  =  parseFloatSuffix ( s, D_PAT,
                          "Degrees followed by 'd'" )

    #-- 3 --
    # [ if minTail is empty -> I
    #   else if minTail has the form "(float)m" ->
    #     minute  :=  that (float)
    #   else if minTail has the form "(float)m(float)s" ->
    #     minute  :=  the first (float)
    #     second  :=  the second (float)
    #   else -> raise SyntaxError ]
    if  len(minTail) != 0:
        #-- 3.1 --
        # [ if minTail starts with a float followed by 'm' or 'M' ->
        #     minute  :=  that float as type float
        #     secTail  :=  minTail after all that
        #   else -> raise SyntaxError ]
        minute, secTail  =  parseFloatSuffix ( minTail, M_PAT,
                                "Minutes followed by 'm'" )

        #-- 3.2 --
        # [ if secTail is empty -> I
        #   else if secTail starts with a float followed by
        #   's' or 'S' ->
        #     second  :=  that float as type float
        #     checkTail  :=  secTail after all that
        #   else -> raise SyntaxError ]
        if  len(secTail) != 0:
            second, checkTail  =  parseFloatSuffix ( secTail,
                S_PAT, "Seconds followed by 's'" )
            if  len(checkTail) != 0:
                raise SyntaxError, ( "Unidentifiable angle parts: "
                                     "'%s'" % checkTail )
    #-- 4 --
    # [ return the angle (degree, minute, second) in radians ]
    angleDegrees  =  dmsUnits.mixToSingle ( (degree, minute, second) )
    return  radians ( angleDegrees )
# - - -   p a r s e F l o a t S u f f i x

def parseFloatSuffix ( s, codeRe, message ):
    """Parse a float followed by a letter code.

      [ (s is a string) and
        (codeRe is a compiled regular expression) and
        (message is a string describing what is expected) ->
          if  s starts with a float, followed by code (using 
          case-insensitive comparison) ->
            return (x, tail) where x is that float as type float
            and tail is the part of s after the float and code
          else -> raise SyntaxError, "Expecting (message)" ]
    """
    #-- 1 --
    # [ if  s starts with a float ->
    #     x  :=  that float as type float
    #     codeTail  :=  the part of s after that float
    #   else -> raise SyntaxError, "Expecting (message)" ]
    x, codeTail  =  parseFloat ( s, message )

    #-- 2 --
    # [ if codeTail starts with code (case-insensitive) ->
    #     return (x, the part of codeTail after the match)
    #   else -> raise SyntaxError ]
    discard, tail  =  parseRe ( codeTail, codeRe, message )

    #-- 3 --
    return (x, tail)
# - - -   p a r s e F l o a t

def parseFloat ( s, message ):
    """Parse a floating-point number at the front of s.

      [ (s is a string) and
        (message is a string describing what is expected) ->
          if s begins with a floating-point number ->
            return (x, tail) where x is the number as type float
            and tail is the part of s after the match
          else -> raise SyntaxError, "Expecting (message)" ]
    """
    #-- 1 --
    # [ if the front of s matches FLOAT_PAT ->
    #     m  :=  a Match object describing the match
    #   else -> raise SyntaxError ]
    rawFloat, tail  =  parseRe ( s, FLOAT_PAT, message )

    #-- 2 --
    return  (float(rawFloat), tail)
# - - -   p a r s e R e

def parseRe ( s, regex, message ):
    """Parse a regular expression at the head of a string.

      [ (s is a string) and
        (regex is a compiled regular expression) and
        (message is a string describing what is expected) ->
          if  s starts with a string that matches regex ->
            return (head, tail) where head is the part of s
            that matched and tail is the rest
          else ->
            raise SyntaxError, "Expecting (message)" ]
    """

    #-- 1 --
    # [ if the head of s matches regex ->
    #     m  :=  a match object describing the matching part
    #   else -> raise SyntaxError, "Expecting (message)" ]
    m  =  regex.match ( s )
    if  m is None:
        raise SyntaxError, "Expecting %s: '%s'" % (message, s)
    #-- 2 --
    # [ return (matched text from s, text from s after match) ]
    head  =  m.group()
    tail  =  s[m.end():]
    return  (head, tail)
# - - -   p a r s e L a t

def parseLat ( s ):
    """Validate and convert an external latitude.

      [ s is a nonempty string ->
          if s is a valid external latitude ->
            return that latitude in radians
          else -> raise SyntaxError ]
    """
    #-- 1 --
    # [ last  :=  last character of s
    #   rawAngle  :=  s up to the last character ]
    last  =  s[-1]
    rawAngle  =  s[:-1]

    #-- 2 --
    # [ if last matches NS_PAT ->
    #     nsFlag  :=  last, lowercased
    #   else -> raise SyntaxError ]
    m  =  NS_PAT.match ( last )
    if  m is None:
        raise SyntaxError, ( "Latitude '%s' does not end with 'n' "
                             "or 's'." % s )
    else:
        nsFlag  =  last.lower()
    #-- 3 --
    # [ if rawAngle is a valid angle ->
    #     absAngle  :=  that angle in radians
    #   else -> raise SyntaxError ]
    absAngle  =  parseAngle ( rawAngle )
    #-- 4 --
    if  nsFlag == 's':  angle  =  - absAngle
    else:               angle  =  absAngle

    #-- 5 --
    return angle
# - - -   p a r s e L o n

def parseLon ( s ):
    """Validate and convert an external longitude.

      [ s is a nonempty string ->
          if s is a valid external longitude ->
            return that longitude in radians
          else -> raise SyntaxError ]
    """
    #-- 1 --
    # [ last  :=  last character of s
    #   rawAngle  :=  s up to the last character ]
    last  =  s[-1]
    rawAngle  =  s[:-1]

    #-- 2 --
    # [ if EW_PAT matches last ->
    #     ewFlag  :=  last, lowercased
    #   else -> raise SyntaxError ]
    m  =  EW_PAT.match ( last )
    if  m is None:
        raise SyntaxError, ( "Longitude '%s' does not end with "
                             "'e' or 'w'." % s )
    else:
        ewFlag  =  last.lower()
    #-- 3 --
    # [ if rawAngle is a valid angle ->
    #     absAngle  :=  that angle in radians
    #   else -> raise SyntaxError ]
    absAngle  =  parseAngle ( rawAngle )
    #-- 4 --
    if  ewFlag == 'w':   angle  =  TWO_PI - absAngle
    else:                angle  =  absAngle

    #-- 5 --
    return  angle
# - - -   p a r s e H o u r s

def parseHours ( s ):
    """Validate and convert a quantity in hours.

      [ s is a non-empty string ->
          if s is a valid mixed hours expression ->
            return the value of s as decimal hours
          else -> raise SyntaxError ]
    """
    #-- 1 --
    minute  =  second  =  0.0

    #-- 2 --
    # [ if s starts with a float followed by 'h' or 'H' ->
    #     hour  :=  that float as type float
    #     minTail  :=  s after that float and suffix
    #   else -> raise SyntaxError ]
    hour, minTail  =  parseFloatSuffix ( s, H_PAT,
                          "Hours followed by 'h'" )

    #-- 3 --
    # [ if minTail is empty -> I
    #   else if minTail has the form "(float)m" ->
    #     minute  :=  that (float)
    #   else if minTail has the form "(float)m(float)s" ->
    #     minute  :=  the first (float)
    #     second  :=  the second (float)
    #   else -> raise SyntaxError ]
    if  len(minTail) != 0:
        #-- 3.1 --
        # [ if minTail starts with a float followed by 'm' or 'M' ->
        #     minute  :=  that float as type float
        #     secTail  :=  minTail after all that
        #   else -> raise SyntaxError ]
        minute, secTail  =  parseFloatSuffix ( minTail, M_PAT,
                                "Minutes followed by 'm'" )

        #-- 3.2 --
        # [ if secTail is empty -> I
        #   else if secTail starts with a float followed by
        #   's' or 'S' ->
        #     second  :=  that float as type float
        #     checkTail  :=  secTail after all that
        #   else -> raise SyntaxError ]
        if  len(secTail) != 0:
            second, checkTail  =  parseFloatSuffix ( secTail,
                S_PAT, "Seconds followed by 's'" )
            if  len(checkTail) != 0:
                raise SyntaxError, ( "Unidentifiable angle parts: "
                                     "'%s'" % checkTail )
    #-- 4 --
    # [ return the quantity (hour, minute, second) in hours ]
    result  =  dmsUnits.mixToSingle ( (hour, minute, second) )
    return  result

# - - - - -   c l a s s   M i x e d U n i t s

class MixedUnits:
    """Represents a system with mixed units, e.g., hours/minutes/seconds
    """
# - - -   M i x e d U n i t s . _ _ i n i t _ _

    def __init__ ( self, factors ):
        """Constructor
        """
        self.factors  =  factors
# - - -   M i x e d U n i t s . m i x T o S i n g l e

    def mixToSingle ( self, coeffs ):
        """Convert mixed units to a single value.

          [ coeffs is a sequence of numbers not longer than
            len(self.factors)+1 ->
              return the equivalent single value in self's system ]
        """
        #-- 1 --
        total  =  0.0

        #-- 2 --
        # [ if  len(coeffs) <= len(self.factors)+1 ->
        #     coeffList  :=  a copy of coeffs, right-padded to length
        #         len(self.factors)+1 with zeroes if necessary ]
        coeffList  =  self.__pad ( coeffs )
        #-- 3 --
        # [ total  +:=  (coeffList[-1] * 
        #        (product of all elements of self.factors)) +
        #       (coeffList[-2] *
        #        (product of all elements of self.factors[:-1])) +
        #       (coeffList[-3] *
        #        (product of all elements of self.factors[:-2]))
        #        ... ]
        for  i in range ( -1, -len(self.factors)-1, -1):
            total  +=  coeffList[i]
            total  /=  self.factors[i]
        #-- 4 --
        total  +=  coeffList[0]

        #-- 5 --
        return total
# - - -   M i x e d U n i t s . _ _ p a d

    def __pad ( self, coeffs ):
        """Pad coefficient lists to standard length.

          [ coeffs is a sequence of numbers ->
              if  len(coeffs) > len(self.factors)+1 ->
                raise ValueError
              else ->
                return a list containing the elements of coeff,
                plus additional zeroes on the right if necessary
                so that the result has length len(self.factors)+1 ]
        """
        #-- 1 --
        # [ stdLen  :=  1 + len(self.factors)
        #   shortage  :=  1 + len(self.factors) - len(coeffs)
        #   result  :=  a copy of coeffs as a list ]
        stdLen  =  1 + len(self.factors)
        shortage  =  stdLen - len(coeffs)
        result  =  list(coeffs)

        #-- 2 --
        # [ if shortage < 0 ->
        #     raise ValueError
        #   else ->
        #     result  :=  result + (a list of shortage zeroes) ]
        if  shortage < 0:
            raise ValueError, ( "Value %s has too many elements; "
                "max is %d." % (coeffs, stdLen) )
        elif  shortage > 0:
            result  +=  [0.0] * shortage

        #-- 3 --
        return result
# - - -   M i x e d U n i t s . s i n g l e T o M i x

    def singleToMix ( self, value ):
        """Convert to mixed units.

          [ value is a float ->
              return value as a sequence of coefficients in
              self's system ]
        """
        #-- 1 --
        # [ whole  :=  whole part of value
        #   frac  :=  fractional part of value ]
        whole, frac  =  divmod ( value, 1.0 )
        result  =  [int(whole)]
        #-- 2 --
        # [ result  :=  result with integral parts of value
        #               in self's system appended ]
        for  factorx in range(len(self.factors)):
            frac  *=  self.factors[factorx]
            whole, frac  =  divmod ( frac, 1.0 )
            result.append ( int(whole) )
        #-- 3 --
        # [ result  :=  result with frac added to its last element ]
        result[-1]  +=  frac

        #-- 4 --
        return result
# - - -   M i x e d U n i t s . f o r m a t

    def format ( self, coeffs, decimals=0, lz=False ):
        """Format mixed units.

          [ (coeffs is a sequence of numbers as returned by
            MixedUnits.singleToMix()) and
            (decimals is a nonnegative integer) and
            (lz is a bool) ->
              return a list of strings corresponding to the values
              of coeffs, with all the values but the last formatted
              as integers, all values zero padded iff lz is true,
              and the last value with (decimals) digits after the
              decimal point ]
        """
        #-- 1 --
        coeffList  =  self.__pad ( coeffs )

        #-- 2 --
        # [ result  :=  the values from coeffList[:-1] formatted
        #               as integers ]
        if  lz:  fmt = "%02d"
        else:    fmt = "%d"
        result  =  [ fmt % x
                     for x in coeffList[:-1] ]
        #-- 2 --
        # [ whole  :=  whole part of coeffList[-1]
        #   frac   :=  fractional part of coeffList[-1]
        #   fuzz   :=  0.5 * (10 ** (-decimals) ]
        whole, frac  =  divmod ( float(coeffList[-1]), 1.0 )
        fuzz = 0.5 * (10.0 ** (-decimals))
        #-- 3 --
        # [ if  frac >= (1-fuzz) ->
        #     result  +:=  [whole+frac-fuzz], formatted with
        #                  (decimals) digits after the decimal
        #   else ->
        #     result  +=   coeffList[-1], formatted with (decimals)
        #                  digits after the decimal ]
        if  frac >= (1.0-fuzz):
            corrected  =  whole + frac - fuzz
        else:
            corrected  =  coeffList[-1]
        #-- 4 --
        # [ if lz ->
        #     s  :=  corrected, formatted with 2 digits of left-zero
        #            padding and (decimals) precision
        #   else ->
        #     s  :=  corrected, formatted with (decimals) precision ]
        if  lz:
            if  decimals:  n = decimals+3
            else:          n = decimals+2

            s  =  "%0*.*f" % (n, decimals, corrected)
        else:
            s  =  "%.*f" % (decimals, corrected)
              
        #-- 5 --
        result.append ( s )

        #-- 6 --
        return result
dmsUnits = MixedUnits ( (60, 60) )

# - - - - -   c l a s s   L a t L o n

class LatLon:
    """Represents a latitude+longitude.
    """
# - - -  L a t L o n . _ _ i n i t _ _

    def __init__ ( self, lat, lon ):
        """Constructor for LatLon.
        """
        self.lat  =  lat
        self.lon  =  lon % TWO_PI
# - - -   L a t L o n . _ _ s t r _ _

    def __str__ ( self ):
        """Return self as a string.
        """
        #-- 1 --
        if  self.lon >= pi:
            e_w  =  "W"
            lonDeg  =  degrees ( TWO_PI - self.lon )
        else:
            e_w  =  "E"
            lonDeg  =  degrees ( self.lon )

        #-- 2 --
        if  self.lat < 0:
            n_s  =  "S"
            latDeg  =  degrees ( - self.lat )
        else:
            n_s  =  "N"
            latDeg  =  degrees ( self.lat )
        #-- 3 --
        # [ latList  :=  three formatted values of latDeg in
        #                degrees/minutes/seconds
        #   lonList  :=  three formatted values of lonDeg similarly ]
        latList  =  dmsUnits.format ( dmsUnits.singleToMix(latDeg), 1 )
        lonList  =  dmsUnits.format ( dmsUnits.singleToMix(lonDeg), 1 )

        #-- 4 --
        return ( '[%sd %s\' %s" %s Lat  %sd %s\' %s" %s Lon]' %
                 (latList[0], latList[1], latList[2], n_s,
                  lonList[0], lonList[1], lonList[2], e_w) )

# - - - - -   c l a s s   J u l i a n D a t e

class JulianDate:
    """Class to represent Julian-date timestamps.

      State/Invariants:
        .f:  [ (Julian date as a float) - JULIAN_BIAS ]
    """
# - - -   J u l i a n D a t e . _ _ i n i t _ _

    def __init__ ( self, j, f=0.0 ):
        """Constructor for JulianDate.
        """
        self.j  =  j - JULIAN_BIAS + f
# - - -   J u l i a n D a t e . _ _ f l o a t _ _

    def __float__ ( self ):
        """Convert self to a float.
        """
        return  self.j + JULIAN_BIAS
# - - -   J u l i a n D a t e . d a t e t i m e

    def datetime ( self ):
        """Convert to a standard Python datetime object in UT.
        """
        #-- 1 --
        # [ i  :=  int(self.j + 0.5)
        #   f  :=  (self.j + 0.5) % 1.0 ]
        i, f  =  divmod ( self.j + 0.5, 1.0 )
        i  +=  JULIAN_BIAS
        #-- 2 --
        if  i > 2299160:
            a  =  int((i-1867216.25)/36524.25)
            b  =  i + 1 + a - int ( a / 4.0 )
        else:
            b  =  i
        #-- 3 --
        c = b + 1524
        #-- 4 --
        d = int((c-122.1)/365.25)
        #-- 5 --
        e = int(365.25*d)
        #-- 6 --
        g = int((c-e)/30.6001)
        #-- 7 --
        dayFrac = c - e + f - int ( 30.6001 * g )
        day, frac = divmod ( dayFrac, 1.0 )
        dd = int(day)
        hr, mn, sc = dmsUnits.singleToMix ( 24.0*frac )
        #-- 8 --
        if  g < 13.5:  mm = int(g - 1)
        else:             mm = int(g - 13)
        #-- 9 --
        if  mm > 2.5:  yyyy = int(d-4716)
        else:          yyyy = int(d-4715)
        #-- 10 --
        sec, fracSec = divmod(sc, 1.0)
        usec = int(fracSec * 1e6)
        return datetime.datetime ( yyyy, mm, dd, hr, mn, int(sec),
                                   usec )
# - - -   J u l i a n D a t e . o f f s e t

    def offset ( self, delta ):
        """Return a new JulianDate for self+(delta days)

          [ delta is a number of days as a float ->
              return a new JulianDate (delta) days in the
              future, or past if negative ]
        """
        #-- 1 --
        newJ  =  self.j + delta

        #-- 2 --
        # [ newWhole  :=  whole part of newJ
        #   newFrac   :=  fractional part of newJ ]
        newWhole, newFrac  =  divmod ( newJ )

        #-- 3 --
        return  JulianDate ( newWhole+JULIAN_BIAS, newFrac )
# - - -   J u l i a n D a t e . _ _ s u b _ _

    def __sub__ ( self, other ):
        """Implement subtraction.

          [ other is a JulianDate instance ->
              return self.j - other.j ]
        """
        return  self.j - other.j
# - - -   J u l i a n D a t e . _ _ c m p _ _

    def __cmp__ ( self, other ):
        """Compare two instances.

          [ other is a JulianDate instance ->
              if  self.j < other.j ->  return a negative number
              else if self.j == other.j -> return zero
              else -> return a positive number ]
        """
        return  cmp ( self.j, other.j )
# - - -   J u l i a n D a t e . f r o m D a t e t i m e

#   @staticmethod
    def fromDatetime ( dt ):
        """Create a JulianDate instance from a datetime.datetime.

          [ dt is a datetime.datetime instance ->
              if  dt is naive ->
                return the equivalent new JulianDate instance,
                assuming dt expresses UTC
              else ->
                return a new JulianDate instance for the UTC
                time equivalent to dt ]              
        """
        #-- 1 --
        # [ if dt is naive ->
        #     utc  :=  dt
        #   else ->
        #     utc  :=  dt - dt.utcoffset() ]
        utc  =  dt
        offset  =  dt.utcoffset()
        if  offset:
            utc  =  dt - offset
        #-- 2 --
        # [ fracDay  :=  fraction of a day in [0.0,1.0) made from
        #       utc.hour, utc.minute, utc.second, and utc.microsecond ]
        s  =  float(utc.second) + float(utc.microsecond)*1e-6
        hours  =  dmsUnits.mixToSingle ( (utc.hour, utc.minute, s) )
        fracDay  =  hours / 24.0
        #-- 3 --
        y  =  utc.year
        m  =  utc.month
        d  =  utc.day
        #-- 4 --
        if  m <= 2:
            y, m  =  y-1, m+12
        #-- 5 --
        if  ( (y, m, d) >= (1582, 10, 15) ):
            A  =  int ( y / 100 )
            B  =  2 - A + int ( A / 4 )
        else:
            B  =  0
        #-- 6 --
        C  =  int ( 365.25 * y )
        D  =  int ( 30.6001 * ( m + 1 ) )
        #-- 7 --
        # [ if fracDay+0.5 >= 1.0 ->
        #     s  +=  1
        #     fracDay  :=  (fracDay+0.5) % 1.0
        #   else ->
        #     fracDay  :=  fracDay + 0.5 ]
        dayCarry, fracDay  =  divmod ( fracDay+0.5, 1.0 )
        d  +=  dayCarry

        #-- 8 --
        j  =  B + C + D + d + 1720994

        #-- 9 --
        return  JulianDate ( j, fracDay )
    fromDatetime = staticmethod(fromDatetime)

# - - - - -   c l a s s   S i d e r e a l T i m e

class SiderealTime:
    """Represents a sidereal time value.

      State/Internals:
        .hours:     [ self as 15-degree hours ]
        .radians:   [ self as radians ]
    """
# - - -   S i d e r e a l T i m e . _ _ i n i t _ _

    def __init__ ( self, hours ):
        """Constructor for SiderealTime
        """
        self.hours  =  hours % 24.0
        self.radians  =  hoursToRadians ( self.hours )
# - - -   S i d e r e a l T i m e . _ _ s t r _ _

    def __str__ ( self ):
        """Convert to a string such as "[04h 40m 5.170s]".
        """

        #-- 1 --
        # [ values  :=  self.hours as a list of mixed units
        #       in dmsUnits terms, formatted as left-zero
        #       filled strings with 3 digits after the decimal ]
        mix  =  dmsUnits.singleToMix ( self.hours )
        values  =  dmsUnits.format ( mix, decimals=3, lz=True )

        #-- 2 --
        return "[%sh %sm %ss]" % tuple(values)
# - - -   S i d e r e a l T i m e . u t c

    def utc ( self, date ):
        """Convert GST to UTC.

          [ date is a UTC date as a datetime.date instance ->
              return the first or only time at which self's GST
              occurs at longitude 0 ]
        """
        #-- 1 --
        # [ nDays  :=  number of days between Jan. 0 of year
        #       (date.year) and date ]
        nDays  =  dayNo ( date )
        #-- 2 --
        # [ t0  :=  (nDays * A - B(date.year)), normalized to
        #           interval [0,24) ]
        t0  =  ( ( nDays * SIDEREAL_A -
                   SiderealTime.factorB ( date.year ) ) % 24.0 )
        #-- 3 --
        # [ t1  :=  ((self in decimal hours)-t0), normalized to
        #           the interval [0,24) ]
        t1  =  ( radiansToHours ( self.radians ) - t0 ) % 24.0
        #-- 4 --
        gmtHours  =  t1 * 0.997270
        #-- 5 --
        # [ dt  :=  a datetime.datetime instance whose date comes
        #           from (date) and whose time is (gmtHours)
        #           decimal hours ]
        hour, minute, floatSec  =  dmsUnits.singleToMix ( gmtHours )
        wholeSec, fracSec  =  divmod ( floatSec, 1.0 )
        second  =  int ( wholeSec )
        micros  =  int ( fracSec * 1e6 )
        dt  =  datetime.datetime ( date.year, date.month,
                   date.day, hour, minute, second, micros )
        
        #-- 6 --
        return  dt
# - - -   S i d e r e a l T i m e . f a c t o r B

#   @staticmethod
    def factorB ( yyyy ):
        """Compute sidereal conversion factor B for a given year.

          [ yyyy is a year number as an int ->
              return the GST at time yyyy-01-00T00:00 ]
        """
        #-- 1 --
        # [ janJD  :=  the Julian date of January 0.0 of year
        #              (yyyy), as a float ]
        janDT  =  datetime.datetime ( yyyy, 1, 1 )
        janJD  =  float(JulianDate.fromDatetime(janDT)) - 1.0
        #-- 2 --
        s  =  janJD - 2415020.0

        #-- 3 --
        t  =  s / 36525.0

        #-- 4 --
        r  =  ( 0.00002581 * t +
                2400.051262 ) * t + 6.6460656
        #-- 5 --
        u = r - 24 * ( yyyy-1900)

        #-- 6 --
        return 24.0 - u

    factorB = staticmethod(factorB)
# - - -   S i d e r e a l T i m e . g s t

    def gst ( self, eLong ):
        """Convert LST to GST.

          [ self is local sidereal time at longitude eLong
            radians east of Greenwich ->
              return the equivalent GST as a SiderealTime instance ]
        """
        #-- 1 --
        # [ deltaHours  :=  eLong expressed in hours ]
        deltaHours  =  radiansToHours ( eLong )

        #-- 2 --
        gstHours  =  ( self.hours - deltaHours ) % 24.0

        #-- 3 --
        return SiderealTime ( gstHours )
# - - -   S i d e r e a l T i m e . l s t

    def lst ( self, eLong ):
        """Convert GST to LST.

          [ (self is Greenwich sidereal time) and
            (eLong is a longitude east of Greenwich in radians) ->
              return a new SiderealTime representing the LST
              at longitude eLong ]
        """
        #-- 1 --
        # [ deltaHours  :=  eLong expressed in hours ]
        deltaHours  =  radiansToHours ( eLong )

        #-- 2 --
        gmtHours  =  (self.hours + deltaHours) % 24.0

        #-- 3 --
        return SiderealTime ( gmtHours )
# - - -   S i d e r e a l T i m e . f r o m D a t e t i m e

    SIDEREAL_C  =  1.002738

#   @staticmethod
    def fromDatetime ( dt ):
        """Convert civil time to Greenwich Sidereal.

          [ dt is a datetime.datetime instance ->
              if  dt has time zone information ->
                return the GST at the UTC equivalent to dt
              else ->
                return the GST assuming dt is UTC ]
        """
        #-- 1 --
        # [ if  dt is naive ->
        #     utc  :=  dt
        #   else ->
        #     utc  :=  the UTC time equivalent to dt ]
        utc  =  dt
        tz  =  dt.tzinfo
        if  tz is not None:
            offset  =  tz.utcoffset ( dt )
            if  offset is not None:
                utc  =  dt - offset
        #-- 2 --
        # [ nDays  :=  number of days between January 0.0 and utc ]
        nDays  =  dayNo ( utc )
        #-- 3 --
        t0  =  ( nDays * SIDEREAL_A -
                 SiderealTime.factorB ( utc.year ) )
        #-- 4 --
        # [ decUTC  :=  utc as decimal hours ]
        floatSec  =  utc.second + float ( utc.microsecond ) / 1e6
        decUTC  =  dmsUnits.mixToSingle (
                       (utc.hour, utc.minute, floatSec) )
        #-- 4 --
        # [ gst  :=  (decUTC * C + t0), normalized to interval [0,24) ]
        gst  =  ( decUTC * SiderealTime.SIDEREAL_C + t0) % 24.0

        #-- 5 --
        return SiderealTime ( gst )
    fromDatetime  =  staticmethod ( fromDatetime )

# - - - - -   c l a s s   A l t A z

class AltAz:
    """Represents a sky location in horizon coords. (altitude/azimuth)

      Exports/Invariants:
        .alt:   [ altitude in radians, in [-pi,+pi] ]
        .az:    [ azimuth in radians, in [0,2*pi] ]
    """
# - - -   A l t A z . _ _ i n i t _ _

    def __init__ ( self, alt, az ):
        """Constructor for AltAz, horizon coordinates.

          [ (alt is an altitude in radians) and
            (az is an azimuth in radians) ->
              return a new AltAz instance with those values,
              normalized as per class invariants ]
        """
        self.alt  =  alt
        self.az  =  az
# - - -   A l t A z . r a D e c

    def raDec ( self, lst, latLon ):
        """Convert horizon coordinates to equatorial.

          [ (lst is a local sidereal time as a SiderealTime instance) and
            (latLon is the observer's position as a LatLon instance) ->
              return the corresponding equatorial coordinates as a
              RADec instance ]            
        """
        #-- 1 --
        # [ dec  :=  declination of self at latLon in radians
        #   hourRadians  :=  hour angle of self at latlon in radians ]
        dec, hourRadians  =  coordRotate ( self.alt, latLon.lat,
                                           self.az )

        #-- 2 --
        # [ hourRadians is an hour angle in radians ->
        #     h  :=  hourRadians in hours ]
        h  =  radiansToHours ( hourRadians )
        #-- 3 --
        # [ ra  :=  right ascension for hour angle (h) at local
        #           sidereal time (lst) and location (latLon) ]
        ra  =  hoursToRadians ( ( lst.hours - h ) % 24.0 )

        #-- 4 --
        return  RADec ( ra, dec )
# - - -   A l t A z . _ _ s t r _ _

    def __str__ ( self ):
        """Convert self to a string.
        """
        #-- 1 --
        # [ altList  :=  self.alt, formatted as degrees, minutes,
        #       and seconds
        #   azList  :=  self.az, formatted as degrees, minutes, and
        #       seconds ]
        altList  =  dmsUnits.format ( dmsUnits.singleToMix ( 
            degrees(self.alt) ), lz=True, decimals=3 )
        azList  =  dmsUnits.format ( dmsUnits.singleToMix (
            degrees(self.az) ), lz=True, decimals=3 )

        #-- 2 --
        return ( "[az %sd %s' %s\" alt %sd %s' %s\"]" %
                 (tuple(azList)+tuple(altList)) )
# - - -   c o o r d R o t a t e

def coordRotate ( x, y, z ):
    """Used to convert between equatorial and horizon coordinates.

      [ x, y, and z are angles in radians ->
          return (xt, yt) where
          xt=arcsin(sin(x)*sin(y)+cos(x)*cos(y)*cos(z)) and
          yt=arccos((sin(x)-sin(y)*sin(xt))/(cos(y)*cos(xt))) ]
    """
    #-- 1 --
    xt  =  asin ( sin(x) * sin(y) +
                  cos(x) * cos(y) * cos(z) )
    #-- 2 --
    yt  =  acos ( ( sin(x) - sin(y) * sin(xt) ) /
                  ( cos(y) * cos(xt) ) )
    #-- 3 --
    if  sin(z) > 0.0:
        yt  =  TWO_PI - yt

    #-- 4 --
    return (xt, yt)

# - - - - -   c l a s s   R A D e c

class RADec:
    """Represents a celestial location in equatorial coordinates.

      Exports/Invariants:
        .ra:      [ right ascension in radians ]
        .dec:     [ declination in radians ]
    """
# - - -   R A D e c . _ _ i n i t _ _

    def __init__ ( self, ra, dec ):
        """Constructor for RADec.
        """
        self.ra  =  ra % TWO_PI
        self.dec  =  dec
# - - -   R A D e c . h o u r A n g l e

    def hourAngle ( self, utc, eLong ):
        """Find the hour angle at a given observer's location.

          [ (utc is a Universal Time as a datetime.datetime) and
            (eLong is an east longitude in radians) ->
              return the hour angle of self at that time and
              longitude, in radians ]
        """
        return  raToHourAngle ( self.ra, utc, eLong )
# - - -   R A D e c . a l t A z

    def altAz ( self, h, lat ):
        """Convert equatorial to horizon coordinates.

          [ (h is an object's hour angle in radians) and
            (lat is the observer's latitude in radians) ->
              return self's position in the observer's sky
              in horizon coordinates as an AltAz instance ]
        """
        #-- 1 --
        # [ alt  :=  altitude of self as seen from latLon at utc
        #   az  :=  azimuth of self as seen from latLon at utc ]
        alt, az  =  coordRotate ( self.dec, lat, h )

        #-- 2 --
        return AltAz ( alt, az )
# - - -   R A D e c . _ _ s t r _ _

    def __str__ ( self ):
        """Return self as a string.
        """
        #-- 1 --
        # [ raUnits  :=  units of self.ra as hours/minutes/seconds
        #   decUnits  :=  units of self.dec as degrees/minutes/seconds
        raUnits  =  dmsUnits.format (
            dmsUnits.singleToMix ( radiansToHours(self.ra) ),
            lz=True, decimals=3 )
        decUnits  =  dmsUnits.format (
            dmsUnits.singleToMix ( degrees(self.dec) ),
            lz=True, decimals=3 )

        #-- 2 --
        return ( "[%sh %sm %ss, %sd %s' %s\"]" %
                 (tuple(raUnits)+tuple(decUnits)) )
