#!/usr/bin/env python
# encoding: utf-8
#
# ifu.py
#
# Created by José Sánchez-Gallego on 5 Sep 2017.
from __future__ import absolute_import, division, print_function
import matplotlib.collections
import matplotlib.patches
import matplotlib.pyplot
import numpy
import astropy.units
from astropy.coordinates.angle_utilities import position_angle
import lvmsurveysim
from lvmsurveysim import config
import lvmsurveysim.utils.geodesic_sphere
__all__ = ['IFU']
[docs]def fibres_to_rows(fibres):
"""Calculates the number of rows for a set of fibres.
Given a total number of ``fibres``, returns the number rows. Given that the
IFU is an hexagon, the central row has as many fibres as rows in the IFU.
It returns ``None`` if the number of fibres can not be arranged in a
full IFU.
"""
tmp_fibres = 0
n_central = 1
while tmp_fibres <= fibres:
n_central += 2 # Central row needs to be odd
n_rows = int(1 + 2 * numpy.floor(n_central / 2.))
tmp_fibres = n_central + 2 * numpy.sum(numpy.arange(n_central - 1, n_central / 2., -1))
if tmp_fibres == fibres:
return n_rows
class EqTransform(object):
'''
The transformation between the two equatorial coordinate systems (for example to galactic):
1. a rotation around the celestial polar axis by, so that the reference zero longitude matches the node
2. a rotation around the node by the inclination of the new equator
3. a rotation around the new polar axis so that the zero new longitude meridian matches the input.
Parameters:
RA_NGP : float
RA coordinate of new north pole in old system
DEC_NGP : float
DEC coordinate of new north pole in old system
L_CP : float
longitude of the old pole in the new system
For example:
EqTransform(192.8594, 27.1282, 122.9319)
transforms from icrs to Galactic coordinates and back.
'''
def __init__(self, RA_NGP, DEC_NGP, L_CP):
self.RA_NGP = numpy.deg2rad(RA_NGP) # Galactic: numpy.deg2rad(192.8594812065348)
self.DEC_NGP = numpy.deg2rad(DEC_NGP) # Galactic: numpy.deg2rad(27.12825118085622)
self.L_CP = numpy.deg2rad(L_CP) # Galactic: numpy.deg2rad(122.9319185680026)
self.L_0 = self.L_CP - numpy.pi / 2.
self.RA_0 = self.RA_NGP + numpy.pi / 2.
self.DEC_0 = numpy.pi / 2. - self.DEC_NGP
def eq2gal(self, ra, dec):
'''
Forward transform from old to new system
'''
ra, dec = numpy.deg2rad(numpy.array(ra, ndmin=1)), numpy.deg2rad(numpy.array(dec, ndmin=1))
numpy.sinb = numpy.sin(dec) * numpy.cos(self.DEC_0) - numpy.cos(dec) * numpy.sin(ra - self.RA_0) * numpy.sin(self.DEC_0)
b = numpy.arcsin(numpy.sinb)
cosl = numpy.cos(dec) * numpy.cos(ra - self.RA_0) / numpy.cos(b)
sinl = (numpy.sin(dec) * numpy.sin(self.DEC_0) + numpy.cos(dec) * numpy.sin(ra - self.RA_0) * numpy.cos(self.DEC_0)) / numpy.cos(b)
return self._normalize_angles(cosl, sinl, self.L_0, b)
def gal2eq(self, l, b):
'''
Backwards transform from new to old system
'''
l, b = numpy.deg2rad(numpy.array(l, ndmin=1)), numpy.deg2rad(numpy.array(b, ndmin=1))
sind = numpy.sin(b) * numpy.sin(self.DEC_NGP) + numpy.cos(b) * numpy.cos(self.DEC_NGP) * numpy.sin(l - self.L_0)
dec = numpy.arcsin(sind)
cosa = numpy.cos(l - self.L_0) * numpy.cos(b) / numpy.cos(dec)
sina = (numpy.cos(b) * numpy.sin(self.DEC_NGP) * numpy.sin(l - self.L_0) - numpy.sin(b) * numpy.cos(self.DEC_NGP)) / numpy.cos(dec)
return self._normalize_angles(cosa, sina, self.RA_0, dec)
def _normalize_angles(self, cosa, sina, ra0, dec):
'''
Make sure everything ends up in the right quadrant
'''
dec = numpy.rad2deg(dec)
cosa[cosa < -1.0] = -1.0
cosa[cosa > 1.0] = 1.0
ra = numpy.arccos(cosa)
ra[numpy.where(sina < 0.)] *= -1.0
ra = numpy.rad2deg(ra + ra0)
ra = numpy.mod(ra, 360.)
dec = numpy.mod(dec + 90., 180.) - 90.
return ra, dec
def transform_coords(lat, lon, transform):
'''
Transform a set of spherical coordinates and caluculate the change of position angle
The output is transform(lat, lon) and the position angle N through E
Parameters:
-----------
lat, lon : array-like
lattitude and longitude input coordinates
transform : function
function taking lat, lon inputs and yielding new lat, lon outputs
Return:
-------
lat2, lon2 : `~numpy.array`
output lattitude and longitude coordinates
pa : `~numpy.array`
position angle at new coordinates relative to the old coordinates
'''
lat2, lon2 = transform(lat, lon + 1./3600.)
lat1, lon1 = transform(lat, lon)
pa = position_angle(numpy.deg2rad(lat1), numpy.deg2rad(lon1), numpy.deg2rad(lat2), numpy.deg2rad(lon2))
return numpy.array([lat1, lon1]).T, numpy.rad2deg(pa)
class Fibre(object):
def __init__(self, xx, yy, radius):
self.x = xx
self.y = yy
self.radius = radius
@property
def patch(self):
return matplotlib.patches.Circle((self.x, self.y), radius=self.radius,
edgecolor='k', facecolor='None', lw=1)
[docs]class SubIFU(object):
"""Represents a sub-IFU (group of contiguous fibres) within a larger IFU.
A sub-IFU is an hexagonal group of contiguous fibres, separated from other
groups of fibres. LVM IFUs are formed by a series of sub-IFUs (a
monolithic IFU can be considered as an IFU with a single sub-IFU). It is
assumed that all sub-IFUs in an IFU have the same size.
Parameters:
n_subifu (int):
An integer used to identify the sub-IFU.
parent (:class:`IFU` object)
The parent :class:`IFU` object.
centre (tuple):
A 2D tuple describing the centre of the sub-IFUs. It asumes the
diameter of the sub-IFU hexagon is 1.
n_fibres (int):
Number of fibres in the sub-IFUs.
fibre_size (float):
The real size, in microns, of each fibre including buffer.
"""
def __init__(self, id_subifu, parent, centre, n_fibres, fibre_size=None):
assert isinstance(centre, (list, tuple, numpy.ndarray)), 'centre is not a list'
#assert isinstance(parent, IFU), 'parent must be an IFU object.'
assert len(centre) == 2, 'centre must be a 2D tuple.'
assert n_fibres is not None, 'incorrect inputs.'
self.id_subifu = id_subifu
self.parent = parent
self.n_fibres = n_fibres
self.n_rows = fibres_to_rows(self.n_fibres)
self.centre = numpy.array(centre)
self.fibre_size = fibre_size
if not isinstance(self.fibre_size, astropy.units.Quantity):
if not self.fibre_size:
self.fibre_size = config['ifu']['fibre_size']
self.fibre_size *= astropy.units.micron
self.polygon = self._create_polygon()
self.fibres = self._create_fibres()
def _create_polygon(self, pointy_top=True):
"""Creates a list of polygon vertices representing the shape of this sub-IFU."""
RR = 0.5 # Assumes unitary diameter, outer radius
vertices = []
cx, cy = self.centre
for i in range(6):
angle_rad = numpy.radians(60 * i - 30)
vertices.append((cx+RR*numpy.cos(angle_rad), cy+RR*numpy.sin(angle_rad)))
return vertices
def _create_fibres(self):
"""Creates Fibre objects for each of the fibres in the sub-IFU."""
n_centre = self.n_rows
if self.n_rows is None:
raise ValueError('incorrect number if fibres. Cannot create a sub-IFU.')
fibres = []
fibre_width = 1 / n_centre
# creates flat top hex pattern
for row in range(int(-self.n_rows / 2), int(self.n_rows / 2) + 1):
n_fibres_row = n_centre - numpy.abs(row)
y_row = row * numpy.sqrt(3) / 2 / self.n_rows
row_length = 1 - 2 * numpy.abs(y_row) * numpy.sqrt(3) / 3.
fibres_x = (numpy.arange(n_fibres_row) * fibre_width -
row_length / 2. + fibre_width / 2.)
fibres += [Fibre(self.centre[0] + fibre_x,
self.centre[1] + y_row,
fibre_width / 2.) for fibre_x in fibres_x]
# rotate to pointy top
s30 = numpy.sin(numpy.radians(30))
c30 = numpy.cos(numpy.radians(30))
for f in fibres:
x = c30*(f.x - self.centre[0]) + s30*(f.y - self.centre[1])
y = -s30*(f.x - self.centre[0]) + c30*(f.y - self.centre[1])
f.x = x + self.centre[0]
f.y = y + self.centre[1]
return fibres
[docs] def get_patch(self, scale=None, centre=None, pa=None, **kwargs):
"""Returns a matplotlib patch for the sub-IFU.
Parameters
----------
scale : ~astropy.units.Quantity or float
The plate scale to be used to convert the IFU to on-sky distances.
Either a `~astropy.units.Quantity` or a value in degrees/mm.
centre : list
The coordinates of the centre of the IFU on the sky.
pa : float
The position angle of the IFU on the sky in degrees. Ignored if None.
kwargs : dict
Parameters to be passed to `~matplotlib.patches.Polygon` when
creating the patch.
Returns
-------
patch : `~matplotlib.patches.Polygon`
A Matplotlib patch with the sub-ifu. If scale and centre are
passed, the coordinates of the patch are on-sky.
"""
if scale is not None and isinstance(scale, astropy.units.Quantity):
scale = scale.to('degree/mm').value
vertices = numpy.array(self.polygon)
# rotate to posotion angle!
if pa != None:
c, s = numpy.cos(numpy.deg2rad(pa)), numpy.sin(numpy.deg2rad(pa))
x = c*vertices[:, 0] + s*vertices[:, 1]
y = -s*vertices[:, 0] + c*vertices[:, 1]
vertices = numpy.array([x, y]).T
if scale:
# Calculates the radius in degrees on the sky
rr_deg = self.n_rows * self.fibre_size.to('mm').value * scale / 2.
vertices *= rr_deg * 2
if centre:
assert scale is not None, 'cannot define a centre without scale.'
centre = numpy.array(centre)
# Calculate declinations first so that we can scale RA for
# all the vertices.
decs = vertices[:, 1] + centre[1]
ras = vertices[:, 0] / numpy.cos(numpy.deg2rad(decs)) + centre[0]
vertices = numpy.array([ras, decs]).T
return matplotlib.patches.Polygon(vertices, **kwargs)
[docs] def get_patch_collection(self, ax):
"""Returns a collection of fibre patches."""
n_centre = self.n_rows
fibre_width = 1 / n_centre * 0.8 # 0.8 to make plotting look nicer.
fibres = self.fibres
fibres_patch = matplotlib.collections.EllipseCollection(
[fibre_width] * len(fibres), [fibre_width] * len(fibres), [0] * len(fibres),
offsets=[[fibre.x, fibre.y] for fibre in fibres],
transOffset=ax.transData,
lw=1, edgecolor='k', facecolor='None',
units='x')
return fibres_patch
[docs]class IFU(object):
"""A generic class representing a LVM hexagonal IFU.
This class is intended to be subclassed into real examples of IFU
designs that LVM will use. An ``IFU`` is defined by a series of sub-IFU
centres (or a single one for a monolithic IFU) and a fibre size, so
that the real size of the IFU on the sky can be calculated.
Parameters:
n_fibres (int):
Number of fibres in each of the sub-IFUs.
centres (list):
A list of 2D tuples describing the centres of each of the
sub-IFUs. It assumes the diameter of the sub-IFU hexagon is 1.
padding (int):
Number of fibres the IFU should overlap when tiling. This will
be used to slightly modify the distance between sub-IFUs.
fibre_size (float):
The real size, in microns, of each fibre including buffer.
gaps (list):
If the IFU is composed of multiple sub-IFUs and gaps exist
between them, a list with the same format of ``centres`` must
be provided, with the list of the hexagonal gap centres.
allow_rotation (bool):
Whether this IFU can be rotated.
"""
def __init__(self, n_fibres=None, centres=None, padding=0,
fibre_size=None, allow_rotation=False, name=None):
assert isinstance(centres, (list, tuple, numpy.ndarray)), 'centres is not a list'
assert len(centres) > 0, 'centres must be a non-zero length list.'
for ll in centres:
assert isinstance(ll, (list, tuple, numpy.ndarray)), 'each centre must be a 2D tuple.'
assert len(ll) == 2, 'each centre must be a 2D tuple.'
assert n_fibres is not None, 'incorrect n_fibres input.'
self.name = name
self.centres = numpy.atleast_2d(centres)
self.n_fibres = n_fibres
self.n_subifus = self.centres.shape[0]
assert self.n_fibres % self.n_subifus == 0, \
'number of fibres is not a multiple of number of sub-IFUs.'
self.fibre_size = fibre_size
if not isinstance(self.fibre_size, astropy.units.Quantity):
if not self.fibre_size:
self.fibre_size = config['ifu']['fibre_size']
self.fibre_size *= astropy.units.micron
self.padding = padding
self.subifus = self._create_subifus()
# list of lists of vertices
self.polygon = [subifu.polygon for subifu in self.subifus]
self.allow_rotation = allow_rotation
def __repr__(self):
return f'<IFU (name={self.name!r}, n_fibres={self.n_fibres}, centres={self.centres!s})>'
[docs] @classmethod
def from_config(cls):
"""Returns an `.IFU` object from the configuration file."""
ifu_conf = config['ifu'].copy()
name = ifu_conf.pop('type', None)
return cls(name=name, **ifu_conf)
def _create_subifus(self):
"""Creates each one of the individual sub-IFUs in this IFU."""
subifus = []
n_subifu = 1
for centre in self.centres:
subifus.append(SubIFU(n_subifu, self, centre, self.n_fibres / self.n_subifus,
fibre_size=self.fibre_size))
n_subifu += 1
return subifus
[docs] def get_patch(self, **kwargs):
"""Returns a matplotlib patch for the IFU.
Parameters
----------
kwargs : dict
Parameters to be passed to `.SubIFU.get_patch`.
"""
return [subifu.get_patch(**kwargs) for subifu in self.subifus]
[docs] def get_ifu_size(self, scale, tile_overlap, sparse):
'''Returns the size of the IFU in degrees
Calculate and return the size of the IFU in degrees in two dimensions, the
first from center to corner of the hexagon (outer radius), the second from
center to edge (inner radius).
Take into account the amount of overlap we want and the sparse tiling.
Parameters:
-----------
scale : float
The plate scale in degrees per mm.
tile_overlap : float
The fraction of tile separation to overlap between neighboring tiles
(ignored for sparse targets)
sparse : float
Factor for sparse sampling. Stretches IFU length scale by the number.
Returns:
--------
dx, dy : tuple of float
The extent of the hexagonal IFU in degrees from center to corner and center to edge
scaled by the desired overlap.
'''
assert len(self.subifus)==1, "Size of ifus with subunits not implemented."
n_rows = self.subifus[0].n_rows
dy = n_rows * self.fibre_size / 1000 * scale / 2. * sparse * (1.0-tile_overlap)
dx = numpy.sqrt(3) / 2. * dy
return dx, dy
[docs] def get_tile_grid(self, region, scale, tile_overlap=None, sparse=None, geodesic=None):
"""Returns a grid of positions that tile a region with this IFU.
Parameters
----------
region : ~lvmsurveysim.target.SkyRegion
The SkyRegion to tile. It is assumed that x coordinates are RA
and y is Declination, both in degrees.
scale : float
The scale in degrees per mm.
tile_overlap : float
The fraction of tile separation to overlap between neighboring tiles
(ignored for sparse targets)
sparse : float
Factor for sparse sampling. Stretches IFU length scale by the number.
geodesic : use geodesic sphere tiling, sparse gives depth in this case.
"""
if isinstance(scale, astropy.units.Quantity):
scale = scale.to('degree/mm').value
points = []
# Calculates the radius and apotheme of each subifu in degrees on the sky
if sparse==None:
tile_overlap = tile_overlap or 0.0
else:
tile_overlap = 0.0
sparse = sparse if sparse!=None else 1.0
ifu_phi_size, ifu_theta_size = self.get_ifu_size(scale, tile_overlap, sparse)
# we are using an angular system theta, phi, with theta counted from the equator
if geodesic == False:
# Determine the centroid and bounds of the region
centroid = numpy.array(region.centroid())
minphi, mintheta, maxphi, maxtheta = region.bounds()
# TODO: transform the Region object rather than the bounds! Add method to SkyRegion?
# Transform to a coordinate system where the region is on the equator. There we're natrually tiling
# along great circles and the hexagon pattern is easiest to describe.
Eq = EqTransform(centroid[0], 90.+centroid[1], 0.0)
centroid = Eq.eq2gal(centroid[0], centroid[1])
minphi, mintheta = Eq.eq2gal(minphi, mintheta)
maxphi, maxtheta = Eq.eq2gal(maxphi, maxtheta)
# transform might have caused issues with mod(360), so correct:
if minphi >= maxphi:
maxphi += 360.
# The size of the grid in phi and theta, in degrees.
size_phi = numpy.abs(maxphi - minphi) * numpy.cos(numpy.radians(centroid[1]))
size_theta = numpy.abs(maxtheta - mintheta)
# The separation between grid points in angles (phi ~ RA/lon, theta ~ DEC/lat)
delta_phi = ifu_phi_size*2.0
delta_theta = 3.0/2.0 * ifu_theta_size
# Calculates the initial positions of the grid points in RA and Dec.
phi_pos = numpy.arange(-size_phi / 2., size_phi / 2. + delta_phi.value, delta_phi.value)
theta_pos = numpy.arange(-size_theta / 2., size_theta / 2. + delta_theta.value, delta_theta.value)
points = numpy.zeros((len(theta_pos), len(phi_pos), 2))
# Offset each other row in phi by 1.5R
points[:, :, 0] = phi_pos
points[:, :, 0][1::2] += (ifu_phi_size.value)
# Set declination values
points[:, :, 1] = theta_pos[numpy.newaxis].T
# The separations in the phi axis must be converted to coordinate distances, but the cos(theta)
# term cancels with the shortening of the length of the circle at theta
points[:, :, 1] += centroid[1]
points[:, :, 0] += centroid[0]
# Reshape into a 2D list of points.
points = points.reshape((-1, 2))
# transform back to original coordinates and determine position angle
points, pa = transform_coords(points[:,0], points[:,1], Eq.gal2eq)
else:
x, y, z = lvmsurveysim.utils.geodesic_sphere.sphere(int(sparse))
sk = astropy.coordinates.SkyCoord(x=x,y=y,z=z, representation_type='cartesian')
sk.representation_type='spherical'
points = numpy.zeros((len(sk),2))
points[:,0] = sk.ra.deg
points[:,1] = sk.dec.deg
pa = numpy.zeros(len(sk))
# Check what grid points would overlap with the region if occupied by an IFU.
inside = [region.contains_point(x,y) for x,y in zip(points[:,0], points[:,1])]
points_inside = points[inside]
pa = pa[inside]
return points_inside, pa
[docs] def plot(self, show_fibres=False, fill=False):
"""Plots the IFU."""
fig, ax = matplotlib.pyplot.subplots()
for subifu in self.subifus:
ax.add_patch(subifu.get_patch(fill=fill, edgecolor='r', linewidth=1, alpha=0.5))
if show_fibres:
ax.add_collection(subifu.get_patch_collection(ax))
ax.autoscale_view()
return fig