class TestAttributes(metaclass=OrderedDescriptorContainer): attr_none = Attribute() attr_2 = Attribute(default=2) attr_3_attr2 = Attribute(default=3, secondary_attribute='attr_2') attr_none_attr2 = Attribute(default=None, secondary_attribute='attr_2') attr_none_nonexist = Attribute(default=None, secondary_attribute='nonexist')
class MyFK4(FK4): # equinox inherited from FK4, obstime overridden, and newattr is new obstime = TimeAttribute(default=_EQUINOX_B1980) newattr = Attribute(default='newattr')
class TestFrame(BaseCoordinateFrame): attrtest = Attribute(default=Spam())
class RotatedSunFrame(SunPyBaseCoordinateFrame): """ A frame that applies solar rotation to a base coordinate frame. .. note:: See :ref:`sunpy-coordinates-rotatedsunframe` for how to use this class. In essence, the coordinate axes of the frame are distorted by differential solar rotation. This allows using a coordinate representation at one time (at the ``obstime`` of the base coordinate frame) to point to a location at a different time that has been differentially rotated by the time difference (``duration``). Parameters ---------- representation : `~astropy.coordinates.BaseRepresentation` or ``None`` A representation object or ``None`` to have no data. Alternatively, use coordinate component keyword arguments, which depend on the base frame. base : `~astropy.coordinates.SkyCoord` or low-level coordinate object. The coordinate which specifies the base coordinate frame. The frame must be a SunPy frame. duration : `~astropy.units.Quantity` The duration of solar rotation (defaults to zero days). rotated_time : {parse_time_types} The time to rotate the Sun to. If provided, ``duration`` will be set to the difference between this time and the observation time in ``base``. rotation_model : `str` Accepted model names are ``'howard'`` (default), ``'snodgrass'``, ``'allen'``, and ``'rigid'``. See the documentation for :func:`~sunpy.physics.differential_rotation.diff_rot` for differences between these models. Notes ----- ``RotatedSunFrame`` is a factory class. That is, the objects that it yields are *not* actually objects of class ``RotatedSunFrame``. Instead, distinct classes are created on-the-fly for whatever the frame class is of ``base``. """ # This code reuses significant code from Astropy's implementation of SkyOffsetFrame # See licenses/ASTROPY.rst # We don't want to inherit the frame attributes of SunPyBaseCoordinateFrame (namely `obstime`) # Note that this does not work for Astropy 4.3+, so we need to manually remove it below _inherit_descriptors_ = False # Even though the frame attribute `base` is a coordinate frame, we use `Attribute` instead of # `CoordinateAttribute` because we are preserving the supplied frame rather than converting to # a common frame. base = Attribute() duration = QuantityAttribute(default=0 * u.day) rotation_model = Attribute(default='howard') def __new__(cls, *args, **kwargs): # We don't want to call this method if we've already set up # an rotated-Sun frame for this class. if not (issubclass(cls, RotatedSunFrame) and cls is not RotatedSunFrame): # We get the base argument, and handle it here. base_frame = kwargs.get('base', None) if base_frame is None: raise TypeError( "Can't initialize a RotatedSunFrame without a `base` keyword." ) # If a SkyCoord is provided, use the underlying frame if hasattr(base_frame, 'frame'): base_frame = base_frame.frame newcls = _make_rotatedsun_cls(base_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): # Validate inputs if kwargs['base'].obstime is None: raise ValueError( "The base coordinate frame must have a defined `obstime`.") if 'rotated_time' in kwargs: rotated_time = parse_time(kwargs['rotated_time']) kwargs['duration'] = (rotated_time - kwargs['base'].obstime).to('day') kwargs.pop('rotated_time') super().__init__(*args, **kwargs) # Move data out from the base frame if self.base.has_data: if not self.has_data: # If the duration is an array but the data is scalar, upgrade data to an array if self.base.data.isscalar and not self.duration.isscalar: self._data = self.base.data._apply('repeat', self.duration.shape) else: self._data = self.base.data self._base = self.base.replicate_without_data() def as_base(self): """ Returns a coordinate with the current representation and in the base coordinate frame. This method can be thought of as "removing" the `~sunpy.coordinates.metaframes.RotatedSunFrame` layer. Be aware that this method is not merely a coordinate transformation, because this method changes the location in inertial space that is being pointed to. """ return self.base.realize_frame(self.data) @property def rotated_time(self): """ Returns the sum of the base frame's observation time and the rotation of duration. """ return self.base.obstime + self.duration
class BorkedFrame(BaseCoordinateFrame): ra = Attribute(default=150) dec = Attribute(default=150)
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.")