class UVW(BaseCoordinateFrame): """ Written by Joshua G. Albert - [email protected] A coordinate or frame in the UVW system. This frame has the following frame attributes, which are necessary for transforming from UVW to some other system: * ``obstime`` The time at which the observation is taken. Used for determining the position and orientation of the Earth. * ``location`` The location on the Earth. This can be specified either as an `~astropy.coordinates.EarthLocation` object or as anything that can be transformed to an `~astropy.coordinates.ITRS` frame. * ``phaseDir`` The phase tracking center of the frame. This can be specified either as an (ra,dec) `~astropy.units.Qunatity` or as anything that can be transformed to an `~astropy.coordinates.ICRS` frame. Parameters ---------- representation : `BaseRepresentation` or None A representation object or None to have no data (or use the other keywords) u : :class:`~astropy.units.Quantity`, optional, must be keyword The u coordinate for this object (``v`` and ``w`` must also be given and ``representation`` must be None). v : :class:`~astropy.units.Quantity`, optional, must be keyword The v coordinate for this object (``u`` and ``w`` must also be given and ``representation`` must be None). w : :class:`~astropy.units.Quantity`, optional, must be keyword The w coordinate for this object (``u`` and ``v`` must also be given and ``representation`` must be None). Notes ----- This is useful for radio astronomy. """ frame_specific_representation_info = { 'cartesian': [ RepresentationMapping('x', 'u'), RepresentationMapping('y', 'v'), RepresentationMapping('z', 'w') ], } default_representation = CartesianRepresentation obstime = TimeAttribute(default=None) location = EarthLocationAttribute(default=None) phase = CoordinateAttribute(ICRS, default=None) def __init__(self, *args, **kwargs): super(UVW, self).__init__(*args, **kwargs) @property def elevation(self): """ Elevation above the horizon of the direction """ return self.phase.transform_to( AltAz(location=self.location, obstime=self.obstime)).alt
class GreatCircleICRSFrame(coord.BaseCoordinateFrame): """A frame rotated into great circle coordinates with the pole and longitude specified as frame attributes. ``GreatCircleICRSFrame``s always have component names for spherical coordinates of ``phi1``/``phi2``. """ pole = CoordinateAttribute(default=None, frame=coord.ICRS) ra0 = QuantityAttribute(default=np.nan * u.deg, unit=u.deg) rotation = QuantityAttribute(default=0, unit=u.deg) frame_specific_representation_info = { coord.SphericalRepresentation: [ coord.RepresentationMapping('lon', 'phi1'), coord.RepresentationMapping('lat', 'phi2'), coord.RepresentationMapping('distance', 'distance') ] } default_representation = coord.SphericalRepresentation default_differential = coord.SphericalCosLatDifferential _default_wrap_angle = 180 * u.deg def __init__(self, *args, **kwargs): wrap = kwargs.pop('wrap_longitude', True) super().__init__(*args, **kwargs) if wrap and isinstance(self._data, (coord.UnitSphericalRepresentation, coord.SphericalRepresentation)): self._data.lon.wrap_angle = self._default_wrap_angle @classmethod def from_endpoints(cls, coord1, coord2, ra0=None, rotation=None): """TODO """ pole = pole_from_endpoints(coord1, coord2) kw = dict(pole=pole) if ra0 is not None: kw['ra0'] = ra0 if rotation is not None: kw['rotation'] = rotation if ra0 is None and rotation is None: midpt = sph_midpoint(coord1, coord2) kw['ra0'] = midpt.ra return cls(**kw)
def __new__(cls, name, bases, members): # Only 'origin' is needed here, to set the origin frame properly. members['origin'] = CoordinateAttribute(frame=framecls, default=None) # This has to be done because FrameMeta will set these attributes # to the defaults from BaseCoordinateFrame when it creates the base # SkyOffsetFrame class initially. members['_default_representation'] = framecls._default_representation members['_default_differential'] = framecls._default_differential newname = name[:-5] if name.endswith('Frame') else name newname += framecls.__name__ return super().__new__(cls, newname, bases, members)
class ExternalGalaxyFrame(BaseCoordinateFrame): """Astropy coordinate frame centered on an external galaxy. The frame is defined by sky coordinate, heliocentric distance and velocity, and inclination and position angle. Modeled on astropy's Galactocentric class, but without that class's defaults-setting mechanism. x, y, and z are defined such that with inclination and position angle both 0, x points along RA, y along dec, and z into sky. With nonzero inclination y will still point within the plane of the sky, while x and z rotate about the y axis. Keyword Arguments: gal_coord: Scalar SkyCoord or Frame object Contains sky position of galaxy gal_distance: Quantity (units of length, e.g. u.kpc) Distance to galaxy galvel_heliocentric: CartesianDifferential, units of velocity 3-d heliocentric velocity of galaxy, in the sky-aligned cartesian frame pointing to galaxy (x=East, y=North, z=distance) inclination: Quantity (units of angle) PA: Quantity (units of angle) """ default_representation = r.CartesianRepresentation default_differential = r.CartesianDifferential # frame attributes # notes: apparently you can't call something "distance"? # also, ipython reload insufficient to reset attributes - have to quit out and restart python? gal_coord = CoordinateAttribute(frame=ICRS) # PA defined relative to ICRS ra/dec gal_distance = QuantityAttribute(unit=u.kpc) galvel_heliocentric = DifferentialAttribute( allowed_classes=[r.CartesianDifferential]) inclination = QuantityAttribute(unit=u.deg) PA = QuantityAttribute(unit=u.deg) def __init__(self, *args, skyalign=False, **kwargs): # left some example code for subclasses in here default_params = { 'gal_coord': ICRS(ra=10.68470833 * u.degree, dec=41.26875 * u.degree), "gal_distance": 780.0 * u.kpc, "galvel_heliocentric": r.CartesianDifferential([125.2, -73.8, -300.] * (u.km / u.s)), "inclination": (-77.) * u.degree, "PA": 37. * u.degree, } kwds = dict() kwds.update(default_params) if skyalign: kwds['inclination'] = 0. * u.deg kwds['PA'] = 0. * u.deg kwds.update(kwargs) super().__init__(*args, **kwds)
def __new__(cls, name, bases, members): # Only 'origin' is needed here, to set the origin frame properly. members["origin"] = CoordinateAttribute(frame=framecls, default=None) # members["afn_bounds"] = QuantityAttribute( # default=[-np.inf, np.inf] * u.yr, unit=u.Myr, shape=(2,) # ) # This has to be done because FrameMeta will set these attributes # to the defaults from BaseCoordinateFrame when it creates the base # OrbitOffsetFrame class initially. members[ "_default_representation"] = OrbitOffsetCartesianRepresentation members[ "_default_differential"] = framecls._default_differential # TODO replace newname = name[:-5] if name.endswith("Frame") else name newname += framecls.__name__ return super().__new__(cls, newname, bases, members)
class GreatCircleICRSFrame(coord.BaseCoordinateFrame): """A frame rotated into great circle coordinates with the pole and longitude specified as frame attributes. ``GreatCircleICRSFrame``s always have component names for spherical coordinates of ``phi1``/``phi2``. """ pole = CoordinateAttribute(default=None, frame=coord.ICRS) center = CoordinateAttribute(default=None, frame=coord.ICRS) ra0 = QuantityAttribute(default=np.nan * u.deg, unit=u.deg) rotation = QuantityAttribute(default=0, unit=u.deg) frame_specific_representation_info = { coord.SphericalRepresentation: [ coord.RepresentationMapping('lon', 'phi1'), coord.RepresentationMapping('lat', 'phi2'), coord.RepresentationMapping('distance', 'distance') ] } default_representation = coord.SphericalRepresentation default_differential = coord.SphericalCosLatDifferential _default_wrap_angle = 180 * u.deg def __init__(self, *args, **kwargs): wrap = kwargs.pop('wrap_longitude', True) super().__init__(*args, **kwargs) if wrap and isinstance(self._data, (coord.UnitSphericalRepresentation, coord.SphericalRepresentation)): self._data.lon.wrap_angle = self._default_wrap_angle if self.center is not None and np.isfinite(self.ra0): raise ValueError( "Both `center` and `ra0` were specified for this " "{} object: you can only specify one or the other.".format( self.__class__.__name__)) # TODO: remove this. This is a hack required as of astropy v3.1 in order # to have the longitude components wrap at the desired angle def represent_as(self, base, s='base', in_frame_units=False): r = super().represent_as(base, s=s, in_frame_units=in_frame_units) r.lon.wrap_angle = self._default_wrap_angle return r represent_as.__doc__ = coord.BaseCoordinateFrame.represent_as.__doc__ @classmethod def from_endpoints(cls, coord1, coord2, ra0=None, rotation=None): """Compute the great circle frame from two endpoints of an arc on the unit sphere. Parameters ---------- coord1 : `~astropy.coordinates.SkyCoord` One endpoint of the great circle arc. coord2 : `~astropy.coordinates.SkyCoord` The other endpoint of the great circle arc. ra0 : `~astropy.units.Quantity`, `~astropy.coordinates.Angle` (optional) If specified, an additional transformation will be applied to make this right ascension the longitude zero-point of the resulting coordinate frame. rotation : `~astropy.units.Quantity`, `~astropy.coordinates.Angle` (optional) If specified, a final rotation about the pole (i.e. the resulting z axis) applied. """ pole = pole_from_endpoints(coord1, coord2) kw = dict(pole=pole) if ra0 is not None: kw['ra0'] = ra0 if rotation is not None: kw['rotation'] = rotation if ra0 is None and rotation is None: midpt = sph_midpoint(coord1, coord2) kw['ra0'] = midpt.ra return cls(**kw) @classmethod def from_xyz(cls, xnew=None, ynew=None, znew=None): """Compute the great circle frame from a specification of the coordinate axes in the new system. Parameters ---------- xnew : astropy ``Representation`` object The x-axis in the new system. ynew : astropy ``Representation`` object The y-axis in the new system. znew : astropy ``Representation`` object The z-axis in the new system. """ is_none = [xnew is None, ynew is None, znew is None] if np.sum(is_none) > 1: raise ValueError("At least 2 axes must be specified.") if xnew is not None: xnew = xnew.to_cartesian() if ynew is not None: ynew = ynew.to_cartesian() if znew is not None: znew = znew.to_cartesian() if znew is None: znew = xnew.cross(ynew) if ynew is None: ynew = -xnew.cross(znew) if xnew is None: xnew = ynew.cross(znew) pole = coord.SkyCoord(znew, frame='icrs') center = coord.SkyCoord(xnew, frame='icrs') return cls(pole=pole, center=center)
class Galactocentric(BaseCoordinateFrame): r""" A coordinate or frame in the Galactocentric system. This frame allows specifying the Sun-Galactic center distance, the height of the Sun above the Galactic midplane, and the solar motion relative to the Galactic center. However, as there is no modern standard definition of a Galactocentric reference frame, it is important to pay attention to the default values used in this class if precision is important in your code. The default values of the parameters of this frame are taken from the original definition of the frame in 2014. As such, the defaults are somewhat out of date relative to recent measurements made possible by, e.g., Gaia. The defaults can, however, be changed at runtime by setting the parameter set name in `~astropy.coordinates.galactocentric_frame_defaults`. The current default parameter set is ``"pre-v4.0"``, indicating that the parameters were adopted before ``astropy`` version 4.0. A regularly-updated parameter set can instead be used by setting ``galactocentric_frame_defaults.set ('latest')``, and other parameter set names may be added in future versions. To find out the scientific papers that the current default parameters are derived from, use ``galcen.frame_attribute_references`` (where ``galcen`` is an instance of this frame), which will update even if the default parameter set is changed. The position of the Sun is assumed to be on the x axis of the final, right-handed system. That is, the x axis points from the position of the Sun projected to the Galactic midplane to the Galactic center -- roughly towards :math:`(l,b) = (0^\circ,0^\circ)`. For the default transformation (:math:`{\rm roll}=0^\circ`), the y axis points roughly towards Galactic longitude :math:`l=90^\circ`, and the z axis points roughly towards the North Galactic Pole (:math:`b=90^\circ`). For a more detailed look at the math behind this transformation, see the document :ref:`astropy:coordinates-galactocentric`. The frame attributes are listed under **Other Parameters**. """ default_representation = r.CartesianRepresentation default_differential = r.CartesianDifferential # frame attributes galcen_coord = CoordinateAttribute(frame=ICRS) galcen_distance = QuantityAttribute(unit=u.kpc) galcen_v_sun = DifferentialAttribute( allowed_classes=[r.CartesianDifferential]) z_sun = QuantityAttribute(unit=u.pc) roll = QuantityAttribute(unit=u.deg) def __init__(self, *args, **kwargs): # Set default frame attribute values based on the ScienceState instance # for the solar parameters defined above default_params = galactocentric_frame_defaults.get() self.frame_attribute_references = \ galactocentric_frame_defaults.references.copy() for k in default_params: if k in kwargs: # If a frame attribute is set by the user, remove its reference self.frame_attribute_references.pop(k, None) # Keep the frame attribute if it is set by the user, otherwise use # the default value kwargs[k] = kwargs.get(k, default_params[k]) super().__init__(*args, **kwargs) @classmethod def get_roll0(cls): """ The additional roll angle (about the final x axis) necessary to align the final z axis to match the Galactic yz-plane. Setting the ``roll`` frame attribute to -this method's return value removes this rotation, allowing the use of the `Galactocentric` frame in more general contexts. """ # note that the actual value is defined at the module level. We make at # a property here because this module isn't actually part of the public # API, so it's better for it to be accessable from Galactocentric return _ROLL0
class Galactocentric(BaseCoordinateFrame): r""" A coordinate or frame in the Galactocentric system. This frame requires specifying the Sun-Galactic center distance, and optionally the height of the Sun above the Galactic midplane. The position of the Sun is assumed to be on the x axis of the final, right-handed system. That is, the x axis points from the position of the Sun projected to the Galactic midplane to the Galactic center -- roughly towards :math:`(l,b) = (0^\circ,0^\circ)`. For the default transformation (:math:`{\rm roll}=0^\circ`), the y axis points roughly towards Galactic longitude :math:`l=90^\circ`, and the z axis points roughly towards the North Galactic Pole (:math:`b=90^\circ`). The default position of the Galactic Center in ICRS coordinates is taken from Reid et al. 2004, http://adsabs.harvard.edu/abs/2004ApJ...616..872R. .. math:: {\rm RA} = 17:45:37.224~{\rm hr}\\ {\rm Dec} = -28:56:10.23~{\rm deg} The default distance to the Galactic Center is 8.3 kpc, e.g., Gillessen et al. (2009), https://ui.adsabs.harvard.edu/#abs/2009ApJ...692.1075G/abstract The default height of the Sun above the Galactic midplane is taken to be 27 pc, as measured by Chen et al. (2001), https://ui.adsabs.harvard.edu/#abs/2001ApJ...553..184C/abstract The default solar motion relative to the Galactic center is taken from a combination of Schönrich et al. (2010) [for the peculiar velocity] and Bovy (2015) [for the circular velocity at the solar radius], https://ui.adsabs.harvard.edu/#abs/2010MNRAS.403.1829S/abstract https://ui.adsabs.harvard.edu/#abs/2015ApJS..216...29B/abstract For a more detailed look at the math behind this transformation, see the document :ref:`coordinates-galactocentric`. The frame attributes are listed under **Other Parameters**. """ default_representation = r.CartesianRepresentation default_differential = r.CartesianDifferential # frame attributes galcen_coord = CoordinateAttribute(default=ICRS(ra=266.4051 * u.degree, dec=-28.936175 * u.degree), frame=ICRS) galcen_distance = QuantityAttribute(default=8.3 * u.kpc) galcen_v_sun = DifferentialAttribute( default=r.CartesianDifferential([11.1, 220 + 12.24, 7.25] * u.km / u.s), allowed_classes=[r.CartesianDifferential]) z_sun = QuantityAttribute(default=27. * u.pc) roll = QuantityAttribute(default=0. * u.deg) def __init__(self, *args, **kwargs): # backwards-compatibility if ('galcen_ra' in kwargs or 'galcen_dec' in kwargs): warnings.warn( "The arguments 'galcen_ra', and 'galcen_dec' are " "deprecated in favor of specifying the sky coordinate" " as a CoordinateAttribute using the 'galcen_coord' " "argument", AstropyDeprecationWarning) galcen_kw = dict() galcen_kw['ra'] = kwargs.pop('galcen_ra', self.galcen_coord.ra) galcen_kw['dec'] = kwargs.pop('galcen_dec', self.galcen_coord.dec) kwargs['galcen_coord'] = ICRS(**galcen_kw) super().__init__(*args, **kwargs) @property def galcen_ra(self): warnings.warn( "The attribute 'galcen_ra' is deprecated. Use " "'.galcen_coord.ra' instead.", AstropyDeprecationWarning) return self.galcen_coord.ra @property def galcen_dec(self): warnings.warn( "The attribute 'galcen_dec' is deprecated. Use " "'.galcen_coord.dec' instead.", AstropyDeprecationWarning) return self.galcen_coord.dec @classmethod def get_roll0(cls): """ The additional roll angle (about the final x axis) necessary to align the final z axis to match the Galactic yz-plane. Setting the ``roll`` frame attribute to -this method's return value removes this rotation, allowing the use of the `Galactocentric` frame in more general contexts. """ # note that the actual value is defined at the module level. We make at # a property here because this module isn't actually part of the public # API, so it's better for it to be accessable from Galactocentric return _ROLL0
class Galactocentric(BaseCoordinateFrame): r""" A coordinate or frame in the Galactocentric system. This frame allows specifying the Sun-Galactic center distance, the height of the Sun above the Galactic midplane, and the solar motion relative to the Galactic center. However, as there is no modern standard definition of a Galactocentric reference frame, it is important to pay attention to the default values used in this class if precision is important in your code. The default values of the parameters of this frame are taken from the original definition of the frame in 2014. As such, the defaults are somewhat out of date relative to recent measurements made possible by, e.g., Gaia. The defaults can, however, be changed at runtime by setting the parameter set name in `~astropy.coordinates.galactocentric_frame_defaults`. The current default parameter set is ``"pre-v4.0"``, indicating that the parameters were adopted before ``astropy`` version 4.0. A regularly-updated parameter set can instead be used by setting ``galactocentric_frame_defaults.set ('latest')``, and other parameter set names may be added in future versions. To find out the scientific papers that the current default parameters are derived from, use ``galcen.frame_attribute_references`` (where ``galcen`` is an instance of this frame), which will update even if the default parameter set is changed. The position of the Sun is assumed to be on the x axis of the final, right-handed system. That is, the x axis points from the position of the Sun projected to the Galactic midplane to the Galactic center -- roughly towards :math:`(l,b) = (0^\circ,0^\circ)`. For the default transformation (:math:`{\rm roll}=0^\circ`), the y axis points roughly towards Galactic longitude :math:`l=90^\circ`, and the z axis points roughly towards the North Galactic Pole (:math:`b=90^\circ`). For a more detailed look at the math behind this transformation, see the document :ref:`coordinates-galactocentric`. The frame attributes are listed under **Other Parameters**. """ default_representation = r.CartesianRepresentation default_differential = r.CartesianDifferential # frame attributes galcen_coord = CoordinateAttribute(frame=ICRS) galcen_distance = QuantityAttribute(unit=u.kpc) galcen_v_sun = DifferentialAttribute( allowed_classes=[r.CartesianDifferential]) z_sun = QuantityAttribute(unit=u.pc) roll = QuantityAttribute(unit=u.deg) def __init__(self, *args, **kwargs): # Set default frame attribute values based on the ScienceState instance # for the solar parameters defined above default_params = galactocentric_frame_defaults.get() self.frame_attribute_references = \ galactocentric_frame_defaults._references.copy() warn = False for k in default_params: # If a frame attribute is set by the user, remove its reference self.frame_attribute_references.pop(k, None) # If a parameter is read from the defaults, we might want to warn # the user that the defaults will change (see below) if k not in kwargs: warn = True # Keep the frame attribute if it is set by the user, otherwise use # the default value kwargs[k] = kwargs.get(k, default_params[k]) # If the frame defaults have not been updated with the ScienceState # class, and the user uses any default parameter value, raise a # deprecation warning to inform them that the defaults will change in # the future: if galactocentric_frame_defaults._value == 'pre-v4.0' and warn: docs_link = 'http://docs.astropy.org/en/latest/coordinates/galactocentric.html' warnings.warn( 'In v4.1 and later versions, the Galactocentric ' 'frame will adopt default parameters that may update ' 'with time. An updated default parameter set is ' 'already available through the ' 'astropy.coordinates.galactocentric_frame_defaults ' 'ScienceState object, as described in but the ' 'default is currently still set to the pre-v4.0 ' 'parameter defaults. The safest way to guard against ' 'changing default parameters in the future is to ' 'either (1) specify all Galactocentric frame ' 'attributes explicitly when using the frame, ' 'or (2) set the galactocentric_frame_defaults ' f'parameter set name explicitly. See {docs_link} for more ' 'information.', AstropyDeprecationWarning) super().__init__(*args, **kwargs) @classmethod def get_roll0(cls): """ The additional roll angle (about the final x axis) necessary to align the final z axis to match the Galactic yz-plane. Setting the ``roll`` frame attribute to -this method's return value removes this rotation, allowing the use of the `Galactocentric` frame in more general contexts. """ # note that the actual value is defined at the module level. We make at # a property here because this module isn't actually part of the public # API, so it's better for it to be accessable from Galactocentric return _ROLL0
class SkyOffsetFrame(BaseCoordinateFrame): """ A frame which is relative to some specific position and oriented to match its frame. SkyOffsetFrames always have component names for spherical coordinates of ``lon``/``lat``, *not* the component names for the frame of ``origin``. This is useful for calculating offsets and dithers in the frame of the sky relative to an arbitrary position. Coordinates in this frame are both centered on the position specified by the ``origin`` coordinate, *and* they are oriented in the same manner as the ``origin`` frame. E.g., if ``origin`` is `~astropy.coordinates.ICRS`, this object's ``lat`` will be pointed in the direction of Dec, while ``lon`` will point in the direction of RA. For more on skyoffset frames, see :ref:`astropy:astropy-skyoffset-frames`. Parameters ---------- representation : `~astropy.coordinates.BaseRepresentation` or None A representation object or None to have no data (or use the other keywords) origin : coordinate-like The coordinate which specifies the origin of this frame. Note that this origin is used purely for on-sky location/rotation. It can have a ``distance`` but it will not be used by this ``SkyOffsetFrame``. rotation : angle-like The final rotation of the frame about the ``origin``. The sign of the rotation is the left-hand rule. That is, an object at a particular position angle in the un-rotated system will be sent to the positive latitude (z) direction in the final frame. Notes ----- ``SkyOffsetFrame`` is a factory class. That is, the objects that it yields are *not* actually objects of class ``SkyOffsetFrame``. Instead, distinct classes are created on-the-fly for whatever the frame class is of ``origin``. """ rotation = QuantityAttribute(default=0, unit=u.deg) origin = CoordinateAttribute(default=None, frame=None) def __new__(cls, *args, **kwargs): # We don't want to call this method if we've already set up # an skyoffset frame for this class. if not (issubclass(cls, SkyOffsetFrame) and cls is not SkyOffsetFrame): # We get the origin argument, and handle it here. try: origin_frame = kwargs['origin'] except KeyError: raise TypeError( "Can't initialize an SkyOffsetFrame without origin= keyword." ) if hasattr(origin_frame, 'frame'): origin_frame = origin_frame.frame newcls = make_skyoffset_cls(origin_frame.__class__) return newcls.__new__(newcls, *args, **kwargs) # http://stackoverflow.com/questions/19277399/why-does-object-new-work-differently-in-these-three-cases # See above for why this is necessary. Basically, because some child # may override __new__, we must override it here to never pass # arguments to the object.__new__ method. if super().__new__ is object.__new__: return super().__new__(cls) return super().__new__(cls, *args, **kwargs) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if self.origin is not None and not self.origin.has_data: raise ValueError('The origin supplied to SkyOffsetFrame has no ' 'data.') if self.has_data: self._set_skyoffset_data_lon_wrap_angle(self.data) @staticmethod def _set_skyoffset_data_lon_wrap_angle(data): if hasattr(data, 'lon'): data.lon.wrap_angle = 180. * u.deg return data def represent_as(self, base, s='base', in_frame_units=False): """ Ensure the wrap angle for any spherical representations. """ data = super().represent_as(base, s, in_frame_units=in_frame_units) self._set_skyoffset_data_lon_wrap_angle(data) return data def __reduce__(self): return (_skyoffset_reducer, (self.origin, ), self.__dict__)
def make_skyoffset_cls(framecls): """ Create a new class that is the sky offset frame for a specific class of origin frame. If such a class has already been created for this frame, the same class will be returned. The new class will always have component names for spherical coordinates of ``lon``/``lat``. Parameters ---------- framecls : `~astropy.coordinates.BaseCoordinateFrame` subclass The class to create the SkyOffsetFrame of. Returns ------- skyoffsetframecls : class The class for the new skyoffset frame. Notes ----- This function is necessary because Astropy's frame transformations depend on connection between specific frame *classes*. So each type of frame needs its own distinct skyoffset frame class. This function generates just that class, as well as ensuring that only one example of such a class actually gets created in any given python session. """ if framecls in _skyoffset_cache: return _skyoffset_cache[framecls] # Create a new SkyOffsetFrame subclass for this frame class. name = 'SkyOffset' + framecls.__name__ _SkyOffsetFramecls = type( name, (SkyOffsetFrame, framecls), { 'origin': CoordinateAttribute(frame=framecls, default=None), # The following two have to be done because otherwise we use the # defaults of SkyOffsetFrame set by BaseCoordinateFrame. '_default_representation': framecls._default_representation, '_default_differential': framecls._default_differential, '__doc__': SkyOffsetFrame.__doc__, }) @frame_transform_graph.transform(FunctionTransform, _SkyOffsetFramecls, _SkyOffsetFramecls) def skyoffset_to_skyoffset(from_skyoffset_coord, to_skyoffset_frame): """Transform between two skyoffset frames.""" # This transform goes through the parent frames on each side. # from_frame -> from_frame.origin -> to_frame.origin -> to_frame intermediate_from = from_skyoffset_coord.transform_to( from_skyoffset_coord.origin) intermediate_to = intermediate_from.transform_to( to_skyoffset_frame.origin) return intermediate_to.transform_to(to_skyoffset_frame) @frame_transform_graph.transform(DynamicMatrixTransform, framecls, _SkyOffsetFramecls) def reference_to_skyoffset(reference_frame, skyoffset_frame): """Convert a reference coordinate to an sky offset frame.""" # Define rotation matrices along the position angle vector, and # relative to the origin. origin = skyoffset_frame.origin.spherical mat1 = rotation_matrix(-skyoffset_frame.rotation, 'x') mat2 = rotation_matrix(-origin.lat, 'y') mat3 = rotation_matrix(origin.lon, 'z') return matrix_product(mat1, mat2, mat3) @frame_transform_graph.transform(DynamicMatrixTransform, _SkyOffsetFramecls, framecls) def skyoffset_to_reference(skyoffset_coord, reference_frame): """Convert an sky offset frame coordinate to the reference frame""" # use the forward transform, but just invert it R = reference_to_skyoffset(reference_frame, skyoffset_coord) # transpose is the inverse because R is a rotation matrix return matrix_transpose(R) _skyoffset_cache[framecls] = _SkyOffsetFramecls return _SkyOffsetFramecls
class OrbitOffsetFrame(BaseCoordinateFrame): """Offset Frame from an Orbit in a Potential. A frame which is relative to some specific position and oriented to match its frame. For more on skyoffset frames, see :ref:`astropy-skyoffset-frames`. Parameters ---------- representation : `~astropy.coordinates.BaseRepresentation` or None A representation object or None to have no data, or use the other keywords origin : `~astropy.coordinates.SkyCoord` or low-level coordinate object. The coordinate which specifies the origin of this frame. potential : `~galpy.potential.Potential` or list thereof. Notes ----- ``OrbitOffsetFrame`` is a factory class. That is, the objects that it yields are *not* actually objects of class ``OrbitOffsetFrame``. Instead, distinct classes are created on-the-fly for whatever the frame class is of ``origin``. """ origin = CoordinateAttribute(default=None, frame=None) potential = PotentialAttribute() afn_bounds = Attribute( # FIXME QuantityAttribute # default=[-np.inf, np.inf] * u.yr, # unit=u.Myr, shape=(2,) ) # [afn_trail, afn_lead] # frame_specific_representation_info = { # note, not in Astropy's Offset # "cartesian": [ # RepresentationMapping("z", "afn"), # RepresentationMapping("x", "x"), # RepresentationMapping("y", "y"), # ], # } def __new__(cls, *args, **kwargs): """OrbitOffsetFrame.""" # We don't want to call this method if we've already set up # an orbitoffset frame for this class. if not (issubclass(cls, OrbitOffsetFrame) and cls is not OrbitOffsetFrame): # We get the origin argument, and handle it here. # TODO maybe get from orbit function, putting in afn t=0 ? try: origin_frame = kwargs["origin"] except KeyError: raise TypeError("Can't initialize an OrbitOffsetFrame " "without origin= keyword.") try: # TODO add to Frame for reverse transformations? track_fn = kwargs.pop("track_fn") except KeyError: raise TypeError("Can't initialize an OrbitOffsetFrame " "without origin= keyword.") inverse_track_fn = kwargs.pop("inverse_track_fn", None) track_fn_kw = kwargs.pop("track_fn_kw", {}) inverse_track_fn_kw = kwargs.pop("inverse_track_fn_kw", {}) # adj_sep_sgn = kwargs.pop("adj_sep_sgn", False) # has default # and the potential argument try: potential = kwargs["potential"] except KeyError: raise TypeError("Can't initialize an OrbitOffsetFrame " "without potential= keyword.") # and the afn arguments # TODO change to afn bounds (tail, lead) try: afn_bounds = kwargs["afn_bounds"] except KeyError: raise TypeError("Can't initialize an OrbitOffsetFrame " "without afn_bounds= keyword.") else: if len(afn_bounds) != 2: raise ValueError("`afn_bounds` must be len= 2.s") if hasattr(origin_frame, "frame"): origin_frame = origin_frame.frame newcls = make_orbitoffset_cls( origin_frame.__class__, potential=potential, track_fn=track_fn, track_fn_kw=track_fn_kw, inverse_track_fn=inverse_track_fn, inverse_track_fn_kw=inverse_track_fn_kw, # adj_sep_sgn=adj_sep_sgn, ) return newcls.__new__(newcls, *args, **kwargs) # http://stackoverflow.com/questions/19277399/why-does-object-new-work-differently-in-these-three-cases # See above for why this is necessary. Basically, because some child # may override __new__, we must override it here to never pass # arguments to the object.__new__ method. if super().__new__ is object.__new__: return super().__new__(cls) return super().__new__(cls, *args, **kwargs) # /def @classmethod def from_galpy_orbit( cls, *args, orbit, orbit_bkw=None, frame="galactocentric", method: T.Union[Literal["closest"], Literal["linear"], Literal["cubic"], T.Callable[[T.Sequence], T.Sequence], ] = "closest", time_unit=None, **kwargs, ): """Create an Orbit Offset Frame from a galpy orbit. Parameters ---------- orbit : `~galpy.orbit.Orbit` An integrated single orbit., keyword only the initial 4-vector and conjugate momenta are taken as the origin. orbit_bkw : `~galpy.orbit.Orbit`, optional, keyword only An integrated single orbit in the opposite time direction. This allows for a leading and trailing orbit from the origin point. Must have same origin as `orbit`, but be integrated in reverse. frame : str or BaseCoordinateFrame, optional, keyword only frame in which to represent the orbit. calls ``transform_to(frame)`` on `orbit`'s ICRS SkyCoord output, so be careful about things like Galactocentric defaults method : {"closest", "linear", "cubic"} or Callable, optional, keyword how to construct the affine parameter function mapping time to coordinate The orbit integration is precomputed at discrete time intervals and the path frame needs to be able to match coordinates to the closest point on the orbit. This can be done by treating the orbit as an immutable catalog (option "closest", default), a linearly interpolatable set of points (option "linear") with :class:`~scipy.interpolate.interp1d`, a cubic interpolation (option "cubic") with :class:`~scipy.interpolate.CubicSpline`. .. todo:: allow user-provided functions that take the time and coordinates and return a function that, given a set of coordinates, returns the time of and angular separation from "closest" (for some dfn) point on orbit. time_unit : Quantity or Nont, optional, keyword only preferred time unit. None means no modification. Galpy defaults to Gyr. Raises ------ ValueError if `orbit` is not integrated if `orbit_bkw` is not integrated (and not None) if the potential in `orbit` and `orbit_bkw` do not match. Notes ----- Make sure that the orbit does not wrap and get close to itself. There are currently no checks that this is happening and this can lead to some very strange coordinate projections. .. todo:: allow affine parameter to be arc length """ # ---------------------------------------------------- # Checks # if orbit_bkw is not None, check has potential and matches orbit # check times go in opposite directions try: # check orbit has potential orbit._pot except AttributeError: # it does not raise ValueError("`orbit` must be integrated.") else: # it does # grab the potential, integration time, and origin potential = orbit._pot t_fwd = orbit.time(use_physical=True) origin = orbit.SkyCoord().transform_to(frame) if orbit_bkw is not None: try: orbit._pot except AttributeError: raise ValueError("`orbit_bkw` must be integrated.") if orbit._pot != potential: raise ValueError( ("potential in `orbit` and `orbit_bkw` do not match.")) # check time "directions" are opposite # they "fwd" direction can be back in time. That is permitted # just not allowed to have the "bkw" direction be the same. time_bkw_sgn = np.sign(orbit_bkw.t[1] - orbit_bkw.t[0]) t_fwd_sgn = np.sign(orbit.t[1] - orbit.t[0]) if time_bkw_sgn == t_fwd_sgn: raise ValueError(("`orbit` and `orbit_bkw` must be integrated" "in opposite time directions")) # get back time, converting to correct units t_bkw = orbit_bkw.time(use_physical=True)[::-1] << t_fwd.unit # concatenate fwd and bkw orbits into single orbit catalog orbit_catalog = concatenate([ orbit_bkw.SkyCoord(t_bkw).transform_to(frame).frame, orbit.SkyCoord(t_fwd).transform_to(frame).frame, ]) orbit_time = np.concatenate((t_bkw, t_fwd)) # create time bounds _u = t_fwd.unit if time_unit is None else time_unit if t_fwd[-1] > t_bkw[-1]: t_bnds = [t_bkw[0], t_fwd[-1]] << _u else: t_bnds = [t_fwd[0], t_bkw[-1]] << _u else: # create orbit catalog orbit_catalog = orbit.SkyCoord(t_fwd).transform_to(frame) orbit_time = t_fwd # create time bounds _u = t_fwd.unit if time_unit is None else time_unit if t_fwd[-1] > t_fwd[0]: t_bnds = [t_fwd[0], t_fwd[-1]] << _u else: t_bnds = [t_fwd[-1], t_fwd[0]] << _u # convert orbit time to `time_unit`, if specified if time_unit is not None: orbit_time <<= time_unit # (in-place modification) else: # time unit is not None time_unit = orbit_time.unit # ---------------------------------------------------- # construct affine function track_fn_kw = kwargs.pop("track_fn_kw", {"afn_name": "time"}) inverse_track_fn_kw = kwargs.pop("inverse_track_fn_kw", {}) if isinstance(method, str): # now need to check it's one of the supported strings if method.lower() == "closest": # does a catalog match between the coordinates and the # points on the orbit from the orbit integration # track_fn = ("closest", orbit_catalog, orbit_time) track_fn = catalog_match_track inverse_track_fn = catalog_match_inverse_track track_fn_kw = { "catalog": orbit_catalog, "affine_param": orbit_time, "adj_sep_sgn": True, "afn_name": "time", } inverse_track_fn_kw = { "catalog": orbit_catalog, "affine_param": orbit_time, } _interpolation_flag = False else: # need to handle interpolated functions separately, # because requires a closest point "optimization" if method.lower() == "linear": method = interpolate.interp1d elif method.lower() == "cubic": method = interpolate.CubicSpline else: raise ValueError(f"method {method} not known.") _interpolation_flag = True elif callable(method): _interpolation_flag = True else: raise ValueError(f"method {method} not known.") # /if if _interpolation_flag: # get affine parameter and data interpolation ready affine_param = orbit_time.to_value(time_unit) _data = orbit_catalog.data._values _data = _data.view(np.float64).reshape(_data.shape + (-1, )) # construct interpolation _track_array_fn = method(affine_param, _data.T) # astropy coordinate object reconstruction information _cmpt = [ ( c, orbit_catalog.data._units[c], ) # in right order, but dict for c in orbit_catalog.data.components # tuple = order ] _frame = orbit_catalog.frame.realize_frame(None) _rep = orbit_catalog.frame.get_representation_cls() # interpolation function as astropy, not numpy def _track_fn(affine_param): """_track_array_fn converted back into a coordinate object.""" _oc = _track_array_fn(affine_param) # evaluate interpolation rep = _rep(**{c: _oc[i] * U for i, (c, U) in enumerate(_cmpt)}) catalog = SkyCoord( # make catalog (TODO not SkyCoord) _frame.realize_frame(rep)) return catalog # make actual track and inverse functions def track_fn(coords, tol=None, init_sampler: T.Union[float, int] = 1e4): """Map coordinates to catalog projection. .. todo:: change defualt `tol` to something else Parameters ---------- coords: SkyCoord tol : float or None, optional If None (default), does catalog match but no further minimization. The catalog is the evaluation of the "method" function with affine parameter linearly sampled with `init_sampler` points between "afn_bounds" init_sampler : int or float, optional the number of points in ``np.linspace`` for an inital sampling of the affine parameter. """ _aff = np.linspace( # affine parameter *t_bnds, num=int(init_sampler))[1:-2] catalog = _track_fn(_aff) if tol is None: return catalog_match_track( coords, catalog=catalog, affine_param=_aff, adj_sep_sgn=True, ) else: # TODO actual minimization # initial guess idx, sep2d, _, = match_coordinates_3d(coords, catalog) raise ValueError("Not yet implemented") return idx # /def def inverse_track_fn(coords, **kw): """Map catalog projection to coordinates. .. todo:: this is a very generic function. put somewhere else. Parameters ---------- coords: SkyCoord in orbit projection frame """ # orbit_pos = _track_fn(coords.afn) # need to know offset # d_afn = coords.data._d_afn # Now offset by `d_afn` in direction `that` raise ValueError("TODO") # out = orbit_pos.directional_offset_by( # pa, np.abs(coords.sep) # need abs() b/c `adj_sep_sgn` # ).represent_as("spherical") # return out.x, out.y, coords.z # /def # /if # TODO correct construction with init to get the Attributes self = cls( *args, origin=origin, potential=potential, track_fn=track_fn, inverse_track_fn=inverse_track_fn, track_fn_kw=track_fn_kw, inverse_track_fn_kw=inverse_track_fn_kw, afn_bounds=t_bnds, **kwargs, ) return self # /def # --------------------------------------------------------------- def __init__(self, *args, **kwargs): """Initialize OrbitOffsetFrame.""" # TODO temporary -------> kwargs.pop("track_fn", None) kwargs.pop("inverse_track_fn", None) kwargs.pop("track_fn_kw", None) kwargs.pop("inverse_track_fn_kw", None) # <------- super().__init__(*args, **kwargs) if self.origin is not None and not self.origin.has_data: raise ValueError( "The origin supplied to OrbitOffsetFrame has no data.")