#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# @Author: José Sánchez-Gallego (gallegoj@uw.edu)
# @Date: 2020-08-17
# @Filename: sky.py
# @License: BSD 3-clause (http://www.opensource.org/licenses/BSD-3-Clause)
# IAU-defined sky coordinate systems and transformations.
from __future__ import annotations
import ctypes
import warnings
from typing import TYPE_CHECKING
import numpy
from . import conv, defaults, sofa
from .coordinate import Coordinate, Coordinate2D, verifySite, verifyWavelength
from .exceptions import CoordIOError, CoordIOUserWarning
from .time import Time
if TYPE_CHECKING:
from .site import Site
__all__ = ['ICRS', 'Observed']
[docs]
class ICRS(Coordinate2D):
"""A representation of ICRS coordinates.
Parameters
----------
value : numpy.ndarray
A Nx2 Numpy array with the RA and Dec coordinates of the targets.
epoch : numpy.ndarray
A 1D array with the epoch of the coordinates for each target,
as a TDB Julian date (although for most applications the small
differences between scales will not matter). Defaults to J2000.
pmra : numpy.ndarray
A 1D array with the proper motion in the RA axis for the N targets,
in milliarcsec/yr. Must be a true angle, i.e, it must include the
``cos(dec)`` term.
pmdec : numpy.ndarray
A 1D array with the proper motion in the RA axis for the N targets,
in milliarcsec/yr.
parallax : numpy.ndarray
A 1D array with the parallax for the N targets, in milliarcsec.
rvel : numpy.ndarray
A 1D array with the radial velocity in km/s, positive when receding.
"""
__extra_arrays__ = ['epoch', 'pmra', 'pmdec', 'parallax', 'rvel']
def __new__(cls, value, **kwargs):
obj = super().__new__(cls, value, **kwargs)
if kwargs.get('epoch', None) is None:
obj.epoch += defaults.EPOCH
if isinstance(value, Coordinate):
if value.coordSysName == 'Observed':
obj._fromObserved(value)
else:
raise CoordIOError(
'Cannot convert to ICRS from %s' % value.coordSysName
)
return obj
def _fromObserved(self, obsCoords):
"""Converts from `.Observed` coordinates. Epoch is the
time specifified by the site.
"""
rlong = numpy.radians(obsCoords.site.longitude)
rlat = numpy.radians(obsCoords.site.latitude)
rZD = numpy.radians(90 - obsCoords[:, 0])
rAz = numpy.radians(obsCoords[:, 1])
wavelength = obsCoords.wavelength / 10000.
_type = "A".encode() # coords are azimuth, zenith dist
time = obsCoords.site.time
utc = time.to_utc()
utc1 = int(utc)
utc2 = utc - utc1
dut1 = time.get_dut1()
_ra = ctypes.c_double()
_dec = ctypes.c_double()
ra = numpy.zeros(len(obsCoords))
dec = numpy.zeros(len(obsCoords))
for ii in range(len(rAz)):
sofa.iauAtoc13(
_type, rAz[ii], rZD[ii], utc1, utc2, dut1,
rlong, rlat, obsCoords.site.altitude, 0.0, 0.0,
obsCoords.site.pressure, obsCoords.site.temperature,
obsCoords.site.rh, wavelength[ii], _ra, _dec
)
ra[ii] = numpy.degrees(_ra.value)
dec[ii] = numpy.degrees(_dec.value)
self[:, 0] = ra
self[:, 1] = dec
[docs]
def to_epoch(self, jd, site=None):
"""Convert the coordinates to a new epoch.
Parameters
----------
jd : float
The Julian date, in TAI scale, of the output epoch.
site : .Site
The site of the observation. Used to determine the TDB-TT offset.
If not provided, it assumes longitude and latitude zero.
Returns
-------
icrs : `.ICRS`
A new `.ICRS` object with the coordinates, proper motion, etc. in
the new epoch.
"""
rra = numpy.radians(self[:, 0])
rdec = numpy.radians(self[:, 1])
rpmra = numpy.radians(self.pmra / 1000. / 3600.) / numpy.cos(rdec)
rpmdec = numpy.radians(self.pmdec / 1000. / 3600.)
# Using TDB is probably an overkill.
tai = Time(jd, scale='TAI')
if site:
epoch2 = tai.to_tdb(longitude=site.longitude,
latitude=site.latitude,
altitude=site.altitude)
else:
epoch2 = tai.to_tdb()
epoch2_1 = int(epoch2)
epoch2_2 = epoch2 - epoch2_1
ra2 = ctypes.c_double()
dec2 = ctypes.c_double()
pmra2 = ctypes.c_double()
pmdec2 = ctypes.c_double()
parallax2 = ctypes.c_double()
rvel2 = ctypes.c_double()
new_icrs = ICRS(numpy.zeros(self.shape, dtype=self.dtype))
for ii in range(self.shape[0]):
epoch1_1 = float(int(self.epoch[ii]))
epoch1_2 = self.epoch[ii] - epoch1_1
res = sofa.iauPmsafe(rra[ii], rdec[ii], rpmra[ii], rpmdec[ii],
self.parallax[ii] / 1000., self.rvel[ii],
epoch1_1, epoch1_2, epoch2_1, epoch2_2,
ra2, dec2, pmra2, pmdec2, parallax2, rvel2)
if res > 1 or res < 0:
warnings.warn(f'iauPmsafe return with error code {res}.',
CoordIOUserWarning)
new_icrs[ii, :] = numpy.rad2deg([ra2.value, dec2.value])
new_icrs.pmra[ii] = numpy.rad2deg(pmra2.value) * 3600. * 1000.
new_icrs.pmra[ii] *= numpy.cos(dec2.value)
new_icrs.pmdec[ii] = numpy.rad2deg(pmdec2.value) * 3600. * 1000.
new_icrs.parallax[ii] = parallax2.value * 1000.
new_icrs.rvel[ii] = rvel2.value
new_icrs.epoch[ii] = jd
return new_icrs
[docs]
class Observed(Coordinate2D):
"""The observed coordinates of a series of targets.
The array contains the Alt/Az coordinates of the targets. Their RA/Dec
coordinates can be accessed via the ``ra`` and ``dec`` attributes.
If `.ICRS` or `.Field` is passed, Alt/Az coordinates are computed.
Parameters
----------
value : numpy.ndarray
A Nx2 Numpy array with the Alt and Az coordinates of the targets,
in degrees. Or `.ICRS` instance. Or a `.Field` instance.
wavelength : numpy.ndarray
A 1D array with he observing wavelength, in angstrom.
If not explicitly passed, it tries to inheret from value.wavelength,
if that doesn't exist, it is set to default specified in:
`defaults.wavelength`
site : .Site
The site from where observations will occur, along with the time
of the observation. Mandatory argument.
Attributes
-----------
ra : numpy.ndarray
Nx1 Numpy array, observed RA in degrees
dec : numpy.ndarray
Nx1 Numpy array, observed Dec in degrees
ha : numpy.ndarray
Nx1 Numpy array, hour angle in degrees
pa : numpy.ndarray
Nx1 Numpy array, position angle in degrees. By SOFA: the angle between
the direction to the north celestial pole and direction to the zenith.
range is [-180, 180]. The sign is according to:
-ha --> -pa, +ha --> +pa
"""
__extra_arrays__ = ['wavelength']
__extra_params__ = ['site'] # mandatory
__computed_arrays__ = ['ra', 'dec', 'ha', 'pa']
ra: numpy.ndarray
dec: numpy.ndarray
ha: numpy.ndarray
pa: numpy.ndarray
wavelength: numpy.ndarray
site: Site
def __new__(cls, value, **kwargs):
# should we do range checks (eg alt < 90)? probably.
verifySite(kwargs)
verifyWavelength(kwargs, len(value), strict=False)
obj = super().__new__(cls, value, **kwargs)
# check if a coordinate was passed that we can just
# 'cast' into Observed
if isinstance(value, Coordinate):
if value.coordSysName == 'ICRS':
obj._fromICRS(value)
elif value.coordSysName == 'Field':
obj._fromField(value)
else:
raise CoordIOError(
'Cannot convert to Observed from %s' % value.coordSysName
)
else:
# raw numpy array supplied compute values
obj._fromRaw()
return obj
def _fromICRS(self, icrsCoords):
"""Converts from ICRS to topocentric observed coordinates for a site.
Automatically executed after initialization with `.ICRS`.
Computes and sets ra, dec, ha, pa arrays.
Parameters:
------------
icrsCoords : `.ICRS`
ICRS coordinates from which to convert to observed coordinates
"""
# eventually move this to coordio.conv?
# Prepare to call iauAtco13
# Given:
# rc,dc double ICRS right ascension at J2000.0 (radians)
# pr double RA proper motion (radians/year)
# pd double Dec proper motion (radians/year)
# px double parallax (arcsec)
# rv double radial velocity (km/s, +ve if receding)
# utc1 double UTC as a 2-part...
# utc2 double ...quasi Julian Date
# dut1 double UT1-UTC (seconds)
# elong double longitude (radians, east +ve)
# phi double latitude (geodetic, radians)
# hm double height above ellipsoid (m, geodetic)
# xp,yp double polar motion coordinates (radians)
# phpa double pressure at the observer (hPa = mB)
# tc double ambient temperature at the observer (deg C)
# rh double relative humidity at the observer (range 0-1)
# wl double wavelength (micrometers)
#
# Returned:
# aob double* observed azimuth (radians: N=0,E=90)
# zob double* observed zenith distance (radians)
# hob double* observed hour angle (radians)
# dob double* observed declination (radians)
# rob double* observed right ascension (CIO-based, radians)
# eo double* equation of the origins (ERA-GST)
# TODO: maybe write this as Cython or C?
# We need the epoch to be J2000.0 because that's what iauAtco13 likes.
icrs_2000 = icrsCoords.to_epoch(2451545.0, site=self.site)
rra = numpy.radians(icrs_2000[:, 0])
rdec = numpy.radians(icrs_2000[:, 1])
rpmra = numpy.radians(icrs_2000.pmra / 1000. / 3600.) / numpy.cos(rdec)
rpmdec = numpy.radians(icrs_2000.pmdec / 1000. / 3600.)
rlong = numpy.radians(self.site.longitude)
rlat = numpy.radians(self.site.latitude)
time = self.site.time
utc = time.to_utc()
utc1 = int(utc)
utc2 = utc - utc1
dut1 = time.get_dut1()
az_obs = ctypes.c_double()
zen_obs = ctypes.c_double()
ha_obs = ctypes.c_double()
dec_obs = ctypes.c_double()
ra_obs = ctypes.c_double()
eo_obs = ctypes.c_double()
for ii in range(len(rra)):
sofa.iauAtco13(
rra[ii], rdec[ii], rpmra[ii], rpmdec[ii],
icrs_2000.parallax[ii] / 1000., icrs_2000.rvel[ii],
utc1, utc2, dut1,
rlong, rlat, self.site.altitude, 0.0, 0.0,
self.site.pressure, self.site.temperature,
self.site.rh, self.wavelength[ii] / 10000.,
az_obs, zen_obs, ha_obs, dec_obs, ra_obs, eo_obs
)
altAz = [
90 - numpy.rad2deg(zen_obs.value),
numpy.rad2deg(az_obs.value)
]
self[ii, :] = altAz
self.ra[ii] = numpy.rad2deg(ra_obs.value)
self.dec[ii] = numpy.rad2deg(dec_obs.value)
self.ha[ii] = numpy.rad2deg(ha_obs.value)
# compute the pa
self.pa[ii] = numpy.rad2deg(
sofa.iauHd2pa(ha_obs.value, dec_obs.value, rlat)
)
def _fromField(self, fieldCoords):
"""Converts from field coordinates to topocentric observed
coordinates for a site. Automatically executed after initialization
with `.Field`.
Computes and sets ra, dec, ha, pa arrays.
Parameters:
------------
fieldCoords : `.Field`
Field coordinates from which to convert to observed coordinates
"""
# get field center info
altCenter, azCenter = fieldCoords.field_center.flatten()
pa = float(fieldCoords.field_center.pa) # parallactic angle
alt, az = conv.fieldToObserved(
fieldCoords.x, fieldCoords.y, fieldCoords.z,
altCenter, azCenter, pa
)
self[:, 0] = alt
self[:, 1] = az
self._fromRaw()
def _fromRaw(self):
"""Automatically executed after initialization with
an Nx2 `numpy.ndarray` of Alt/Az coords.
Computes and sets ra, dec, ha, pa arrays.
"""
self[:, 1] = self[:, 1] % 360
# compute ra, dec, ha, pa here...
dec_obs = ctypes.c_double()
ha_obs = ctypes.c_double()
rlat = numpy.radians(self.site.latitude)
rlong = numpy.radians(self.site.longitude)
ut1 = self.site.time.to_ut1()
for ii, (alt, az) in enumerate(self):
raz = numpy.radians(az)
ralt = numpy.radians(alt)
sofa.iauAe2hd(raz, ralt, rlat, ha_obs, dec_obs)
self.ha[ii] = numpy.degrees(ha_obs.value)
self.dec[ii] = numpy.degrees(dec_obs.value)
self.pa[ii] = numpy.degrees(
sofa.iauHd2pa(ha_obs.value, dec_obs.value, rlat)
)
# earth rotation angle (from SOFA docs)
# https://www.iausofa.org/2017_0420_C/sofa/sofa_ast_c.pdf
era = sofa.iauEra00(ut1, 0) # time is sum of the 2 args
_ra = numpy.degrees(era + rlong - ha_obs.value)
_ra = _ra % 360 # wrap ra
self.ra[ii] = _ra
[docs]
@classmethod
def fromEquatorial(cls, coords, hadec=False, site=None, **kwargs):
"""Initialises `.Observed` coordinates from equatorial topocentric.
Parameters
----------
coords : numpy.ndarray
Nx2 Numpy array with the RA and Dec coordinates of the targets.
These must be topocentric equatorial coordinates that will be
converted to horizontal topocentric used to initialise `.Observed`.
Alternatively, if ``hadec=True`` then the first column in the array
must be the hour angle in degrees.
hadec : bool
Whether the coordinates are HA and declination.
site : .Site
The site from where observations will occur, along with the time
of the observation. Mandatory argument.
kwargs
Other arguments to pass to `.Observed`.
"""
coords = numpy.array(coords)
if len(coords.shape) != 2 or coords.shape[1] != 2:
raise CoordIOError('coords must be Nx2 array.')
if site is None:
raise CoordIOError('A site must be specified.')
lat_r = numpy.radians(site.latitude)
dec_d = coords[:, 1]
dec_r = numpy.radians(dec_d)
if hadec is False:
ra_d = coords[:, 0]
ra_r = numpy.radians(ra_d)
ut = site.time.to_ut1()
tt = site.time.to_tt()
gmst = sofa.iauGmst00(ut, 0.0, tt, 0.0)
lst_r = gmst + numpy.radians(site.longitude)
ha_r = lst_r - ra_r
else:
ha_r = numpy.radians(coords[:, 0])
# h = altitude, A = azimuth
sin_h = (numpy.sin(dec_r) * numpy.sin(lat_r) +
numpy.cos(dec_r) * numpy.cos(lat_r) * numpy.cos(ha_r))
h_r = numpy.arcsin(sin_h)
h_d = numpy.degrees(h_r)
sin_A = -numpy.sin(ha_r) * numpy.cos(dec_r) / numpy.cos(h_r)
cos_A = ((numpy.sin(dec_r) - numpy.sin(lat_r) * numpy.sin(h_r)) /
(numpy.cos(lat_r) * numpy.cos(h_r)))
A_r = numpy.arctan2(sin_A, cos_A)
A_d = numpy.degrees(A_r) % 360
return cls(numpy.array([h_d, A_d]).T, site=site, **kwargs)