Source code for lvmsurveysim.target.skyregion

#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# @Author: José Sánchez-Gallego (gallegoj@uw.edu)
# @Date: 2017-10-17
# @Filename: region.py
# @License: BSD 3-clause (http://www.opensource.org/licenses/BSD-3-Clause)
#
# @Last modified by: José Sánchez-Gallego (gallegoj@uw.edu)
# @Last modified time: 2019-03-13 11:32:39

import astropy.units as u
from astropy.coordinates import SkyCoord
import matplotlib.patches
import matplotlib.path
import matplotlib.transforms
import numpy
from copy import deepcopy

from spherical_geometry import polygon as sp
from lvmsurveysim.utils import plot as lvm_plot
from lvmsurveysim.exceptions import LVMSurveyOpsError, LVMSurveyOpsWarning

from . import _VALID_FRAMES


__all__ = ['SkyRegion']


# if we want to inherit: 
#super(SubClass, self).__init__('x')

[docs]class SkyRegion(object): """ This class represents a region on the sky. This class represents a region on the sky, parameterized either by one of a number of common shapes, or by a set of vertices of a polygon. Internally all shapes are held as `~spherical_geometry.polygon` object so that the edges of the polygons are great circle segments on a sphere. The class provides convenience methods to construct such polygons using the Target parameterization from the target yaml file. It also provides methods to compute intersections between the regions and whether a point is contained in the region (used later in tiling). Parameters ---------- typ : str String describing the shape, one of 'circle', 'ellipse', 'rectangle' 'polygon' or 'raw'. Depending on the value of this parameter, we expect to find further parameters in **kwargs. coords : tuple of float Center coordinates for 'circle', 'ellipse', 'rectangle' regions, or tuple of vertices for 'polygon' in degrees. For 'raw', we expect the `~spherical_geometry.SphericalPolygon` object. **kwargs : dict Must contain keyword 'frame' set to 'icrs' or 'galactic'. For 'rectangle' must contain 'width' and 'height' in degrees and 'pa' a position angle (N through E) also in degrees. For 'circle' must contain 'r' with radius in degrees. For 'ellipse' must contain 'a', 'b', 'pa' with semi-axes and position angle in degrees. For 'raw' we expect only the 'frame' to be passed as a keyword argument. """ def __init__(self, typ, coords, **kwargs): #print(typ, coords, kwargs) self.region_type = typ self.frame = kwargs['frame'] if typ == 'rectangle': self.center = coords width = kwargs['width'] height = kwargs['height'] x0 = - width / 2. x1 = + width / 2. y0 = - height / 2. y1 = + height / 2. x, y = self._rotate_coords([x0, x1, x1, x0, x0], [y0, y0, y1, y1, y0], kwargs['pa']) x, y = self._polygon_perimeter(x, y) y += self.center[1] x = x / numpy.cos(numpy.deg2rad(y)) + self.center[0] self.region = sp.SphericalPolygon.from_radec(x, y, degrees=True) elif typ == 'circle': self.center = coords r = kwargs['r'] k = int(numpy.max([numpy.floor(numpy.sqrt(r * 20)), 24])) x = numpy.array(list(reversed([r * numpy.cos(2.0*numpy.pi/k * i) for i in range(k+1)]))) y = numpy.array(list(reversed([r * numpy.sin(2.0*numpy.pi/k * i) for i in range(k+1)]))) y += self.center[1] x = x / numpy.cos(numpy.deg2rad(y)) + self.center[0] self.region = sp.SphericalPolygon.from_radec(x, y, center=self.center, degrees=True) # self.region = sp.SphericalPolygon.from_cone(coords[0], coords[1], kwargs['r']) # self.center = coords elif typ == 'ellipse': self.center = coords a, b = kwargs['a'], kwargs['b'] k = int(numpy.max([numpy.floor(numpy.sqrt(((a + b) / 2) * 20)), 24])) x = list(reversed([a * numpy.cos(2.0*numpy.pi/k * i) for i in range(k+1)])) y = list(reversed([b * numpy.sin(2.0*numpy.pi/k * i) for i in range(k+1)])) x, y = self._rotate_coords(x, y, kwargs['pa']) y += self.center[1] x = x / numpy.cos(numpy.deg2rad(y)) + self.center[0] self.region = sp.SphericalPolygon.from_radec(x, y, center=self.center, degrees=True) elif typ == 'polygon': self.region_type = 'polygon' x, y = self._rotate_vertices(numpy.array(coords), 0.0) x, y = self._polygon_perimeter(x, y) self.center = [numpy.average(x), numpy.average(y)] self.region = sp.SphericalPolygon.from_radec(x, y, center=self.center, degrees=True) elif typ == 'raw': assert isinstance(coords, sp.SphericalPolygon), 'Raw SkyRegion requires SphericalPolygon.' self.region_type = 'polygon' self.region = coords self.frame = kwargs['frame'] x, y = next(self.region.to_lonlat()) self.center = [numpy.average(x), numpy.average(y)] else: raise LVMSurveyOpsError('Unknown region type '+typ) def __repr__(self): return f'<SkyRegion(type={self.region_type}, center={self.center}, frame={self.frame})>'
[docs] def vertices(self): """ Return a `~numpy.array` of dimension Nx2 with the N vertices of the SkyRegion. """ i = self.region.to_lonlat() return numpy.array(next(i)).T
[docs] def bounds(self): """ Return a tuple of the bounds of the SkyRegion defined as the minimum and maximum value of the coordinates in each dimension. """ x, y = next(self.region.to_lonlat()) return numpy.min(x), numpy.min(y), numpy.max(x), numpy.max(y)
[docs] def centroid(self): """ Return the center coordinates of the SkyRegion """ return self.center
[docs] def intersects_poly(self, other): """ Return True if the SkyRegion intersects another SkyRegion. """ assert self.frame == other.frame, "SkyRegions must be in the same coordinate frame for intersection." return self.region.intersects_poly(other.region)
[docs] def contains_point(self, x, y): """ Return True if the point (x,y) is inside the region, false otherwise. """ return self.region.contains_lonlat(x, y, degrees=True)
[docs] def icrs_region(self): """ Return a copy of the region transformed into the ICRS system. A deep-copy of the region with vertices transformed into the ICRS system is returned if the region is in any other reference frame. Returns ------- `.SkyRegion` with vertices in the ICRS system. """ r2 = deepcopy(self) if self.frame == 'icrs': return r2 else: r2.frame = 'icrs' x, y = next(self.region.to_lonlat()) c = SkyCoord(self.center[0]*u.deg, self.center[1]*u.deg).transform_to('icrs') s = SkyCoord(x*u.deg, y*u.deg, frame=self.frame).transform_to('icrs') r2.center = [c.ra.deg, c.dec.deg] r2.region = sp.SphericalPolygon.from_radec(s.ra.deg, s.dec.deg, degrees=True) return r2
[docs] @classmethod def multi_union(cls, regions): """ Return a new SkyRegion of the union of the SkyRegions in `regions`. """ def fchecker(f1, f2): assert f1==f2, "multi union must have uniform frame" return True frame = regions[0].frame [fchecker(frame, r.frame) for r in regions] return cls('raw', sp.SphericalPolygon.multi_union([r.region for r in regions]), frame=frame)
@classmethod def _polygon_perimeter(cls, x, y, n=1.0, min_points=5): """ Subsample a polygon perimeter. This function returns new vertices along the perimeter of a polygon spaced `.n` degrees apart. Parameters ---------- x, y : array-like x and y coordinates of the vertices of the original polygon. n : float optional, spacing for new vertices. defaults to 1.0 min_points : int optional, minimal number of new points, defaults to 5 Returns ------- xp, yp : `~numpy.array` coordinates of new vertices """ xp = numpy.array([]) yp = numpy.array([]) for x1,x2,y1,y2 in zip(x[:-1], x[1:], y[:-1], y[1:]): # Calculate the length of a segment, hopefully in degrees dl = ((x2-x1)**2 + (y2-y1)**2)**0.5 n_dl = numpy.max([int(dl/n), min_points]) if x1 != x2: m = (y2-y1)/(x2-x1) b = y2 - m*x2 interp_x = numpy.linspace(x1, x2, num=n_dl, endpoint=False) interp_y = interp_x * m + b else: interp_x = numpy.full(n_dl, x1) interp_y = numpy.linspace(y1,y2, n_dl, endpoint=False) xp = numpy.append(xp, interp_x) yp = numpy.append(yp, interp_y) return xp, yp
[docs] def plot(self, ax=None, projection='rectangular', return_patch=False, **kwargs): """Plots the region. Parameters ---------- ax : `~matplotlib.axes.Axes` The axes to use. If `None`, new axes will be created. projection (str): The projection to use. At this time, only ``rectangular`` and ``mollweide`` are accepted. return_patch (bool): If True, returns the `matplotlib patch <https://matplotlib.org/api/patches_api.html>`_ for the region. kwargs (dict): Options to be passed to matplotlib when creating the patch. Returns ------- Returns the matplotlib fig, ax objects for this plot. If not specified, the default plotting styles will be used. If ``return_patch=True``, returns the patch instead. """ if ax==None and not return_patch: fig, ax = lvm_plot.get_axes(projection=projection, frame=self.frame) coords = self.vertices() if projection == 'rectangular': #ax.set_aspect('equal', adjustable='box') poly = matplotlib.path.Path(coords, closed=True) poly_patch = matplotlib.patches.PathPatch(poly, **kwargs) min_x, min_y = coords.min(0) max_x, max_y = coords.max(0) padding_x = 0.1 * (max_x - min_x) padding_y = 0.1 * (max_y - min_y) if not return_patch: poly_patch = ax.add_patch(poly_patch) ax.set_xlim(min_x - padding_x, max_x + padding_x) ax.set_ylim(min_y - padding_y, max_y + padding_y) elif projection == 'mollweide': coords = lvm_plot.transform_vertices_mollweide(coords) poly = matplotlib.path.Path(coords, closed=True) poly_patch = matplotlib.patches.PathPatch(poly, **kwargs) if not return_patch: poly_patch = ax.add_patch(poly_patch) if return_patch: return poly_patch else: return fig, ax
@classmethod def _rotate_vertices(cls, vertices, pa): sa, ca = numpy.sin(numpy.deg2rad(pa)), numpy.cos(numpy.deg2rad(pa)) R = numpy.array([[ca, -sa], [sa, ca]]) return numpy.dot(R, vertices.T).T @classmethod def _rotate_coords(cls, x, y, pa): rot = cls._rotate_vertices(numpy.array([x, y]).T, pa) xyprime = rot.T return xyprime[0,:], xyprime[1,:]