1""" 2In this module, we define the coordinate representation classes, which are 3used to represent low-level cartesian, spherical, cylindrical, and other 4coordinates. 5""" 6 7import abc 8import functools 9import operator 10import inspect 11import warnings 12 13import numpy as np 14import astropy.units as u 15from erfa import ufunc as erfa_ufunc 16 17from .angles import Angle, Longitude, Latitude 18from .distances import Distance 19from .matrix_utilities import is_O3 20from astropy.utils import ShapedLikeNDArray, classproperty 21from astropy.utils.data_info import MixinInfo 22from astropy.utils.exceptions import DuplicateRepresentationWarning 23 24 25__all__ = ["BaseRepresentationOrDifferential", "BaseRepresentation", 26 "CartesianRepresentation", "SphericalRepresentation", 27 "UnitSphericalRepresentation", "RadialRepresentation", 28 "PhysicsSphericalRepresentation", "CylindricalRepresentation", 29 "BaseDifferential", "CartesianDifferential", 30 "BaseSphericalDifferential", "BaseSphericalCosLatDifferential", 31 "SphericalDifferential", "SphericalCosLatDifferential", 32 "UnitSphericalDifferential", "UnitSphericalCosLatDifferential", 33 "RadialDifferential", "CylindricalDifferential", 34 "PhysicsSphericalDifferential"] 35 36# Module-level dict mapping representation string alias names to classes. 37# This is populated by __init_subclass__ when called by Representation or 38# Differential classes so that they are all registered automatically. 39REPRESENTATION_CLASSES = {} 40DIFFERENTIAL_CLASSES = {} 41# set for tracking duplicates 42DUPLICATE_REPRESENTATIONS = set() 43 44# a hash for the content of the above two dicts, cached for speed. 45_REPRDIFF_HASH = None 46 47 48def _fqn_class(cls): 49 ''' Get the fully qualified name of a class ''' 50 return cls.__module__ + '.' + cls.__qualname__ 51 52 53def get_reprdiff_cls_hash(): 54 """ 55 Returns a hash value that should be invariable if the 56 `REPRESENTATION_CLASSES` and `DIFFERENTIAL_CLASSES` dictionaries have not 57 changed. 58 """ 59 global _REPRDIFF_HASH 60 if _REPRDIFF_HASH is None: 61 _REPRDIFF_HASH = (hash(tuple(REPRESENTATION_CLASSES.items())) + 62 hash(tuple(DIFFERENTIAL_CLASSES.items()))) 63 return _REPRDIFF_HASH 64 65 66def _invalidate_reprdiff_cls_hash(): 67 global _REPRDIFF_HASH 68 _REPRDIFF_HASH = None 69 70 71def _array2string(values, prefix=''): 72 # Work around version differences for array2string. 73 kwargs = {'separator': ', ', 'prefix': prefix} 74 kwargs['formatter'] = {} 75 76 return np.array2string(values, **kwargs) 77 78 79class BaseRepresentationOrDifferentialInfo(MixinInfo): 80 """ 81 Container for meta information like name, description, format. This is 82 required when the object is used as a mixin column within a table, but can 83 be used as a general way to store meta information. 84 """ 85 attrs_from_parent = {'unit'} # Indicates unit is read-only 86 _supports_indexing = False 87 88 @staticmethod 89 def default_format(val): 90 # Create numpy dtype so that numpy formatting will work. 91 components = val.components 92 values = tuple(getattr(val, component).value for component in components) 93 a = np.empty(getattr(val, 'shape', ()), 94 [(component, value.dtype) for component, value 95 in zip(components, values)]) 96 for component, value in zip(components, values): 97 a[component] = value 98 return str(a) 99 100 @property 101 def _represent_as_dict_attrs(self): 102 return self._parent.components 103 104 @property 105 def unit(self): 106 if self._parent is None: 107 return None 108 109 unit = self._parent._unitstr 110 return unit[1:-1] if unit.startswith('(') else unit 111 112 def new_like(self, reps, length, metadata_conflicts='warn', name=None): 113 """ 114 Return a new instance like ``reps`` with ``length`` rows. 115 116 This is intended for creating an empty column object whose elements can 117 be set in-place for table operations like join or vstack. 118 119 Parameters 120 ---------- 121 reps : list 122 List of input representations or differentials. 123 length : int 124 Length of the output column object 125 metadata_conflicts : str ('warn'|'error'|'silent') 126 How to handle metadata conflicts 127 name : str 128 Output column name 129 130 Returns 131 ------- 132 col : `BaseRepresentation` or `BaseDifferential` subclass instance 133 Empty instance of this class consistent with ``cols`` 134 135 """ 136 137 # Get merged info attributes like shape, dtype, format, description, etc. 138 attrs = self.merge_cols_attributes(reps, metadata_conflicts, name, 139 ('meta', 'description')) 140 # Make a new representation or differential with the desired length 141 # using the _apply / __getitem__ machinery to effectively return 142 # rep0[[0, 0, ..., 0, 0]]. This will have the right shape, and 143 # include possible differentials. 144 indexes = np.zeros(length, dtype=np.int64) 145 out = reps[0][indexes] 146 147 # Use __setitem__ machinery to check whether all representations 148 # can represent themselves as this one without loss of information. 149 for rep in reps[1:]: 150 try: 151 out[0] = rep[0] 152 except Exception as err: 153 raise ValueError(f'input representations are inconsistent.') from err 154 155 # Set (merged) info attributes. 156 for attr in ('name', 'meta', 'description'): 157 if attr in attrs: 158 setattr(out.info, attr, attrs[attr]) 159 160 return out 161 162 163class BaseRepresentationOrDifferential(ShapedLikeNDArray): 164 """3D coordinate representations and differentials. 165 166 Parameters 167 ---------- 168 comp1, comp2, comp3 : `~astropy.units.Quantity` or subclass 169 The components of the 3D point or differential. The names are the 170 keys and the subclasses the values of the ``attr_classes`` attribute. 171 copy : bool, optional 172 If `True` (default), arrays will be copied; if `False`, they will be 173 broadcast together but not use new memory. 174 """ 175 176 # Ensure multiplication/division with ndarray or Quantity doesn't lead to 177 # object arrays. 178 __array_priority__ = 50000 179 180 info = BaseRepresentationOrDifferentialInfo() 181 182 def __init__(self, *args, **kwargs): 183 # make argument a list, so we can pop them off. 184 args = list(args) 185 components = self.components 186 if (args and isinstance(args[0], self.__class__) 187 and all(arg is None for arg in args[1:])): 188 rep_or_diff = args[0] 189 copy = kwargs.pop('copy', True) 190 attrs = [getattr(rep_or_diff, component) 191 for component in components] 192 if 'info' in rep_or_diff.__dict__: 193 self.info = rep_or_diff.info 194 195 if kwargs: 196 raise TypeError(f'unexpected keyword arguments for case ' 197 f'where class instance is passed in: {kwargs}') 198 199 else: 200 attrs = [] 201 for component in components: 202 try: 203 attr = args.pop(0) if args else kwargs.pop(component) 204 except KeyError: 205 raise TypeError(f'__init__() missing 1 required positional ' 206 f'argument: {component!r}') from None 207 208 if attr is None: 209 raise TypeError(f'__init__() missing 1 required positional ' 210 f'argument: {component!r} (or first ' 211 f'argument should be an instance of ' 212 f'{self.__class__.__name__}).') 213 214 attrs.append(attr) 215 216 copy = args.pop(0) if args else kwargs.pop('copy', True) 217 218 if args: 219 raise TypeError(f'unexpected arguments: {args}') 220 221 if kwargs: 222 for component in components: 223 if component in kwargs: 224 raise TypeError(f"__init__() got multiple values for " 225 f"argument {component!r}") 226 227 raise TypeError(f'unexpected keyword arguments: {kwargs}') 228 229 # Pass attributes through the required initializing classes. 230 attrs = [self.attr_classes[component](attr, copy=copy, subok=True) 231 for component, attr in zip(components, attrs)] 232 try: 233 bc_attrs = np.broadcast_arrays(*attrs, subok=True) 234 except ValueError as err: 235 if len(components) <= 2: 236 c_str = ' and '.join(components) 237 else: 238 c_str = ', '.join(components[:2]) + ', and ' + components[2] 239 raise ValueError(f"Input parameters {c_str} cannot be broadcast") from err 240 241 # If inputs have been copied, there is no reason to output a broadcasted array for a 242 # component, so we perform another copy of any component that has been broadcasted 243 # TODO: Look for some way to avoid the double copy in these situations 244 if copy: 245 attrs = [bc_attr.copy() if bc_attr.shape != attr.shape else attr 246 for attr, bc_attr in zip(attrs, bc_attrs)] 247 else: 248 attrs = bc_attrs 249 250 # Set private attributes for the attributes. (If not defined explicitly 251 # on the class, the metaclass will define properties to access these.) 252 for component, attr in zip(components, attrs): 253 setattr(self, '_' + component, attr) 254 255 @classmethod 256 def get_name(cls): 257 """Name of the representation or differential. 258 259 In lower case, with any trailing 'representation' or 'differential' 260 removed. (E.g., 'spherical' for 261 `~astropy.coordinates.SphericalRepresentation` or 262 `~astropy.coordinates.SphericalDifferential`.) 263 """ 264 name = cls.__name__.lower() 265 266 if name.endswith('representation'): 267 name = name[:-14] 268 elif name.endswith('differential'): 269 name = name[:-12] 270 271 return name 272 273 # The two methods that any subclass has to define. 274 @classmethod 275 @abc.abstractmethod 276 def from_cartesian(cls, other): 277 """Create a representation of this class from a supplied Cartesian one. 278 279 Parameters 280 ---------- 281 other : `CartesianRepresentation` 282 The representation to turn into this class 283 284 Returns 285 ------- 286 representation : `BaseRepresentation` subclass instance 287 A new representation of this class's type. 288 """ 289 # Note: the above docstring gets overridden for differentials. 290 raise NotImplementedError() 291 292 @abc.abstractmethod 293 def to_cartesian(self): 294 """Convert the representation to its Cartesian form. 295 296 Note that any differentials get dropped. 297 Also note that orientation information at the origin is *not* preserved by 298 conversions through Cartesian coordinates. For example, transforming 299 an angular position defined at distance=0 through cartesian coordinates 300 and back will lose the original angular coordinates:: 301 302 >>> import astropy.units as u 303 >>> import astropy.coordinates as coord 304 >>> rep = coord.SphericalRepresentation( 305 ... lon=15*u.deg, 306 ... lat=-11*u.deg, 307 ... distance=0*u.pc) 308 >>> rep.to_cartesian().represent_as(coord.SphericalRepresentation) 309 <SphericalRepresentation (lon, lat, distance) in (rad, rad, pc) 310 (0., 0., 0.)> 311 312 Returns 313 ------- 314 cartrepr : `CartesianRepresentation` 315 The representation in Cartesian form. 316 """ 317 # Note: the above docstring gets overridden for differentials. 318 raise NotImplementedError() 319 320 @property 321 def components(self): 322 """A tuple with the in-order names of the coordinate components.""" 323 return tuple(self.attr_classes) 324 325 def __eq__(self, value): 326 """Equality operator 327 328 This implements strict equality and requires that the representation 329 classes are identical and that the representation data are exactly equal. 330 """ 331 if self.__class__ is not value.__class__: 332 raise TypeError(f'cannot compare: objects must have same class: ' 333 f'{self.__class__.__name__} vs. ' 334 f'{value.__class__.__name__}') 335 336 try: 337 np.broadcast(self, value) 338 except ValueError as exc: 339 raise ValueError(f'cannot compare: {exc}') from exc 340 341 out = True 342 for comp in self.components: 343 out &= (getattr(self, '_' + comp) == getattr(value, '_' + comp)) 344 345 return out 346 347 def __ne__(self, value): 348 return np.logical_not(self == value) 349 350 def _apply(self, method, *args, **kwargs): 351 """Create a new representation or differential with ``method`` applied 352 to the component data. 353 354 In typical usage, the method is any of the shape-changing methods for 355 `~numpy.ndarray` (``reshape``, ``swapaxes``, etc.), as well as those 356 picking particular elements (``__getitem__``, ``take``, etc.), which 357 are all defined in `~astropy.utils.shapes.ShapedLikeNDArray`. It will be 358 applied to the underlying arrays (e.g., ``x``, ``y``, and ``z`` for 359 `~astropy.coordinates.CartesianRepresentation`), with the results used 360 to create a new instance. 361 362 Internally, it is also used to apply functions to the components 363 (in particular, `~numpy.broadcast_to`). 364 365 Parameters 366 ---------- 367 method : str or callable 368 If str, it is the name of a method that is applied to the internal 369 ``components``. If callable, the function is applied. 370 *args : tuple 371 Any positional arguments for ``method``. 372 **kwargs : dict 373 Any keyword arguments for ``method``. 374 """ 375 if callable(method): 376 apply_method = lambda array: method(array, *args, **kwargs) 377 else: 378 apply_method = operator.methodcaller(method, *args, **kwargs) 379 380 new = super().__new__(self.__class__) 381 for component in self.components: 382 setattr(new, '_' + component, 383 apply_method(getattr(self, component))) 384 385 # Copy other 'info' attr only if it has actually been defined. 386 # See PR #3898 for further explanation and justification, along 387 # with Quantity.__array_finalize__ 388 if 'info' in self.__dict__: 389 new.info = self.info 390 391 return new 392 393 def __setitem__(self, item, value): 394 if value.__class__ is not self.__class__: 395 raise TypeError(f'can only set from object of same class: ' 396 f'{self.__class__.__name__} vs. ' 397 f'{value.__class__.__name__}') 398 399 for component in self.components: 400 getattr(self, '_' + component)[item] = getattr(value, '_' + component) 401 402 @property 403 def shape(self): 404 """The shape of the instance and underlying arrays. 405 406 Like `~numpy.ndarray.shape`, can be set to a new shape by assigning a 407 tuple. Note that if different instances share some but not all 408 underlying data, setting the shape of one instance can make the other 409 instance unusable. Hence, it is strongly recommended to get new, 410 reshaped instances with the ``reshape`` method. 411 412 Raises 413 ------ 414 ValueError 415 If the new shape has the wrong total number of elements. 416 AttributeError 417 If the shape of any of the components cannot be changed without the 418 arrays being copied. For these cases, use the ``reshape`` method 419 (which copies any arrays that cannot be reshaped in-place). 420 """ 421 return getattr(self, self.components[0]).shape 422 423 @shape.setter 424 def shape(self, shape): 425 # We keep track of arrays that were already reshaped since we may have 426 # to return those to their original shape if a later shape-setting 427 # fails. (This can happen since coordinates are broadcast together.) 428 reshaped = [] 429 oldshape = self.shape 430 for component in self.components: 431 val = getattr(self, component) 432 if val.size > 1: 433 try: 434 val.shape = shape 435 except Exception: 436 for val2 in reshaped: 437 val2.shape = oldshape 438 raise 439 else: 440 reshaped.append(val) 441 442 # Required to support multiplication and division, and defined by the base 443 # representation and differential classes. 444 @abc.abstractmethod 445 def _scale_operation(self, op, *args): 446 raise NotImplementedError() 447 448 def __mul__(self, other): 449 return self._scale_operation(operator.mul, other) 450 451 def __rmul__(self, other): 452 return self.__mul__(other) 453 454 def __truediv__(self, other): 455 return self._scale_operation(operator.truediv, other) 456 457 def __div__(self, other): # pragma: py2 458 return self._scale_operation(operator.truediv, other) 459 460 def __neg__(self): 461 return self._scale_operation(operator.neg) 462 463 # Follow numpy convention and make an independent copy. 464 def __pos__(self): 465 return self.copy() 466 467 # Required to support addition and subtraction, and defined by the base 468 # representation and differential classes. 469 @abc.abstractmethod 470 def _combine_operation(self, op, other, reverse=False): 471 raise NotImplementedError() 472 473 def __add__(self, other): 474 return self._combine_operation(operator.add, other) 475 476 def __radd__(self, other): 477 return self._combine_operation(operator.add, other, reverse=True) 478 479 def __sub__(self, other): 480 return self._combine_operation(operator.sub, other) 481 482 def __rsub__(self, other): 483 return self._combine_operation(operator.sub, other, reverse=True) 484 485 # The following are used for repr and str 486 @property 487 def _values(self): 488 """Turn the coordinates into a record array with the coordinate values. 489 490 The record array fields will have the component names. 491 """ 492 coo_items = [(c, getattr(self, c)) for c in self.components] 493 result = np.empty(self.shape, [(c, coo.dtype) for c, coo in coo_items]) 494 for c, coo in coo_items: 495 result[c] = coo.value 496 return result 497 498 @property 499 def _units(self): 500 """Return a dictionary with the units of the coordinate components.""" 501 return dict([(component, getattr(self, component).unit) 502 for component in self.components]) 503 504 @property 505 def _unitstr(self): 506 units_set = set(self._units.values()) 507 if len(units_set) == 1: 508 unitstr = units_set.pop().to_string() 509 else: 510 unitstr = '({})'.format( 511 ', '.join([self._units[component].to_string() 512 for component in self.components])) 513 return unitstr 514 515 def __str__(self): 516 return f'{_array2string(self._values)} {self._unitstr:s}' 517 518 def __repr__(self): 519 prefixstr = ' ' 520 arrstr = _array2string(self._values, prefix=prefixstr) 521 522 diffstr = '' 523 if getattr(self, 'differentials', None): 524 diffstr = '\n (has differentials w.r.t.: {})'.format( 525 ', '.join([repr(key) for key in self.differentials.keys()])) 526 527 unitstr = ('in ' + self._unitstr) if self._unitstr else '[dimensionless]' 528 return '<{} ({}) {:s}\n{}{}{}>'.format( 529 self.__class__.__name__, ', '.join(self.components), 530 unitstr, prefixstr, arrstr, diffstr) 531 532 533def _make_getter(component): 534 """Make an attribute getter for use in a property. 535 536 Parameters 537 ---------- 538 component : str 539 The name of the component that should be accessed. This assumes the 540 actual value is stored in an attribute of that name prefixed by '_'. 541 """ 542 # This has to be done in a function to ensure the reference to component 543 # is not lost/redirected. 544 component = '_' + component 545 546 def get_component(self): 547 return getattr(self, component) 548 return get_component 549 550 551class RepresentationInfo(BaseRepresentationOrDifferentialInfo): 552 553 @property 554 def _represent_as_dict_attrs(self): 555 attrs = super()._represent_as_dict_attrs 556 if self._parent._differentials: 557 attrs += ('differentials',) 558 return attrs 559 560 def _represent_as_dict(self, attrs=None): 561 out = super()._represent_as_dict(attrs) 562 for key, value in out.pop('differentials', {}).items(): 563 out[f'differentials.{key}'] = value 564 return out 565 566 def _construct_from_dict(self, map): 567 differentials = {} 568 for key in list(map.keys()): 569 if key.startswith('differentials.'): 570 differentials[key[14:]] = map.pop(key) 571 map['differentials'] = differentials 572 return super()._construct_from_dict(map) 573 574 575class BaseRepresentation(BaseRepresentationOrDifferential): 576 """Base for representing a point in a 3D coordinate system. 577 578 Parameters 579 ---------- 580 comp1, comp2, comp3 : `~astropy.units.Quantity` or subclass 581 The components of the 3D points. The names are the keys and the 582 subclasses the values of the ``attr_classes`` attribute. 583 differentials : dict, `~astropy.coordinates.BaseDifferential`, optional 584 Any differential classes that should be associated with this 585 representation. The input must either be a single `~astropy.coordinates.BaseDifferential` 586 subclass instance, or a dictionary with keys set to a string 587 representation of the SI unit with which the differential (derivative) 588 is taken. For example, for a velocity differential on a positional 589 representation, the key would be ``'s'`` for seconds, indicating that 590 the derivative is a time derivative. 591 copy : bool, optional 592 If `True` (default), arrays will be copied. If `False`, arrays will 593 be references, though possibly broadcast to ensure matching shapes. 594 595 Notes 596 ----- 597 All representation classes should subclass this base representation class, 598 and define an ``attr_classes`` attribute, a `dict` 599 which maps component names to the class that creates them. They must also 600 define a ``to_cartesian`` method and a ``from_cartesian`` class method. By 601 default, transformations are done via the cartesian system, but classes 602 that want to define a smarter transformation path can overload the 603 ``represent_as`` method. If one wants to use an associated differential 604 class, one should also define ``unit_vectors`` and ``scale_factors`` 605 methods (see those methods for details). 606 """ 607 608 info = RepresentationInfo() 609 610 def __init_subclass__(cls, **kwargs): 611 # Register representation name (except for BaseRepresentation) 612 if cls.__name__ == 'BaseRepresentation': 613 return 614 615 if not hasattr(cls, 'attr_classes'): 616 raise NotImplementedError('Representations must have an ' 617 '"attr_classes" class attribute.') 618 619 repr_name = cls.get_name() 620 # first time a duplicate is added 621 # remove first entry and add both using their qualnames 622 if repr_name in REPRESENTATION_CLASSES: 623 DUPLICATE_REPRESENTATIONS.add(repr_name) 624 625 fqn_cls = _fqn_class(cls) 626 existing = REPRESENTATION_CLASSES[repr_name] 627 fqn_existing = _fqn_class(existing) 628 629 if fqn_cls == fqn_existing: 630 raise ValueError(f'Representation "{fqn_cls}" already defined') 631 632 msg = ( 633 f'Representation "{repr_name}" already defined, removing it to avoid confusion.' 634 f'Use qualnames "{fqn_cls}" and "{fqn_existing}" or class instances directly' 635 ) 636 warnings.warn(msg, DuplicateRepresentationWarning) 637 638 del REPRESENTATION_CLASSES[repr_name] 639 REPRESENTATION_CLASSES[fqn_existing] = existing 640 repr_name = fqn_cls 641 642 # further definitions with the same name, just add qualname 643 elif repr_name in DUPLICATE_REPRESENTATIONS: 644 fqn_cls = _fqn_class(cls) 645 warnings.warn(f'Representation "{repr_name}" already defined, using qualname ' 646 f'"{fqn_cls}".') 647 repr_name = fqn_cls 648 if repr_name in REPRESENTATION_CLASSES: 649 raise ValueError( 650 f'Representation "{repr_name}" already defined' 651 ) 652 653 REPRESENTATION_CLASSES[repr_name] = cls 654 _invalidate_reprdiff_cls_hash() 655 656 # define getters for any component that does not yet have one. 657 for component in cls.attr_classes: 658 if not hasattr(cls, component): 659 setattr(cls, component, 660 property(_make_getter(component), 661 doc=f"The '{component}' component of the points(s).")) 662 663 super().__init_subclass__(**kwargs) 664 665 def __init__(self, *args, differentials=None, **kwargs): 666 # Handle any differentials passed in. 667 super().__init__(*args, **kwargs) 668 if (differentials is None 669 and args and isinstance(args[0], self.__class__)): 670 differentials = args[0]._differentials 671 self._differentials = self._validate_differentials(differentials) 672 673 def _validate_differentials(self, differentials): 674 """ 675 Validate that the provided differentials are appropriate for this 676 representation and recast/reshape as necessary and then return. 677 678 Note that this does *not* set the differentials on 679 ``self._differentials``, but rather leaves that for the caller. 680 """ 681 682 # Now handle the actual validation of any specified differential classes 683 if differentials is None: 684 differentials = dict() 685 686 elif isinstance(differentials, BaseDifferential): 687 # We can't handle auto-determining the key for this combo 688 if (isinstance(differentials, RadialDifferential) and 689 isinstance(self, UnitSphericalRepresentation)): 690 raise ValueError("To attach a RadialDifferential to a " 691 "UnitSphericalRepresentation, you must supply " 692 "a dictionary with an appropriate key.") 693 694 key = differentials._get_deriv_key(self) 695 differentials = {key: differentials} 696 697 for key in differentials: 698 try: 699 diff = differentials[key] 700 except TypeError as err: 701 raise TypeError("'differentials' argument must be a " 702 "dictionary-like object") from err 703 704 diff._check_base(self) 705 706 if (isinstance(diff, RadialDifferential) and 707 isinstance(self, UnitSphericalRepresentation)): 708 # We trust the passing of a key for a RadialDifferential 709 # attached to a UnitSphericalRepresentation because it will not 710 # have a paired component name (UnitSphericalRepresentation has 711 # no .distance) to automatically determine the expected key 712 pass 713 714 else: 715 expected_key = diff._get_deriv_key(self) 716 if key != expected_key: 717 raise ValueError("For differential object '{}', expected " 718 "unit key = '{}' but received key = '{}'" 719 .format(repr(diff), expected_key, key)) 720 721 # For now, we are very rigid: differentials must have the same shape 722 # as the representation. This makes it easier to handle __getitem__ 723 # and any other shape-changing operations on representations that 724 # have associated differentials 725 if diff.shape != self.shape: 726 # TODO: message of IncompatibleShapeError is not customizable, 727 # so use a valueerror instead? 728 raise ValueError("Shape of differentials must be the same " 729 "as the shape of the representation ({} vs " 730 "{})".format(diff.shape, self.shape)) 731 732 return differentials 733 734 def _raise_if_has_differentials(self, op_name): 735 """ 736 Used to raise a consistent exception for any operation that is not 737 supported when a representation has differentials attached. 738 """ 739 if self.differentials: 740 raise TypeError("Operation '{}' is not supported when " 741 "differentials are attached to a {}." 742 .format(op_name, self.__class__.__name__)) 743 744 @classproperty 745 def _compatible_differentials(cls): 746 return [DIFFERENTIAL_CLASSES[cls.get_name()]] 747 748 @property 749 def differentials(self): 750 """A dictionary of differential class instances. 751 752 The keys of this dictionary must be a string representation of the SI 753 unit with which the differential (derivative) is taken. For example, for 754 a velocity differential on a positional representation, the key would be 755 ``'s'`` for seconds, indicating that the derivative is a time 756 derivative. 757 """ 758 return self._differentials 759 760 # We do not make unit_vectors and scale_factors abstract methods, since 761 # they are only necessary if one also defines an associated Differential. 762 # Also, doing so would break pre-differential representation subclasses. 763 def unit_vectors(self): 764 r"""Cartesian unit vectors in the direction of each component. 765 766 Given unit vectors :math:`\hat{e}_c` and scale factors :math:`f_c`, 767 a change in one component of :math:`\delta c` corresponds to a change 768 in representation of :math:`\delta c \times f_c \times \hat{e}_c`. 769 770 Returns 771 ------- 772 unit_vectors : dict of `CartesianRepresentation` 773 The keys are the component names. 774 """ 775 raise NotImplementedError(f"{type(self)} has not implemented unit vectors") 776 777 def scale_factors(self): 778 r"""Scale factors for each component's direction. 779 780 Given unit vectors :math:`\hat{e}_c` and scale factors :math:`f_c`, 781 a change in one component of :math:`\delta c` corresponds to a change 782 in representation of :math:`\delta c \times f_c \times \hat{e}_c`. 783 784 Returns 785 ------- 786 scale_factors : dict of `~astropy.units.Quantity` 787 The keys are the component names. 788 """ 789 raise NotImplementedError(f"{type(self)} has not implemented scale factors.") 790 791 def _re_represent_differentials(self, new_rep, differential_class): 792 """Re-represent the differentials to the specified classes. 793 794 This returns a new dictionary with the same keys but with the 795 attached differentials converted to the new differential classes. 796 """ 797 if differential_class is None: 798 return dict() 799 800 if not self.differentials and differential_class: 801 raise ValueError("No differentials associated with this " 802 "representation!") 803 804 elif (len(self.differentials) == 1 and 805 inspect.isclass(differential_class) and 806 issubclass(differential_class, BaseDifferential)): 807 # TODO: is there a better way to do this? 808 differential_class = { 809 list(self.differentials.keys())[0]: differential_class 810 } 811 812 elif differential_class.keys() != self.differentials.keys(): 813 raise ValueError("Desired differential classes must be passed in " 814 "as a dictionary with keys equal to a string " 815 "representation of the unit of the derivative " 816 "for each differential stored with this " 817 "representation object ({0})" 818 .format(self.differentials)) 819 820 new_diffs = dict() 821 for k in self.differentials: 822 diff = self.differentials[k] 823 try: 824 new_diffs[k] = diff.represent_as(differential_class[k], 825 base=self) 826 except Exception as err: 827 if (differential_class[k] not in 828 new_rep._compatible_differentials): 829 raise TypeError("Desired differential class {} is not " 830 "compatible with the desired " 831 "representation class {}" 832 .format(differential_class[k], 833 new_rep.__class__)) from err 834 else: 835 raise 836 837 return new_diffs 838 839 def represent_as(self, other_class, differential_class=None): 840 """Convert coordinates to another representation. 841 842 If the instance is of the requested class, it is returned unmodified. 843 By default, conversion is done via Cartesian coordinates. 844 Also note that orientation information at the origin is *not* preserved by 845 conversions through Cartesian coordinates. See the docstring for 846 :meth:`~astropy.coordinates.BaseRepresentationOrDifferential.to_cartesian` 847 for an example. 848 849 Parameters 850 ---------- 851 other_class : `~astropy.coordinates.BaseRepresentation` subclass 852 The type of representation to turn the coordinates into. 853 differential_class : dict of `~astropy.coordinates.BaseDifferential`, optional 854 Classes in which the differentials should be represented. 855 Can be a single class if only a single differential is attached, 856 otherwise it should be a `dict` keyed by the same keys as the 857 differentials. 858 """ 859 if other_class is self.__class__ and not differential_class: 860 return self.without_differentials() 861 862 else: 863 if isinstance(other_class, str): 864 raise ValueError("Input to a representation's represent_as " 865 "must be a class, not a string. For " 866 "strings, use frame objects") 867 868 if other_class is not self.__class__: 869 # The default is to convert via cartesian coordinates 870 new_rep = other_class.from_cartesian(self.to_cartesian()) 871 else: 872 new_rep = self 873 874 new_rep._differentials = self._re_represent_differentials( 875 new_rep, differential_class) 876 877 return new_rep 878 879 def transform(self, matrix): 880 """Transform coordinates using a 3x3 matrix in a Cartesian basis. 881 882 This returns a new representation and does not modify the original one. 883 Any differentials attached to this representation will also be 884 transformed. 885 886 Parameters 887 ---------- 888 matrix : (3,3) array-like 889 A 3x3 (or stack thereof) matrix, such as a rotation matrix. 890 891 """ 892 # route transformation through Cartesian 893 difs_cls = {k: CartesianDifferential for k in self.differentials.keys()} 894 crep = self.represent_as(CartesianRepresentation, 895 differential_class=difs_cls 896 ).transform(matrix) 897 898 # move back to original representation 899 difs_cls = {k: diff.__class__ for k, diff in self.differentials.items()} 900 rep = crep.represent_as(self.__class__, difs_cls) 901 return rep 902 903 def with_differentials(self, differentials): 904 """ 905 Create a new representation with the same positions as this 906 representation, but with these new differentials. 907 908 Differential keys that already exist in this object's differential dict 909 are overwritten. 910 911 Parameters 912 ---------- 913 differentials : sequence of `~astropy.coordinates.BaseDifferential` subclass instance 914 The differentials for the new representation to have. 915 916 Returns 917 ------- 918 `~astropy.coordinates.BaseRepresentation` subclass instance 919 A copy of this representation, but with the ``differentials`` as 920 its differentials. 921 """ 922 if not differentials: 923 return self 924 925 args = [getattr(self, component) for component in self.components] 926 927 # We shallow copy the differentials dictionary so we don't update the 928 # current object's dictionary when adding new keys 929 new_rep = self.__class__(*args, differentials=self.differentials.copy(), 930 copy=False) 931 new_rep._differentials.update( 932 new_rep._validate_differentials(differentials)) 933 934 return new_rep 935 936 def without_differentials(self): 937 """Return a copy of the representation without attached differentials. 938 939 Returns 940 ------- 941 `~astropy.coordinates.BaseRepresentation` subclass instance 942 A shallow copy of this representation, without any differentials. 943 If no differentials were present, no copy is made. 944 """ 945 946 if not self._differentials: 947 return self 948 949 args = [getattr(self, component) for component in self.components] 950 return self.__class__(*args, copy=False) 951 952 @classmethod 953 def from_representation(cls, representation): 954 """Create a new instance of this representation from another one. 955 956 Parameters 957 ---------- 958 representation : `~astropy.coordinates.BaseRepresentation` instance 959 The presentation that should be converted to this class. 960 """ 961 return representation.represent_as(cls) 962 963 def __eq__(self, value): 964 """Equality operator for BaseRepresentation 965 966 This implements strict equality and requires that the representation 967 classes are identical, the differentials are identical, and that the 968 representation data are exactly equal. 969 """ 970 # BaseRepresentationOrDifferental (checks classes and compares components) 971 out = super().__eq__(value) 972 973 # super() checks that the class is identical so can this even happen? 974 # (same class, different differentials ?) 975 if self._differentials.keys() != value._differentials.keys(): 976 raise ValueError(f'cannot compare: objects must have same differentials') 977 978 for self_diff, value_diff in zip(self._differentials.values(), 979 value._differentials.values()): 980 out &= (self_diff == value_diff) 981 982 return out 983 984 def __ne__(self, value): 985 return np.logical_not(self == value) 986 987 def _apply(self, method, *args, **kwargs): 988 """Create a new representation with ``method`` applied to the component 989 data. 990 991 This is not a simple inherit from ``BaseRepresentationOrDifferential`` 992 because we need to call ``._apply()`` on any associated differential 993 classes. 994 995 See docstring for `BaseRepresentationOrDifferential._apply`. 996 997 Parameters 998 ---------- 999 method : str or callable 1000 If str, it is the name of a method that is applied to the internal 1001 ``components``. If callable, the function is applied. 1002 *args : tuple 1003 Any positional arguments for ``method``. 1004 **kwargs : dict 1005 Any keyword arguments for ``method``. 1006 1007 """ 1008 rep = super()._apply(method, *args, **kwargs) 1009 1010 rep._differentials = dict( 1011 [(k, diff._apply(method, *args, **kwargs)) 1012 for k, diff in self._differentials.items()]) 1013 return rep 1014 1015 def __setitem__(self, item, value): 1016 if not isinstance(value, BaseRepresentation): 1017 raise TypeError(f'value must be a representation instance, ' 1018 f'not {type(value)}.') 1019 1020 if not (isinstance(value, self.__class__) 1021 or len(value.attr_classes) == len(self.attr_classes)): 1022 raise ValueError( 1023 f'value must be representable as {self.__class__.__name__} ' 1024 f'without loss of information.') 1025 1026 diff_classes = {} 1027 if self._differentials: 1028 if self._differentials.keys() != value._differentials.keys(): 1029 raise ValueError('value must have the same differentials.') 1030 1031 for key, self_diff in self._differentials.items(): 1032 diff_classes[key] = self_diff_cls = self_diff.__class__ 1033 value_diff_cls = value._differentials[key].__class__ 1034 if not (isinstance(value_diff_cls, self_diff_cls) 1035 or (len(value_diff_cls.attr_classes) 1036 == len(self_diff_cls.attr_classes))): 1037 raise ValueError( 1038 f'value differential {key!r} must be representable as ' 1039 f'{self_diff.__class__.__name__} without loss of information.') 1040 1041 value = value.represent_as(self.__class__, diff_classes) 1042 super().__setitem__(item, value) 1043 for key, differential in self._differentials.items(): 1044 differential[item] = value._differentials[key] 1045 1046 def _scale_operation(self, op, *args): 1047 """Scale all non-angular components, leaving angular ones unchanged. 1048 1049 Parameters 1050 ---------- 1051 op : `~operator` callable 1052 Operator to apply (e.g., `~operator.mul`, `~operator.neg`, etc. 1053 *args 1054 Any arguments required for the operator (typically, what is to 1055 be multiplied with, divided by). 1056 """ 1057 results = [] 1058 for component, cls in self.attr_classes.items(): 1059 value = getattr(self, component) 1060 if issubclass(cls, Angle): 1061 results.append(value) 1062 else: 1063 results.append(op(value, *args)) 1064 1065 # try/except catches anything that cannot initialize the class, such 1066 # as operations that returned NotImplemented or a representation 1067 # instead of a quantity (as would happen for, e.g., rep * rep). 1068 try: 1069 result = self.__class__(*results) 1070 except Exception: 1071 return NotImplemented 1072 1073 for key, differential in self.differentials.items(): 1074 diff_result = differential._scale_operation(op, *args, scaled_base=True) 1075 result.differentials[key] = diff_result 1076 1077 return result 1078 1079 def _combine_operation(self, op, other, reverse=False): 1080 """Combine two representation. 1081 1082 By default, operate on the cartesian representations of both. 1083 1084 Parameters 1085 ---------- 1086 op : `~operator` callable 1087 Operator to apply (e.g., `~operator.add`, `~operator.sub`, etc. 1088 other : `~astropy.coordinates.BaseRepresentation` subclass instance 1089 The other representation. 1090 reverse : bool 1091 Whether the operands should be reversed (e.g., as we got here via 1092 ``self.__rsub__`` because ``self`` is a subclass of ``other``). 1093 """ 1094 self._raise_if_has_differentials(op.__name__) 1095 1096 result = self.to_cartesian()._combine_operation(op, other, reverse) 1097 if result is NotImplemented: 1098 return NotImplemented 1099 else: 1100 return self.from_cartesian(result) 1101 1102 # We need to override this setter to support differentials 1103 @BaseRepresentationOrDifferential.shape.setter 1104 def shape(self, shape): 1105 orig_shape = self.shape 1106 1107 # See: https://stackoverflow.com/questions/3336767/ for an example 1108 BaseRepresentationOrDifferential.shape.fset(self, shape) 1109 1110 # also try to perform shape-setting on any associated differentials 1111 try: 1112 for k in self.differentials: 1113 self.differentials[k].shape = shape 1114 except Exception: 1115 BaseRepresentationOrDifferential.shape.fset(self, orig_shape) 1116 for k in self.differentials: 1117 self.differentials[k].shape = orig_shape 1118 1119 raise 1120 1121 def norm(self): 1122 """Vector norm. 1123 1124 The norm is the standard Frobenius norm, i.e., the square root of the 1125 sum of the squares of all components with non-angular units. 1126 1127 Note that any associated differentials will be dropped during this 1128 operation. 1129 1130 Returns 1131 ------- 1132 norm : `astropy.units.Quantity` 1133 Vector norm, with the same shape as the representation. 1134 """ 1135 return np.sqrt(functools.reduce( 1136 operator.add, (getattr(self, component)**2 1137 for component, cls in self.attr_classes.items() 1138 if not issubclass(cls, Angle)))) 1139 1140 def mean(self, *args, **kwargs): 1141 """Vector mean. 1142 1143 Averaging is done by converting the representation to cartesian, and 1144 taking the mean of the x, y, and z components. The result is converted 1145 back to the same representation as the input. 1146 1147 Refer to `~numpy.mean` for full documentation of the arguments, noting 1148 that ``axis`` is the entry in the ``shape`` of the representation, and 1149 that the ``out`` argument cannot be used. 1150 1151 Returns 1152 ------- 1153 mean : `~astropy.coordinates.BaseRepresentation` subclass instance 1154 Vector mean, in the same representation as that of the input. 1155 """ 1156 self._raise_if_has_differentials('mean') 1157 return self.from_cartesian(self.to_cartesian().mean(*args, **kwargs)) 1158 1159 def sum(self, *args, **kwargs): 1160 """Vector sum. 1161 1162 Adding is done by converting the representation to cartesian, and 1163 summing the x, y, and z components. The result is converted back to the 1164 same representation as the input. 1165 1166 Refer to `~numpy.sum` for full documentation of the arguments, noting 1167 that ``axis`` is the entry in the ``shape`` of the representation, and 1168 that the ``out`` argument cannot be used. 1169 1170 Returns 1171 ------- 1172 sum : `~astropy.coordinates.BaseRepresentation` subclass instance 1173 Vector sum, in the same representation as that of the input. 1174 """ 1175 self._raise_if_has_differentials('sum') 1176 return self.from_cartesian(self.to_cartesian().sum(*args, **kwargs)) 1177 1178 def dot(self, other): 1179 """Dot product of two representations. 1180 1181 The calculation is done by converting both ``self`` and ``other`` 1182 to `~astropy.coordinates.CartesianRepresentation`. 1183 1184 Note that any associated differentials will be dropped during this 1185 operation. 1186 1187 Parameters 1188 ---------- 1189 other : `~astropy.coordinates.BaseRepresentation` 1190 The representation to take the dot product with. 1191 1192 Returns 1193 ------- 1194 dot_product : `~astropy.units.Quantity` 1195 The sum of the product of the x, y, and z components of the 1196 cartesian representations of ``self`` and ``other``. 1197 """ 1198 return self.to_cartesian().dot(other) 1199 1200 def cross(self, other): 1201 """Vector cross product of two representations. 1202 1203 The calculation is done by converting both ``self`` and ``other`` 1204 to `~astropy.coordinates.CartesianRepresentation`, and converting the 1205 result back to the type of representation of ``self``. 1206 1207 Parameters 1208 ---------- 1209 other : `~astropy.coordinates.BaseRepresentation` subclass instance 1210 The representation to take the cross product with. 1211 1212 Returns 1213 ------- 1214 cross_product : `~astropy.coordinates.BaseRepresentation` subclass instance 1215 With vectors perpendicular to both ``self`` and ``other``, in the 1216 same type of representation as ``self``. 1217 """ 1218 self._raise_if_has_differentials('cross') 1219 return self.from_cartesian(self.to_cartesian().cross(other)) 1220 1221 1222class CartesianRepresentation(BaseRepresentation): 1223 """ 1224 Representation of points in 3D cartesian coordinates. 1225 1226 Parameters 1227 ---------- 1228 x, y, z : `~astropy.units.Quantity` or array 1229 The x, y, and z coordinates of the point(s). If ``x``, ``y``, and ``z`` 1230 have different shapes, they should be broadcastable. If not quantity, 1231 ``unit`` should be set. If only ``x`` is given, it is assumed that it 1232 contains an array with the 3 coordinates stored along ``xyz_axis``. 1233 unit : unit-like 1234 If given, the coordinates will be converted to this unit (or taken to 1235 be in this unit if not given. 1236 xyz_axis : int, optional 1237 The axis along which the coordinates are stored when a single array is 1238 provided rather than distinct ``x``, ``y``, and ``z`` (default: 0). 1239 1240 differentials : dict, `CartesianDifferential`, optional 1241 Any differential classes that should be associated with this 1242 representation. The input must either be a single 1243 `CartesianDifferential` instance, or a dictionary of 1244 `CartesianDifferential` s with keys set to a string representation of 1245 the SI unit with which the differential (derivative) is taken. For 1246 example, for a velocity differential on a positional representation, the 1247 key would be ``'s'`` for seconds, indicating that the derivative is a 1248 time derivative. 1249 1250 copy : bool, optional 1251 If `True` (default), arrays will be copied. If `False`, arrays will 1252 be references, though possibly broadcast to ensure matching shapes. 1253 """ 1254 1255 attr_classes = {'x': u.Quantity, 1256 'y': u.Quantity, 1257 'z': u.Quantity} 1258 1259 _xyz = None 1260 1261 def __init__(self, x, y=None, z=None, unit=None, xyz_axis=None, 1262 differentials=None, copy=True): 1263 1264 if y is None and z is None: 1265 if isinstance(x, np.ndarray) and x.dtype.kind not in 'OV': 1266 # Short-cut for 3-D array input. 1267 x = u.Quantity(x, unit, copy=copy, subok=True) 1268 # Keep a link to the array with all three coordinates 1269 # so that we can return it quickly if needed in get_xyz. 1270 self._xyz = x 1271 if xyz_axis: 1272 x = np.moveaxis(x, xyz_axis, 0) 1273 self._xyz_axis = xyz_axis 1274 else: 1275 self._xyz_axis = 0 1276 1277 self._x, self._y, self._z = x 1278 self._differentials = self._validate_differentials(differentials) 1279 return 1280 1281 elif (isinstance(x, CartesianRepresentation) 1282 and unit is None and xyz_axis is None): 1283 if differentials is None: 1284 differentials = x._differentials 1285 1286 return super().__init__(x, differentials=differentials, 1287 copy=copy) 1288 1289 else: 1290 x, y, z = x 1291 1292 if xyz_axis is not None: 1293 raise ValueError("xyz_axis should only be set if x, y, and z are " 1294 "in a single array passed in through x, " 1295 "i.e., y and z should not be not given.") 1296 1297 if y is None or z is None: 1298 raise ValueError("x, y, and z are required to instantiate {}" 1299 .format(self.__class__.__name__)) 1300 1301 if unit is not None: 1302 x = u.Quantity(x, unit, copy=copy, subok=True) 1303 y = u.Quantity(y, unit, copy=copy, subok=True) 1304 z = u.Quantity(z, unit, copy=copy, subok=True) 1305 copy = False 1306 1307 super().__init__(x, y, z, copy=copy, differentials=differentials) 1308 if not (self._x.unit.is_equivalent(self._y.unit) and 1309 self._x.unit.is_equivalent(self._z.unit)): 1310 raise u.UnitsError("x, y, and z should have matching physical types") 1311 1312 def unit_vectors(self): 1313 l = np.broadcast_to(1.*u.one, self.shape, subok=True) 1314 o = np.broadcast_to(0.*u.one, self.shape, subok=True) 1315 return { 1316 'x': CartesianRepresentation(l, o, o, copy=False), 1317 'y': CartesianRepresentation(o, l, o, copy=False), 1318 'z': CartesianRepresentation(o, o, l, copy=False)} 1319 1320 def scale_factors(self): 1321 l = np.broadcast_to(1.*u.one, self.shape, subok=True) 1322 return {'x': l, 'y': l, 'z': l} 1323 1324 def get_xyz(self, xyz_axis=0): 1325 """Return a vector array of the x, y, and z coordinates. 1326 1327 Parameters 1328 ---------- 1329 xyz_axis : int, optional 1330 The axis in the final array along which the x, y, z components 1331 should be stored (default: 0). 1332 1333 Returns 1334 ------- 1335 xyz : `~astropy.units.Quantity` 1336 With dimension 3 along ``xyz_axis``. Note that, if possible, 1337 this will be a view. 1338 """ 1339 if self._xyz is not None: 1340 if self._xyz_axis == xyz_axis: 1341 return self._xyz 1342 else: 1343 return np.moveaxis(self._xyz, self._xyz_axis, xyz_axis) 1344 1345 # Create combined array. TO DO: keep it in _xyz for repeated use? 1346 # But then in-place changes have to cancel it. Likely best to 1347 # also update components. 1348 return np.stack([self._x, self._y, self._z], axis=xyz_axis) 1349 1350 xyz = property(get_xyz) 1351 1352 @classmethod 1353 def from_cartesian(cls, other): 1354 return other 1355 1356 def to_cartesian(self): 1357 return self 1358 1359 def transform(self, matrix): 1360 """ 1361 Transform the cartesian coordinates using a 3x3 matrix. 1362 1363 This returns a new representation and does not modify the original one. 1364 Any differentials attached to this representation will also be 1365 transformed. 1366 1367 Parameters 1368 ---------- 1369 matrix : ndarray 1370 A 3x3 transformation matrix, such as a rotation matrix. 1371 1372 Examples 1373 -------- 1374 1375 We can start off by creating a cartesian representation object: 1376 1377 >>> from astropy import units as u 1378 >>> from astropy.coordinates import CartesianRepresentation 1379 >>> rep = CartesianRepresentation([1, 2] * u.pc, 1380 ... [2, 3] * u.pc, 1381 ... [3, 4] * u.pc) 1382 1383 We now create a rotation matrix around the z axis: 1384 1385 >>> from astropy.coordinates.matrix_utilities import rotation_matrix 1386 >>> rotation = rotation_matrix(30 * u.deg, axis='z') 1387 1388 Finally, we can apply this transformation: 1389 1390 >>> rep_new = rep.transform(rotation) 1391 >>> rep_new.xyz # doctest: +FLOAT_CMP 1392 <Quantity [[ 1.8660254 , 3.23205081], 1393 [ 1.23205081, 1.59807621], 1394 [ 3. , 4. ]] pc> 1395 """ 1396 # erfa rxp: Multiply a p-vector by an r-matrix. 1397 p = erfa_ufunc.rxp(matrix, self.get_xyz(xyz_axis=-1)) 1398 # transformed representation 1399 rep = self.__class__(p, xyz_axis=-1, copy=False) 1400 # Handle differentials attached to this representation 1401 new_diffs = dict((k, d.transform(matrix, self, rep)) 1402 for k, d in self.differentials.items()) 1403 return rep.with_differentials(new_diffs) 1404 1405 def _combine_operation(self, op, other, reverse=False): 1406 self._raise_if_has_differentials(op.__name__) 1407 1408 try: 1409 other_c = other.to_cartesian() 1410 except Exception: 1411 return NotImplemented 1412 1413 first, second = ((self, other_c) if not reverse else 1414 (other_c, self)) 1415 return self.__class__(*(op(getattr(first, component), 1416 getattr(second, component)) 1417 for component in first.components)) 1418 1419 def norm(self): 1420 """Vector norm. 1421 1422 The norm is the standard Frobenius norm, i.e., the square root of the 1423 sum of the squares of all components with non-angular units. 1424 1425 Note that any associated differentials will be dropped during this 1426 operation. 1427 1428 Returns 1429 ------- 1430 norm : `astropy.units.Quantity` 1431 Vector norm, with the same shape as the representation. 1432 """ 1433 # erfa pm: Modulus of p-vector. 1434 return erfa_ufunc.pm(self.get_xyz(xyz_axis=-1)) 1435 1436 def mean(self, *args, **kwargs): 1437 """Vector mean. 1438 1439 Returns a new CartesianRepresentation instance with the means of the 1440 x, y, and z components. 1441 1442 Refer to `~numpy.mean` for full documentation of the arguments, noting 1443 that ``axis`` is the entry in the ``shape`` of the representation, and 1444 that the ``out`` argument cannot be used. 1445 """ 1446 self._raise_if_has_differentials('mean') 1447 return self._apply('mean', *args, **kwargs) 1448 1449 def sum(self, *args, **kwargs): 1450 """Vector sum. 1451 1452 Returns a new CartesianRepresentation instance with the sums of the 1453 x, y, and z components. 1454 1455 Refer to `~numpy.sum` for full documentation of the arguments, noting 1456 that ``axis`` is the entry in the ``shape`` of the representation, and 1457 that the ``out`` argument cannot be used. 1458 """ 1459 self._raise_if_has_differentials('sum') 1460 return self._apply('sum', *args, **kwargs) 1461 1462 def dot(self, other): 1463 """Dot product of two representations. 1464 1465 Note that any associated differentials will be dropped during this 1466 operation. 1467 1468 Parameters 1469 ---------- 1470 other : `~astropy.coordinates.BaseRepresentation` subclass instance 1471 If not already cartesian, it is converted. 1472 1473 Returns 1474 ------- 1475 dot_product : `~astropy.units.Quantity` 1476 The sum of the product of the x, y, and z components of ``self`` 1477 and ``other``. 1478 """ 1479 try: 1480 other_c = other.to_cartesian() 1481 except Exception as err: 1482 raise TypeError("cannot only take dot product with another " 1483 "representation, not a {} instance." 1484 .format(type(other))) from err 1485 # erfa pdp: p-vector inner (=scalar=dot) product. 1486 return erfa_ufunc.pdp(self.get_xyz(xyz_axis=-1), 1487 other_c.get_xyz(xyz_axis=-1)) 1488 1489 def cross(self, other): 1490 """Cross product of two representations. 1491 1492 Parameters 1493 ---------- 1494 other : `~astropy.coordinates.BaseRepresentation` subclass instance 1495 If not already cartesian, it is converted. 1496 1497 Returns 1498 ------- 1499 cross_product : `~astropy.coordinates.CartesianRepresentation` 1500 With vectors perpendicular to both ``self`` and ``other``. 1501 """ 1502 self._raise_if_has_differentials('cross') 1503 try: 1504 other_c = other.to_cartesian() 1505 except Exception as err: 1506 raise TypeError("cannot only take cross product with another " 1507 "representation, not a {} instance." 1508 .format(type(other))) from err 1509 # erfa pxp: p-vector outer (=vector=cross) product. 1510 sxo = erfa_ufunc.pxp(self.get_xyz(xyz_axis=-1), 1511 other_c.get_xyz(xyz_axis=-1)) 1512 return self.__class__(sxo, xyz_axis=-1) 1513 1514 1515class UnitSphericalRepresentation(BaseRepresentation): 1516 """ 1517 Representation of points on a unit sphere. 1518 1519 Parameters 1520 ---------- 1521 lon, lat : `~astropy.units.Quantity` ['angle'] or str 1522 The longitude and latitude of the point(s), in angular units. The 1523 latitude should be between -90 and 90 degrees, and the longitude will 1524 be wrapped to an angle between 0 and 360 degrees. These can also be 1525 instances of `~astropy.coordinates.Angle`, 1526 `~astropy.coordinates.Longitude`, or `~astropy.coordinates.Latitude`. 1527 1528 differentials : dict, `~astropy.coordinates.BaseDifferential`, optional 1529 Any differential classes that should be associated with this 1530 representation. The input must either be a single `~astropy.coordinates.BaseDifferential` 1531 instance (see `._compatible_differentials` for valid types), or a 1532 dictionary of of differential instances with keys set to a string 1533 representation of the SI unit with which the differential (derivative) 1534 is taken. For example, for a velocity differential on a positional 1535 representation, the key would be ``'s'`` for seconds, indicating that 1536 the derivative is a time derivative. 1537 1538 copy : bool, optional 1539 If `True` (default), arrays will be copied. If `False`, arrays will 1540 be references, though possibly broadcast to ensure matching shapes. 1541 """ 1542 1543 attr_classes = {'lon': Longitude, 1544 'lat': Latitude} 1545 1546 @classproperty 1547 def _dimensional_representation(cls): 1548 return SphericalRepresentation 1549 1550 def __init__(self, lon, lat=None, differentials=None, copy=True): 1551 super().__init__(lon, lat, differentials=differentials, copy=copy) 1552 1553 @classproperty 1554 def _compatible_differentials(cls): 1555 return [UnitSphericalDifferential, UnitSphericalCosLatDifferential, 1556 SphericalDifferential, SphericalCosLatDifferential, 1557 RadialDifferential] 1558 1559 # Could let the metaclass define these automatically, but good to have 1560 # a bit clearer docstrings. 1561 @property 1562 def lon(self): 1563 """ 1564 The longitude of the point(s). 1565 """ 1566 return self._lon 1567 1568 @property 1569 def lat(self): 1570 """ 1571 The latitude of the point(s). 1572 """ 1573 return self._lat 1574 1575 def unit_vectors(self): 1576 sinlon, coslon = np.sin(self.lon), np.cos(self.lon) 1577 sinlat, coslat = np.sin(self.lat), np.cos(self.lat) 1578 return { 1579 'lon': CartesianRepresentation(-sinlon, coslon, 0., copy=False), 1580 'lat': CartesianRepresentation(-sinlat*coslon, -sinlat*sinlon, 1581 coslat, copy=False)} 1582 1583 def scale_factors(self, omit_coslat=False): 1584 sf_lat = np.broadcast_to(1./u.radian, self.shape, subok=True) 1585 sf_lon = sf_lat if omit_coslat else np.cos(self.lat) / u.radian 1586 return {'lon': sf_lon, 1587 'lat': sf_lat} 1588 1589 def to_cartesian(self): 1590 """ 1591 Converts spherical polar coordinates to 3D rectangular cartesian 1592 coordinates. 1593 """ 1594 # erfa s2c: Convert [unit]spherical coordinates to Cartesian. 1595 p = erfa_ufunc.s2c(self.lon, self.lat) 1596 return CartesianRepresentation(p, xyz_axis=-1, copy=False) 1597 1598 @classmethod 1599 def from_cartesian(cls, cart): 1600 """ 1601 Converts 3D rectangular cartesian coordinates to spherical polar 1602 coordinates. 1603 """ 1604 p = cart.get_xyz(xyz_axis=-1) 1605 # erfa c2s: P-vector to [unit]spherical coordinates. 1606 return cls(*erfa_ufunc.c2s(p), copy=False) 1607 1608 def represent_as(self, other_class, differential_class=None): 1609 # Take a short cut if the other class is a spherical representation 1610 # TODO! for differential_class. This cannot (currently) be implemented 1611 # like in the other Representations since `_re_represent_differentials` 1612 # keeps differentials' unit keys, but this can result in a mismatch 1613 # between the UnitSpherical expected key (e.g. "s") and that expected 1614 # in the other class (here "s / m"). For more info, see PR #11467 1615 if inspect.isclass(other_class) and not differential_class: 1616 if issubclass(other_class, PhysicsSphericalRepresentation): 1617 return other_class(phi=self.lon, theta=90 * u.deg - self.lat, 1618 r=1.0, copy=False) 1619 elif issubclass(other_class, SphericalRepresentation): 1620 return other_class(lon=self.lon, lat=self.lat, distance=1.0, 1621 copy=False) 1622 1623 return super().represent_as(other_class, differential_class) 1624 1625 def transform(self, matrix): 1626 r"""Transform the unit-spherical coordinates using a 3x3 matrix. 1627 1628 This returns a new representation and does not modify the original one. 1629 Any differentials attached to this representation will also be 1630 transformed. 1631 1632 Parameters 1633 ---------- 1634 matrix : (3,3) array-like 1635 A 3x3 matrix, such as a rotation matrix (or a stack of matrices). 1636 1637 Returns 1638 ------- 1639 `UnitSphericalRepresentation` or `SphericalRepresentation` 1640 If ``matrix`` is O(3) -- :math:`M \dot M^T = I` -- like a rotation, 1641 then the result is a `UnitSphericalRepresentation`. 1642 All other matrices will change the distance, so the dimensional 1643 representation is used instead. 1644 1645 """ 1646 # the transformation matrix does not need to be a rotation matrix, 1647 # so the unit-distance is not guaranteed. For speed, we check if the 1648 # matrix is in O(3) and preserves lengths. 1649 if np.all(is_O3(matrix)): # remain in unit-rep 1650 xyz = erfa_ufunc.s2c(self.lon, self.lat) 1651 p = erfa_ufunc.rxp(matrix, xyz) 1652 lon, lat = erfa_ufunc.c2s(p) 1653 rep = self.__class__(lon=lon, lat=lat) 1654 # handle differentials 1655 new_diffs = dict((k, d.transform(matrix, self, rep)) 1656 for k, d in self.differentials.items()) 1657 rep = rep.with_differentials(new_diffs) 1658 1659 else: # switch to dimensional representation 1660 rep = self._dimensional_representation( 1661 lon=self.lon, lat=self.lat, distance=1, 1662 differentials=self.differentials 1663 ).transform(matrix) 1664 1665 return rep 1666 1667 def _scale_operation(self, op, *args): 1668 return self._dimensional_representation( 1669 lon=self.lon, lat=self.lat, distance=1., 1670 differentials=self.differentials)._scale_operation(op, *args) 1671 1672 def __neg__(self): 1673 if any(differential.base_representation is not self.__class__ 1674 for differential in self.differentials.values()): 1675 return super().__neg__() 1676 1677 result = self.__class__(self.lon + 180. * u.deg, -self.lat, copy=False) 1678 for key, differential in self.differentials.items(): 1679 new_comps = (op(getattr(differential, comp)) 1680 for op, comp in zip((operator.pos, operator.neg), 1681 differential.components)) 1682 result.differentials[key] = differential.__class__(*new_comps, copy=False) 1683 return result 1684 1685 def norm(self): 1686 """Vector norm. 1687 1688 The norm is the standard Frobenius norm, i.e., the square root of the 1689 sum of the squares of all components with non-angular units, which is 1690 always unity for vectors on the unit sphere. 1691 1692 Returns 1693 ------- 1694 norm : `~astropy.units.Quantity` ['dimensionless'] 1695 Dimensionless ones, with the same shape as the representation. 1696 """ 1697 return u.Quantity(np.ones(self.shape), u.dimensionless_unscaled, 1698 copy=False) 1699 1700 def _combine_operation(self, op, other, reverse=False): 1701 self._raise_if_has_differentials(op.__name__) 1702 1703 result = self.to_cartesian()._combine_operation(op, other, reverse) 1704 if result is NotImplemented: 1705 return NotImplemented 1706 else: 1707 return self._dimensional_representation.from_cartesian(result) 1708 1709 def mean(self, *args, **kwargs): 1710 """Vector mean. 1711 1712 The representation is converted to cartesian, the means of the x, y, 1713 and z components are calculated, and the result is converted to a 1714 `~astropy.coordinates.SphericalRepresentation`. 1715 1716 Refer to `~numpy.mean` for full documentation of the arguments, noting 1717 that ``axis`` is the entry in the ``shape`` of the representation, and 1718 that the ``out`` argument cannot be used. 1719 """ 1720 self._raise_if_has_differentials('mean') 1721 return self._dimensional_representation.from_cartesian( 1722 self.to_cartesian().mean(*args, **kwargs)) 1723 1724 def sum(self, *args, **kwargs): 1725 """Vector sum. 1726 1727 The representation is converted to cartesian, the sums of the x, y, 1728 and z components are calculated, and the result is converted to a 1729 `~astropy.coordinates.SphericalRepresentation`. 1730 1731 Refer to `~numpy.sum` for full documentation of the arguments, noting 1732 that ``axis`` is the entry in the ``shape`` of the representation, and 1733 that the ``out`` argument cannot be used. 1734 """ 1735 self._raise_if_has_differentials('sum') 1736 return self._dimensional_representation.from_cartesian( 1737 self.to_cartesian().sum(*args, **kwargs)) 1738 1739 def cross(self, other): 1740 """Cross product of two representations. 1741 1742 The calculation is done by converting both ``self`` and ``other`` 1743 to `~astropy.coordinates.CartesianRepresentation`, and converting the 1744 result back to `~astropy.coordinates.SphericalRepresentation`. 1745 1746 Parameters 1747 ---------- 1748 other : `~astropy.coordinates.BaseRepresentation` subclass instance 1749 The representation to take the cross product with. 1750 1751 Returns 1752 ------- 1753 cross_product : `~astropy.coordinates.SphericalRepresentation` 1754 With vectors perpendicular to both ``self`` and ``other``. 1755 """ 1756 self._raise_if_has_differentials('cross') 1757 return self._dimensional_representation.from_cartesian( 1758 self.to_cartesian().cross(other)) 1759 1760 1761class RadialRepresentation(BaseRepresentation): 1762 """ 1763 Representation of the distance of points from the origin. 1764 1765 Note that this is mostly intended as an internal helper representation. 1766 It can do little else but being used as a scale in multiplication. 1767 1768 Parameters 1769 ---------- 1770 distance : `~astropy.units.Quantity` ['length'] 1771 The distance of the point(s) from the origin. 1772 1773 differentials : dict, `~astropy.coordinates.BaseDifferential`, optional 1774 Any differential classes that should be associated with this 1775 representation. The input must either be a single `~astropy.coordinates.BaseDifferential` 1776 instance (see `._compatible_differentials` for valid types), or a 1777 dictionary of of differential instances with keys set to a string 1778 representation of the SI unit with which the differential (derivative) 1779 is taken. For example, for a velocity differential on a positional 1780 representation, the key would be ``'s'`` for seconds, indicating that 1781 the derivative is a time derivative. 1782 1783 copy : bool, optional 1784 If `True` (default), arrays will be copied. If `False`, arrays will 1785 be references, though possibly broadcast to ensure matching shapes. 1786 """ 1787 1788 attr_classes = {'distance': u.Quantity} 1789 1790 def __init__(self, distance, differentials=None, copy=True): 1791 super().__init__(distance, differentials=differentials, copy=copy) 1792 1793 @property 1794 def distance(self): 1795 """ 1796 The distance from the origin to the point(s). 1797 """ 1798 return self._distance 1799 1800 def unit_vectors(self): 1801 """Cartesian unit vectors are undefined for radial representation.""" 1802 raise NotImplementedError('Cartesian unit vectors are undefined for ' 1803 '{} instances'.format(self.__class__)) 1804 1805 def scale_factors(self): 1806 l = np.broadcast_to(1.*u.one, self.shape, subok=True) 1807 return {'distance': l} 1808 1809 def to_cartesian(self): 1810 """Cannot convert radial representation to cartesian.""" 1811 raise NotImplementedError('cannot convert {} instance to cartesian.' 1812 .format(self.__class__)) 1813 1814 @classmethod 1815 def from_cartesian(cls, cart): 1816 """ 1817 Converts 3D rectangular cartesian coordinates to radial coordinate. 1818 """ 1819 return cls(distance=cart.norm(), copy=False) 1820 1821 def __mul__(self, other): 1822 if isinstance(other, BaseRepresentation): 1823 return self.distance * other 1824 else: 1825 return super().__mul__(other) 1826 1827 def norm(self): 1828 """Vector norm. 1829 1830 Just the distance itself. 1831 1832 Returns 1833 ------- 1834 norm : `~astropy.units.Quantity` ['dimensionless'] 1835 Dimensionless ones, with the same shape as the representation. 1836 """ 1837 return self.distance 1838 1839 def _combine_operation(self, op, other, reverse=False): 1840 return NotImplemented 1841 1842 def transform(self, matrix): 1843 """Radial representations cannot be transformed by a Cartesian matrix. 1844 1845 Parameters 1846 ---------- 1847 matrix : array-like 1848 The transformation matrix in a Cartesian basis. 1849 Must be a multiplication: a diagonal matrix with identical elements. 1850 Must have shape (..., 3, 3), where the last 2 indices are for the 1851 matrix on each other axis. Make sure that the matrix shape is 1852 compatible with the shape of this representation. 1853 1854 Raises 1855 ------ 1856 ValueError 1857 If the matrix is not a multiplication. 1858 1859 """ 1860 scl = matrix[..., 0, 0] 1861 # check that the matrix is a scaled identity matrix on the last 2 axes. 1862 if np.any(matrix != scl[..., np.newaxis, np.newaxis] * np.identity(3)): 1863 raise ValueError("Radial representations can only be " 1864 "transformed by a scaled identity matrix") 1865 1866 return self * scl 1867 1868 1869def _spherical_op_funcs(op, *args): 1870 """For given operator, return functions that adjust lon, lat, distance.""" 1871 if op is operator.neg: 1872 return lambda x: x+180*u.deg, operator.neg, operator.pos 1873 1874 try: 1875 scale_sign = np.sign(args[0]) 1876 except Exception: 1877 # This should always work, even if perhaps we get a negative distance. 1878 return operator.pos, operator.pos, lambda x: op(x, *args) 1879 1880 scale = abs(args[0]) 1881 return (lambda x: x + 180*u.deg*np.signbit(scale_sign), 1882 lambda x: x * scale_sign, 1883 lambda x: op(x, scale)) 1884 1885 1886class SphericalRepresentation(BaseRepresentation): 1887 """ 1888 Representation of points in 3D spherical coordinates. 1889 1890 Parameters 1891 ---------- 1892 lon, lat : `~astropy.units.Quantity` ['angle'] 1893 The longitude and latitude of the point(s), in angular units. The 1894 latitude should be between -90 and 90 degrees, and the longitude will 1895 be wrapped to an angle between 0 and 360 degrees. These can also be 1896 instances of `~astropy.coordinates.Angle`, 1897 `~astropy.coordinates.Longitude`, or `~astropy.coordinates.Latitude`. 1898 1899 distance : `~astropy.units.Quantity` ['length'] 1900 The distance to the point(s). If the distance is a length, it is 1901 passed to the :class:`~astropy.coordinates.Distance` class, otherwise 1902 it is passed to the :class:`~astropy.units.Quantity` class. 1903 1904 differentials : dict, `~astropy.coordinates.BaseDifferential`, optional 1905 Any differential classes that should be associated with this 1906 representation. The input must either be a single `~astropy.coordinates.BaseDifferential` 1907 instance (see `._compatible_differentials` for valid types), or a 1908 dictionary of of differential instances with keys set to a string 1909 representation of the SI unit with which the differential (derivative) 1910 is taken. For example, for a velocity differential on a positional 1911 representation, the key would be ``'s'`` for seconds, indicating that 1912 the derivative is a time derivative. 1913 1914 copy : bool, optional 1915 If `True` (default), arrays will be copied. If `False`, arrays will 1916 be references, though possibly broadcast to ensure matching shapes. 1917 """ 1918 1919 attr_classes = {'lon': Longitude, 1920 'lat': Latitude, 1921 'distance': u.Quantity} 1922 _unit_representation = UnitSphericalRepresentation 1923 1924 def __init__(self, lon, lat=None, distance=None, differentials=None, 1925 copy=True): 1926 super().__init__(lon, lat, distance, copy=copy, 1927 differentials=differentials) 1928 if (not isinstance(self._distance, Distance) 1929 and self._distance.unit.physical_type == 'length'): 1930 try: 1931 self._distance = Distance(self._distance, copy=False) 1932 except ValueError as e: 1933 if e.args[0].startswith('Distance must be >= 0'): 1934 raise ValueError("Distance must be >= 0. To allow negative " 1935 "distance values, you must explicitly pass" 1936 " in a `Distance` object with the the " 1937 "argument 'allow_negative=True'.") from e 1938 else: 1939 raise 1940 1941 @classproperty 1942 def _compatible_differentials(cls): 1943 return [UnitSphericalDifferential, UnitSphericalCosLatDifferential, 1944 SphericalDifferential, SphericalCosLatDifferential, 1945 RadialDifferential] 1946 1947 @property 1948 def lon(self): 1949 """ 1950 The longitude of the point(s). 1951 """ 1952 return self._lon 1953 1954 @property 1955 def lat(self): 1956 """ 1957 The latitude of the point(s). 1958 """ 1959 return self._lat 1960 1961 @property 1962 def distance(self): 1963 """ 1964 The distance from the origin to the point(s). 1965 """ 1966 return self._distance 1967 1968 def unit_vectors(self): 1969 sinlon, coslon = np.sin(self.lon), np.cos(self.lon) 1970 sinlat, coslat = np.sin(self.lat), np.cos(self.lat) 1971 return { 1972 'lon': CartesianRepresentation(-sinlon, coslon, 0., copy=False), 1973 'lat': CartesianRepresentation(-sinlat*coslon, -sinlat*sinlon, 1974 coslat, copy=False), 1975 'distance': CartesianRepresentation(coslat*coslon, coslat*sinlon, 1976 sinlat, copy=False)} 1977 1978 def scale_factors(self, omit_coslat=False): 1979 sf_lat = self.distance / u.radian 1980 sf_lon = sf_lat if omit_coslat else sf_lat * np.cos(self.lat) 1981 sf_distance = np.broadcast_to(1.*u.one, self.shape, subok=True) 1982 return {'lon': sf_lon, 1983 'lat': sf_lat, 1984 'distance': sf_distance} 1985 1986 def represent_as(self, other_class, differential_class=None): 1987 # Take a short cut if the other class is a spherical representation 1988 1989 if inspect.isclass(other_class): 1990 if issubclass(other_class, PhysicsSphericalRepresentation): 1991 diffs = self._re_represent_differentials(other_class, 1992 differential_class) 1993 return other_class(phi=self.lon, theta=90 * u.deg - self.lat, 1994 r=self.distance, differentials=diffs, 1995 copy=False) 1996 1997 elif issubclass(other_class, UnitSphericalRepresentation): 1998 diffs = self._re_represent_differentials(other_class, 1999 differential_class) 2000 return other_class(lon=self.lon, lat=self.lat, 2001 differentials=diffs, copy=False) 2002 2003 return super().represent_as(other_class, differential_class) 2004 2005 def to_cartesian(self): 2006 """ 2007 Converts spherical polar coordinates to 3D rectangular cartesian 2008 coordinates. 2009 """ 2010 2011 # We need to convert Distance to Quantity to allow negative values. 2012 if isinstance(self.distance, Distance): 2013 d = self.distance.view(u.Quantity) 2014 else: 2015 d = self.distance 2016 2017 # erfa s2p: Convert spherical polar coordinates to p-vector. 2018 p = erfa_ufunc.s2p(self.lon, self.lat, d) 2019 2020 return CartesianRepresentation(p, xyz_axis=-1, copy=False) 2021 2022 @classmethod 2023 def from_cartesian(cls, cart): 2024 """ 2025 Converts 3D rectangular cartesian coordinates to spherical polar 2026 coordinates. 2027 """ 2028 p = cart.get_xyz(xyz_axis=-1) 2029 # erfa p2s: P-vector to spherical polar coordinates. 2030 return cls(*erfa_ufunc.p2s(p), copy=False) 2031 2032 def transform(self, matrix): 2033 """Transform the spherical coordinates using a 3x3 matrix. 2034 2035 This returns a new representation and does not modify the original one. 2036 Any differentials attached to this representation will also be 2037 transformed. 2038 2039 Parameters 2040 ---------- 2041 matrix : (3,3) array-like 2042 A 3x3 matrix, such as a rotation matrix (or a stack of matrices). 2043 2044 """ 2045 xyz = erfa_ufunc.s2c(self.lon, self.lat) 2046 p = erfa_ufunc.rxp(matrix, xyz) 2047 lon, lat, ur = erfa_ufunc.p2s(p) 2048 rep = self.__class__(lon=lon, lat=lat, distance=self.distance * ur) 2049 2050 # handle differentials 2051 new_diffs = dict((k, d.transform(matrix, self, rep)) 2052 for k, d in self.differentials.items()) 2053 return rep.with_differentials(new_diffs) 2054 2055 def norm(self): 2056 """Vector norm. 2057 2058 The norm is the standard Frobenius norm, i.e., the square root of the 2059 sum of the squares of all components with non-angular units. For 2060 spherical coordinates, this is just the absolute value of the distance. 2061 2062 Returns 2063 ------- 2064 norm : `astropy.units.Quantity` 2065 Vector norm, with the same shape as the representation. 2066 """ 2067 return np.abs(self.distance) 2068 2069 def _scale_operation(self, op, *args): 2070 # TODO: expand special-casing to UnitSpherical and RadialDifferential. 2071 if any(differential.base_representation is not self.__class__ 2072 for differential in self.differentials.values()): 2073 return super()._scale_operation(op, *args) 2074 2075 lon_op, lat_op, distance_op = _spherical_op_funcs(op, *args) 2076 2077 result = self.__class__(lon_op(self.lon), lat_op(self.lat), 2078 distance_op(self.distance), copy=False) 2079 for key, differential in self.differentials.items(): 2080 new_comps = (op(getattr(differential, comp)) for op, comp in zip( 2081 (operator.pos, lat_op, distance_op), 2082 differential.components)) 2083 result.differentials[key] = differential.__class__(*new_comps, copy=False) 2084 return result 2085 2086 2087class PhysicsSphericalRepresentation(BaseRepresentation): 2088 """ 2089 Representation of points in 3D spherical coordinates (using the physics 2090 convention of using ``phi`` and ``theta`` for azimuth and inclination 2091 from the pole). 2092 2093 Parameters 2094 ---------- 2095 phi, theta : `~astropy.units.Quantity` or str 2096 The azimuth and inclination of the point(s), in angular units. The 2097 inclination should be between 0 and 180 degrees, and the azimuth will 2098 be wrapped to an angle between 0 and 360 degrees. These can also be 2099 instances of `~astropy.coordinates.Angle`. If ``copy`` is False, `phi` 2100 will be changed inplace if it is not between 0 and 360 degrees. 2101 2102 r : `~astropy.units.Quantity` 2103 The distance to the point(s). If the distance is a length, it is 2104 passed to the :class:`~astropy.coordinates.Distance` class, otherwise 2105 it is passed to the :class:`~astropy.units.Quantity` class. 2106 2107 differentials : dict, `PhysicsSphericalDifferential`, optional 2108 Any differential classes that should be associated with this 2109 representation. The input must either be a single 2110 `PhysicsSphericalDifferential` instance, or a dictionary of of 2111 differential instances with keys set to a string representation of the 2112 SI unit with which the differential (derivative) is taken. For example, 2113 for a velocity differential on a positional representation, the key 2114 would be ``'s'`` for seconds, indicating that the derivative is a time 2115 derivative. 2116 2117 copy : bool, optional 2118 If `True` (default), arrays will be copied. If `False`, arrays will 2119 be references, though possibly broadcast to ensure matching shapes. 2120 """ 2121 2122 attr_classes = {'phi': Angle, 2123 'theta': Angle, 2124 'r': u.Quantity} 2125 2126 def __init__(self, phi, theta=None, r=None, differentials=None, copy=True): 2127 super().__init__(phi, theta, r, copy=copy, differentials=differentials) 2128 2129 # Wrap/validate phi/theta 2130 # Note that _phi already holds our own copy if copy=True. 2131 self._phi.wrap_at(360 * u.deg, inplace=True) 2132 2133 # This invalid catch block can be removed when the minimum numpy 2134 # version is >= 1.19 (NUMPY_LT_1_19) 2135 with np.errstate(invalid='ignore'): 2136 if np.any(self._theta < 0.*u.deg) or np.any(self._theta > 180.*u.deg): 2137 raise ValueError('Inclination angle(s) must be within ' 2138 '0 deg <= angle <= 180 deg, ' 2139 'got {}'.format(theta.to(u.degree))) 2140 2141 if self._r.unit.physical_type == 'length': 2142 self._r = self._r.view(Distance) 2143 2144 @property 2145 def phi(self): 2146 """ 2147 The azimuth of the point(s). 2148 """ 2149 return self._phi 2150 2151 @property 2152 def theta(self): 2153 """ 2154 The elevation of the point(s). 2155 """ 2156 return self._theta 2157 2158 @property 2159 def r(self): 2160 """ 2161 The distance from the origin to the point(s). 2162 """ 2163 return self._r 2164 2165 def unit_vectors(self): 2166 sinphi, cosphi = np.sin(self.phi), np.cos(self.phi) 2167 sintheta, costheta = np.sin(self.theta), np.cos(self.theta) 2168 return { 2169 'phi': CartesianRepresentation(-sinphi, cosphi, 0., copy=False), 2170 'theta': CartesianRepresentation(costheta*cosphi, 2171 costheta*sinphi, 2172 -sintheta, copy=False), 2173 'r': CartesianRepresentation(sintheta*cosphi, sintheta*sinphi, 2174 costheta, copy=False)} 2175 2176 def scale_factors(self): 2177 r = self.r / u.radian 2178 sintheta = np.sin(self.theta) 2179 l = np.broadcast_to(1.*u.one, self.shape, subok=True) 2180 return {'phi': r * sintheta, 2181 'theta': r, 2182 'r': l} 2183 2184 def represent_as(self, other_class, differential_class=None): 2185 # Take a short cut if the other class is a spherical representation 2186 2187 if inspect.isclass(other_class): 2188 if issubclass(other_class, SphericalRepresentation): 2189 diffs = self._re_represent_differentials(other_class, 2190 differential_class) 2191 return other_class(lon=self.phi, lat=90 * u.deg - self.theta, 2192 distance=self.r, differentials=diffs, 2193 copy=False) 2194 elif issubclass(other_class, UnitSphericalRepresentation): 2195 diffs = self._re_represent_differentials(other_class, 2196 differential_class) 2197 return other_class(lon=self.phi, lat=90 * u.deg - self.theta, 2198 differentials=diffs, copy=False) 2199 2200 return super().represent_as(other_class, differential_class) 2201 2202 def to_cartesian(self): 2203 """ 2204 Converts spherical polar coordinates to 3D rectangular cartesian 2205 coordinates. 2206 """ 2207 2208 # We need to convert Distance to Quantity to allow negative values. 2209 if isinstance(self.r, Distance): 2210 d = self.r.view(u.Quantity) 2211 else: 2212 d = self.r 2213 2214 x = d * np.sin(self.theta) * np.cos(self.phi) 2215 y = d * np.sin(self.theta) * np.sin(self.phi) 2216 z = d * np.cos(self.theta) 2217 2218 return CartesianRepresentation(x=x, y=y, z=z, copy=False) 2219 2220 @classmethod 2221 def from_cartesian(cls, cart): 2222 """ 2223 Converts 3D rectangular cartesian coordinates to spherical polar 2224 coordinates. 2225 """ 2226 2227 s = np.hypot(cart.x, cart.y) 2228 r = np.hypot(s, cart.z) 2229 2230 phi = np.arctan2(cart.y, cart.x) 2231 theta = np.arctan2(s, cart.z) 2232 2233 return cls(phi=phi, theta=theta, r=r, copy=False) 2234 2235 def transform(self, matrix): 2236 """Transform the spherical coordinates using a 3x3 matrix. 2237 2238 This returns a new representation and does not modify the original one. 2239 Any differentials attached to this representation will also be 2240 transformed. 2241 2242 Parameters 2243 ---------- 2244 matrix : (3,3) array-like 2245 A 3x3 matrix, such as a rotation matrix (or a stack of matrices). 2246 2247 """ 2248 # apply transformation in unit-spherical coordinates 2249 xyz = erfa_ufunc.s2c(self.phi, 90*u.deg-self.theta) 2250 p = erfa_ufunc.rxp(matrix, xyz) 2251 lon, lat, ur = erfa_ufunc.p2s(p) # `ur` is transformed unit-`r` 2252 # create transformed physics-spherical representation, 2253 # reapplying the distance scaling 2254 rep = self.__class__(phi=lon, theta=90*u.deg-lat, r=self.r * ur) 2255 2256 new_diffs = dict((k, d.transform(matrix, self, rep)) 2257 for k, d in self.differentials.items()) 2258 return rep.with_differentials(new_diffs) 2259 2260 def norm(self): 2261 """Vector norm. 2262 2263 The norm is the standard Frobenius norm, i.e., the square root of the 2264 sum of the squares of all components with non-angular units. For 2265 spherical coordinates, this is just the absolute value of the radius. 2266 2267 Returns 2268 ------- 2269 norm : `astropy.units.Quantity` 2270 Vector norm, with the same shape as the representation. 2271 """ 2272 return np.abs(self.r) 2273 2274 def _scale_operation(self, op, *args): 2275 if any(differential.base_representation is not self.__class__ 2276 for differential in self.differentials.values()): 2277 return super()._scale_operation(op, *args) 2278 2279 phi_op, adjust_theta_sign, r_op = _spherical_op_funcs(op, *args) 2280 # Also run phi_op on theta to ensure theta remains between 0 and 180: 2281 # any time the scale is negative, we do -theta + 180 degrees. 2282 result = self.__class__(phi_op(self.phi), 2283 phi_op(adjust_theta_sign(self.theta)), 2284 r_op(self.r), copy=False) 2285 for key, differential in self.differentials.items(): 2286 new_comps = (op(getattr(differential, comp)) for op, comp in zip( 2287 (operator.pos, adjust_theta_sign, r_op), 2288 differential.components)) 2289 result.differentials[key] = differential.__class__(*new_comps, copy=False) 2290 return result 2291 2292 2293class CylindricalRepresentation(BaseRepresentation): 2294 """ 2295 Representation of points in 3D cylindrical coordinates. 2296 2297 Parameters 2298 ---------- 2299 rho : `~astropy.units.Quantity` 2300 The distance from the z axis to the point(s). 2301 2302 phi : `~astropy.units.Quantity` or str 2303 The azimuth of the point(s), in angular units, which will be wrapped 2304 to an angle between 0 and 360 degrees. This can also be instances of 2305 `~astropy.coordinates.Angle`, 2306 2307 z : `~astropy.units.Quantity` 2308 The z coordinate(s) of the point(s) 2309 2310 differentials : dict, `CylindricalDifferential`, optional 2311 Any differential classes that should be associated with this 2312 representation. The input must either be a single 2313 `CylindricalDifferential` instance, or a dictionary of of differential 2314 instances with keys set to a string representation of the SI unit with 2315 which the differential (derivative) is taken. For example, for a 2316 velocity differential on a positional representation, the key would be 2317 ``'s'`` for seconds, indicating that the derivative is a time 2318 derivative. 2319 2320 copy : bool, optional 2321 If `True` (default), arrays will be copied. If `False`, arrays will 2322 be references, though possibly broadcast to ensure matching shapes. 2323 """ 2324 2325 attr_classes = {'rho': u.Quantity, 2326 'phi': Angle, 2327 'z': u.Quantity} 2328 2329 def __init__(self, rho, phi=None, z=None, differentials=None, copy=True): 2330 super().__init__(rho, phi, z, copy=copy, differentials=differentials) 2331 2332 if not self._rho.unit.is_equivalent(self._z.unit): 2333 raise u.UnitsError("rho and z should have matching physical types") 2334 2335 @property 2336 def rho(self): 2337 """ 2338 The distance of the point(s) from the z-axis. 2339 """ 2340 return self._rho 2341 2342 @property 2343 def phi(self): 2344 """ 2345 The azimuth of the point(s). 2346 """ 2347 return self._phi 2348 2349 @property 2350 def z(self): 2351 """ 2352 The height of the point(s). 2353 """ 2354 return self._z 2355 2356 def unit_vectors(self): 2357 sinphi, cosphi = np.sin(self.phi), np.cos(self.phi) 2358 l = np.broadcast_to(1., self.shape) 2359 return { 2360 'rho': CartesianRepresentation(cosphi, sinphi, 0, copy=False), 2361 'phi': CartesianRepresentation(-sinphi, cosphi, 0, copy=False), 2362 'z': CartesianRepresentation(0, 0, l, unit=u.one, copy=False)} 2363 2364 def scale_factors(self): 2365 rho = self.rho / u.radian 2366 l = np.broadcast_to(1.*u.one, self.shape, subok=True) 2367 return {'rho': l, 2368 'phi': rho, 2369 'z': l} 2370 2371 @classmethod 2372 def from_cartesian(cls, cart): 2373 """ 2374 Converts 3D rectangular cartesian coordinates to cylindrical polar 2375 coordinates. 2376 """ 2377 2378 rho = np.hypot(cart.x, cart.y) 2379 phi = np.arctan2(cart.y, cart.x) 2380 z = cart.z 2381 2382 return cls(rho=rho, phi=phi, z=z, copy=False) 2383 2384 def to_cartesian(self): 2385 """ 2386 Converts cylindrical polar coordinates to 3D rectangular cartesian 2387 coordinates. 2388 """ 2389 x = self.rho * np.cos(self.phi) 2390 y = self.rho * np.sin(self.phi) 2391 z = self.z 2392 2393 return CartesianRepresentation(x=x, y=y, z=z, copy=False) 2394 2395 def _scale_operation(self, op, *args): 2396 if any(differential.base_representation is not self.__class__ 2397 for differential in self.differentials.values()): 2398 return super()._scale_operation(op, *args) 2399 2400 phi_op, _, rho_op = _spherical_op_funcs(op, *args) 2401 z_op = lambda x: op(x, *args) 2402 2403 result = self.__class__(rho_op(self.rho), phi_op(self.phi), 2404 z_op(self.z), copy=False) 2405 for key, differential in self.differentials.items(): 2406 new_comps = (op(getattr(differential, comp)) for op, comp in zip( 2407 (rho_op, operator.pos, z_op), differential.components)) 2408 result.differentials[key] = differential.__class__(*new_comps, copy=False) 2409 return result 2410 2411 2412class BaseDifferential(BaseRepresentationOrDifferential): 2413 r"""A base class representing differentials of representations. 2414 2415 These represent differences or derivatives along each component. 2416 E.g., for physics spherical coordinates, these would be 2417 :math:`\delta r, \delta \theta, \delta \phi`. 2418 2419 Parameters 2420 ---------- 2421 d_comp1, d_comp2, d_comp3 : `~astropy.units.Quantity` or subclass 2422 The components of the 3D differentials. The names are the keys and the 2423 subclasses the values of the ``attr_classes`` attribute. 2424 copy : bool, optional 2425 If `True` (default), arrays will be copied. If `False`, arrays will 2426 be references, though possibly broadcast to ensure matching shapes. 2427 2428 Notes 2429 ----- 2430 All differential representation classes should subclass this base class, 2431 and define an ``base_representation`` attribute with the class of the 2432 regular `~astropy.coordinates.BaseRepresentation` for which differential 2433 coordinates are provided. This will set up a default ``attr_classes`` 2434 instance with names equal to the base component names prefixed by ``d_``, 2435 and all classes set to `~astropy.units.Quantity`, plus properties to access 2436 those, and a default ``__init__`` for initialization. 2437 """ 2438 2439 def __init_subclass__(cls, **kwargs): 2440 """Set default ``attr_classes`` and component getters on a Differential. 2441 class BaseDifferential(BaseRepresentationOrDifferential): 2442 2443 For these, the components are those of the base representation prefixed 2444 by 'd_', and the class is `~astropy.units.Quantity`. 2445 """ 2446 2447 # Don't do anything for base helper classes. 2448 if cls.__name__ in ('BaseDifferential', 'BaseSphericalDifferential', 2449 'BaseSphericalCosLatDifferential'): 2450 return 2451 2452 if not hasattr(cls, 'base_representation'): 2453 raise NotImplementedError('Differential representations must have a' 2454 '"base_representation" class attribute.') 2455 2456 # If not defined explicitly, create attr_classes. 2457 if not hasattr(cls, 'attr_classes'): 2458 base_attr_classes = cls.base_representation.attr_classes 2459 cls.attr_classes = {'d_' + c: u.Quantity 2460 for c in base_attr_classes} 2461 2462 repr_name = cls.get_name() 2463 if repr_name in DIFFERENTIAL_CLASSES: 2464 raise ValueError(f"Differential class {repr_name} already defined") 2465 2466 DIFFERENTIAL_CLASSES[repr_name] = cls 2467 _invalidate_reprdiff_cls_hash() 2468 2469 # If not defined explicitly, create properties for the components. 2470 for component in cls.attr_classes: 2471 if not hasattr(cls, component): 2472 setattr(cls, component, 2473 property(_make_getter(component), 2474 doc=f"Component '{component}' of the Differential.")) 2475 2476 super().__init_subclass__(**kwargs) 2477 2478 @classmethod 2479 def _check_base(cls, base): 2480 if cls not in base._compatible_differentials: 2481 raise TypeError(f"Differential class {cls} is not compatible with the " 2482 f"base (representation) class {base.__class__}") 2483 2484 def _get_deriv_key(self, base): 2485 """Given a base (representation instance), determine the unit of the 2486 derivative by removing the representation unit from the component units 2487 of this differential. 2488 """ 2489 2490 # This check is just a last resort so we don't return a strange unit key 2491 # from accidentally passing in the wrong base. 2492 self._check_base(base) 2493 2494 for name in base.components: 2495 comp = getattr(base, name) 2496 d_comp = getattr(self, f'd_{name}', None) 2497 if d_comp is not None: 2498 d_unit = comp.unit / d_comp.unit 2499 2500 # This is quite a bit faster than using to_system() or going 2501 # through Quantity() 2502 d_unit_si = d_unit.decompose(u.si.bases) 2503 d_unit_si._scale = 1 # remove the scale from the unit 2504 2505 return str(d_unit_si) 2506 2507 else: 2508 raise RuntimeError("Invalid representation-differential units! This" 2509 " likely happened because either the " 2510 "representation or the associated differential " 2511 "have non-standard units. Check that the input " 2512 "positional data have positional units, and the " 2513 "input velocity data have velocity units, or " 2514 "are both dimensionless.") 2515 2516 @classmethod 2517 def _get_base_vectors(cls, base): 2518 """Get unit vectors and scale factors from base. 2519 2520 Parameters 2521 ---------- 2522 base : instance of ``self.base_representation`` 2523 The points for which the unit vectors and scale factors should be 2524 retrieved. 2525 2526 Returns 2527 ------- 2528 unit_vectors : dict of `CartesianRepresentation` 2529 In the directions of the coordinates of base. 2530 scale_factors : dict of `~astropy.units.Quantity` 2531 Scale factors for each of the coordinates 2532 2533 Raises 2534 ------ 2535 TypeError : if the base is not of the correct type 2536 """ 2537 cls._check_base(base) 2538 return base.unit_vectors(), base.scale_factors() 2539 2540 def to_cartesian(self, base): 2541 """Convert the differential to 3D rectangular cartesian coordinates. 2542 2543 Parameters 2544 ---------- 2545 base : instance of ``self.base_representation`` 2546 The points for which the differentials are to be converted: each of 2547 the components is multiplied by its unit vectors and scale factors. 2548 2549 Returns 2550 ------- 2551 `CartesianDifferential` 2552 This object, converted. 2553 2554 """ 2555 base_e, base_sf = self._get_base_vectors(base) 2556 return functools.reduce( 2557 operator.add, (getattr(self, d_c) * base_sf[c] * base_e[c] 2558 for d_c, c in zip(self.components, base.components))) 2559 2560 @classmethod 2561 def from_cartesian(cls, other, base): 2562 """Convert the differential from 3D rectangular cartesian coordinates to 2563 the desired class. 2564 2565 Parameters 2566 ---------- 2567 other 2568 The object to convert into this differential. 2569 base : `BaseRepresentation` 2570 The points for which the differentials are to be converted: each of 2571 the components is multiplied by its unit vectors and scale factors. 2572 Will be converted to ``cls.base_representation`` if needed. 2573 2574 Returns 2575 ------- 2576 `BaseDifferential` subclass instance 2577 A new differential object that is this class' type. 2578 """ 2579 base = base.represent_as(cls.base_representation) 2580 base_e, base_sf = cls._get_base_vectors(base) 2581 return cls(*(other.dot(e / base_sf[component]) 2582 for component, e in base_e.items()), copy=False) 2583 2584 def represent_as(self, other_class, base): 2585 """Convert coordinates to another representation. 2586 2587 If the instance is of the requested class, it is returned unmodified. 2588 By default, conversion is done via cartesian coordinates. 2589 2590 Parameters 2591 ---------- 2592 other_class : `~astropy.coordinates.BaseRepresentation` subclass 2593 The type of representation to turn the coordinates into. 2594 base : instance of ``self.base_representation`` 2595 Base relative to which the differentials are defined. If the other 2596 class is a differential representation, the base will be converted 2597 to its ``base_representation``. 2598 """ 2599 if other_class is self.__class__: 2600 return self 2601 2602 # The default is to convert via cartesian coordinates. 2603 self_cartesian = self.to_cartesian(base) 2604 if issubclass(other_class, BaseDifferential): 2605 return other_class.from_cartesian(self_cartesian, base) 2606 else: 2607 return other_class.from_cartesian(self_cartesian) 2608 2609 @classmethod 2610 def from_representation(cls, representation, base): 2611 """Create a new instance of this representation from another one. 2612 2613 Parameters 2614 ---------- 2615 representation : `~astropy.coordinates.BaseRepresentation` instance 2616 The presentation that should be converted to this class. 2617 base : instance of ``cls.base_representation`` 2618 The base relative to which the differentials will be defined. If 2619 the representation is a differential itself, the base will be 2620 converted to its ``base_representation`` to help convert it. 2621 """ 2622 if isinstance(representation, BaseDifferential): 2623 cartesian = representation.to_cartesian( 2624 base.represent_as(representation.base_representation)) 2625 else: 2626 cartesian = representation.to_cartesian() 2627 2628 return cls.from_cartesian(cartesian, base) 2629 2630 def transform(self, matrix, base, transformed_base): 2631 """Transform differential using a 3x3 matrix in a Cartesian basis. 2632 2633 This returns a new differential and does not modify the original one. 2634 2635 Parameters 2636 ---------- 2637 matrix : (3,3) array-like 2638 A 3x3 (or stack thereof) matrix, such as a rotation matrix. 2639 base : instance of ``cls.base_representation`` 2640 Base relative to which the differentials are defined. If the other 2641 class is a differential representation, the base will be converted 2642 to its ``base_representation``. 2643 transformed_base : instance of ``cls.base_representation`` 2644 Base relative to which the transformed differentials are defined. 2645 If the other class is a differential representation, the base will 2646 be converted to its ``base_representation``. 2647 """ 2648 # route transformation through Cartesian 2649 cdiff = self.represent_as(CartesianDifferential, base=base 2650 ).transform(matrix) 2651 # move back to original representation 2652 diff = cdiff.represent_as(self.__class__, transformed_base) 2653 return diff 2654 2655 def _scale_operation(self, op, *args, scaled_base=False): 2656 """Scale all components. 2657 2658 Parameters 2659 ---------- 2660 op : `~operator` callable 2661 Operator to apply (e.g., `~operator.mul`, `~operator.neg`, etc. 2662 *args 2663 Any arguments required for the operator (typically, what is to 2664 be multiplied with, divided by). 2665 scaled_base : bool, optional 2666 Whether the base was scaled the same way. This affects whether 2667 differential components should be scaled. For instance, a differential 2668 in longitude should not be scaled if its spherical base is scaled 2669 in radius. 2670 """ 2671 scaled_attrs = [op(getattr(self, c), *args) for c in self.components] 2672 return self.__class__(*scaled_attrs, copy=False) 2673 2674 def _combine_operation(self, op, other, reverse=False): 2675 """Combine two differentials, or a differential with a representation. 2676 2677 If ``other`` is of the same differential type as ``self``, the 2678 components will simply be combined. If ``other`` is a representation, 2679 it will be used as a base for which to evaluate the differential, 2680 and the result is a new representation. 2681 2682 Parameters 2683 ---------- 2684 op : `~operator` callable 2685 Operator to apply (e.g., `~operator.add`, `~operator.sub`, etc. 2686 other : `~astropy.coordinates.BaseRepresentation` subclass instance 2687 The other differential or representation. 2688 reverse : bool 2689 Whether the operands should be reversed (e.g., as we got here via 2690 ``self.__rsub__`` because ``self`` is a subclass of ``other``). 2691 """ 2692 if isinstance(self, type(other)): 2693 first, second = (self, other) if not reverse else (other, self) 2694 return self.__class__(*[op(getattr(first, c), getattr(second, c)) 2695 for c in self.components]) 2696 else: 2697 try: 2698 self_cartesian = self.to_cartesian(other) 2699 except TypeError: 2700 return NotImplemented 2701 2702 return other._combine_operation(op, self_cartesian, not reverse) 2703 2704 def __sub__(self, other): 2705 # avoid "differential - representation". 2706 if isinstance(other, BaseRepresentation): 2707 return NotImplemented 2708 return super().__sub__(other) 2709 2710 def norm(self, base=None): 2711 """Vector norm. 2712 2713 The norm is the standard Frobenius norm, i.e., the square root of the 2714 sum of the squares of all components with non-angular units. 2715 2716 Parameters 2717 ---------- 2718 base : instance of ``self.base_representation`` 2719 Base relative to which the differentials are defined. This is 2720 required to calculate the physical size of the differential for 2721 all but Cartesian differentials or radial differentials. 2722 2723 Returns 2724 ------- 2725 norm : `astropy.units.Quantity` 2726 Vector norm, with the same shape as the representation. 2727 """ 2728 # RadialDifferential overrides this function, so there is no handling here 2729 if not isinstance(self, CartesianDifferential) and base is None: 2730 raise ValueError("`base` must be provided to calculate the norm of a" 2731 f" {type(self).__name__}") 2732 return self.to_cartesian(base).norm() 2733 2734 2735class CartesianDifferential(BaseDifferential): 2736 """Differentials in of points in 3D cartesian coordinates. 2737 2738 Parameters 2739 ---------- 2740 d_x, d_y, d_z : `~astropy.units.Quantity` or array 2741 The x, y, and z coordinates of the differentials. If ``d_x``, ``d_y``, 2742 and ``d_z`` have different shapes, they should be broadcastable. If not 2743 quantities, ``unit`` should be set. If only ``d_x`` is given, it is 2744 assumed that it contains an array with the 3 coordinates stored along 2745 ``xyz_axis``. 2746 unit : `~astropy.units.Unit` or str 2747 If given, the differentials will be converted to this unit (or taken to 2748 be in this unit if not given. 2749 xyz_axis : int, optional 2750 The axis along which the coordinates are stored when a single array is 2751 provided instead of distinct ``d_x``, ``d_y``, and ``d_z`` (default: 0). 2752 copy : bool, optional 2753 If `True` (default), arrays will be copied. If `False`, arrays will 2754 be references, though possibly broadcast to ensure matching shapes. 2755 """ 2756 base_representation = CartesianRepresentation 2757 _d_xyz = None 2758 2759 def __init__(self, d_x, d_y=None, d_z=None, unit=None, xyz_axis=None, 2760 copy=True): 2761 2762 if d_y is None and d_z is None: 2763 if isinstance(d_x, np.ndarray) and d_x.dtype.kind not in 'OV': 2764 # Short-cut for 3-D array input. 2765 d_x = u.Quantity(d_x, unit, copy=copy, subok=True) 2766 # Keep a link to the array with all three coordinates 2767 # so that we can return it quickly if needed in get_xyz. 2768 self._d_xyz = d_x 2769 if xyz_axis: 2770 d_x = np.moveaxis(d_x, xyz_axis, 0) 2771 self._xyz_axis = xyz_axis 2772 else: 2773 self._xyz_axis = 0 2774 2775 self._d_x, self._d_y, self._d_z = d_x 2776 return 2777 2778 else: 2779 d_x, d_y, d_z = d_x 2780 2781 if xyz_axis is not None: 2782 raise ValueError("xyz_axis should only be set if d_x, d_y, and d_z " 2783 "are in a single array passed in through d_x, " 2784 "i.e., d_y and d_z should not be not given.") 2785 2786 if d_y is None or d_z is None: 2787 raise ValueError("d_x, d_y, and d_z are required to instantiate {}" 2788 .format(self.__class__.__name__)) 2789 2790 if unit is not None: 2791 d_x = u.Quantity(d_x, unit, copy=copy, subok=True) 2792 d_y = u.Quantity(d_y, unit, copy=copy, subok=True) 2793 d_z = u.Quantity(d_z, unit, copy=copy, subok=True) 2794 copy = False 2795 2796 super().__init__(d_x, d_y, d_z, copy=copy) 2797 if not (self._d_x.unit.is_equivalent(self._d_y.unit) and 2798 self._d_x.unit.is_equivalent(self._d_z.unit)): 2799 raise u.UnitsError('d_x, d_y and d_z should have equivalent units.') 2800 2801 def to_cartesian(self, base=None): 2802 return CartesianRepresentation(*[getattr(self, c) for c 2803 in self.components]) 2804 2805 @classmethod 2806 def from_cartesian(cls, other, base=None): 2807 return cls(*[getattr(other, c) for c in other.components]) 2808 2809 def transform(self, matrix, base=None, transformed_base=None): 2810 """Transform differentials using a 3x3 matrix in a Cartesian basis. 2811 2812 This returns a new differential and does not modify the original one. 2813 2814 Parameters 2815 ---------- 2816 matrix : (3,3) array-like 2817 A 3x3 (or stack thereof) matrix, such as a rotation matrix. 2818 base, transformed_base : `~astropy.coordinates.CartesianRepresentation` or None, optional 2819 Not used in the Cartesian transformation. 2820 """ 2821 # erfa rxp: Multiply a p-vector by an r-matrix. 2822 p = erfa_ufunc.rxp(matrix, self.get_d_xyz(xyz_axis=-1)) 2823 2824 return self.__class__(p, xyz_axis=-1, copy=False) 2825 2826 def get_d_xyz(self, xyz_axis=0): 2827 """Return a vector array of the x, y, and z coordinates. 2828 2829 Parameters 2830 ---------- 2831 xyz_axis : int, optional 2832 The axis in the final array along which the x, y, z components 2833 should be stored (default: 0). 2834 2835 Returns 2836 ------- 2837 d_xyz : `~astropy.units.Quantity` 2838 With dimension 3 along ``xyz_axis``. Note that, if possible, 2839 this will be a view. 2840 """ 2841 if self._d_xyz is not None: 2842 if self._xyz_axis == xyz_axis: 2843 return self._d_xyz 2844 else: 2845 return np.moveaxis(self._d_xyz, self._xyz_axis, xyz_axis) 2846 2847 # Create combined array. TO DO: keep it in _d_xyz for repeated use? 2848 # But then in-place changes have to cancel it. Likely best to 2849 # also update components. 2850 return np.stack([self._d_x, self._d_y, self._d_z], axis=xyz_axis) 2851 2852 d_xyz = property(get_d_xyz) 2853 2854 2855class BaseSphericalDifferential(BaseDifferential): 2856 def _d_lon_coslat(self, base): 2857 """Convert longitude differential d_lon to d_lon_coslat. 2858 2859 Parameters 2860 ---------- 2861 base : instance of ``cls.base_representation`` 2862 The base from which the latitude will be taken. 2863 """ 2864 self._check_base(base) 2865 return self.d_lon * np.cos(base.lat) 2866 2867 @classmethod 2868 def _get_d_lon(cls, d_lon_coslat, base): 2869 """Convert longitude differential d_lon_coslat to d_lon. 2870 2871 Parameters 2872 ---------- 2873 d_lon_coslat : `~astropy.units.Quantity` 2874 Longitude differential that includes ``cos(lat)``. 2875 base : instance of ``cls.base_representation`` 2876 The base from which the latitude will be taken. 2877 """ 2878 cls._check_base(base) 2879 return d_lon_coslat / np.cos(base.lat) 2880 2881 def _combine_operation(self, op, other, reverse=False): 2882 """Combine two differentials, or a differential with a representation. 2883 2884 If ``other`` is of the same differential type as ``self``, the 2885 components will simply be combined. If both are different parts of 2886 a `~astropy.coordinates.SphericalDifferential` (e.g., a 2887 `~astropy.coordinates.UnitSphericalDifferential` and a 2888 `~astropy.coordinates.RadialDifferential`), they will combined 2889 appropriately. 2890 2891 If ``other`` is a representation, it will be used as a base for which 2892 to evaluate the differential, and the result is a new representation. 2893 2894 Parameters 2895 ---------- 2896 op : `~operator` callable 2897 Operator to apply (e.g., `~operator.add`, `~operator.sub`, etc. 2898 other : `~astropy.coordinates.BaseRepresentation` subclass instance 2899 The other differential or representation. 2900 reverse : bool 2901 Whether the operands should be reversed (e.g., as we got here via 2902 ``self.__rsub__`` because ``self`` is a subclass of ``other``). 2903 """ 2904 if (isinstance(other, BaseSphericalDifferential) and 2905 not isinstance(self, type(other)) or 2906 isinstance(other, RadialDifferential)): 2907 all_components = set(self.components) | set(other.components) 2908 first, second = (self, other) if not reverse else (other, self) 2909 result_args = {c: op(getattr(first, c, 0.), getattr(second, c, 0.)) 2910 for c in all_components} 2911 return SphericalDifferential(**result_args) 2912 2913 return super()._combine_operation(op, other, reverse) 2914 2915 2916class UnitSphericalDifferential(BaseSphericalDifferential): 2917 """Differential(s) of points on a unit sphere. 2918 2919 Parameters 2920 ---------- 2921 d_lon, d_lat : `~astropy.units.Quantity` 2922 The longitude and latitude of the differentials. 2923 copy : bool, optional 2924 If `True` (default), arrays will be copied. If `False`, arrays will 2925 be references, though possibly broadcast to ensure matching shapes. 2926 """ 2927 base_representation = UnitSphericalRepresentation 2928 2929 @classproperty 2930 def _dimensional_differential(cls): 2931 return SphericalDifferential 2932 2933 def __init__(self, d_lon, d_lat=None, copy=True): 2934 super().__init__(d_lon, d_lat, copy=copy) 2935 if not self._d_lon.unit.is_equivalent(self._d_lat.unit): 2936 raise u.UnitsError('d_lon and d_lat should have equivalent units.') 2937 2938 @classmethod 2939 def from_cartesian(cls, other, base): 2940 # Go via the dimensional equivalent, so that the longitude and latitude 2941 # differentials correctly take into account the norm of the base. 2942 dimensional = cls._dimensional_differential.from_cartesian(other, base) 2943 return dimensional.represent_as(cls) 2944 2945 def to_cartesian(self, base): 2946 if isinstance(base, SphericalRepresentation): 2947 scale = base.distance 2948 elif isinstance(base, PhysicsSphericalRepresentation): 2949 scale = base.r 2950 else: 2951 return super().to_cartesian(base) 2952 2953 base = base.represent_as(UnitSphericalRepresentation) 2954 return scale * super().to_cartesian(base) 2955 2956 def represent_as(self, other_class, base=None): 2957 # Only have enough information to represent other unit-spherical. 2958 if issubclass(other_class, UnitSphericalCosLatDifferential): 2959 return other_class(self._d_lon_coslat(base), self.d_lat) 2960 2961 return super().represent_as(other_class, base) 2962 2963 @classmethod 2964 def from_representation(cls, representation, base=None): 2965 # All spherical differentials can be done without going to Cartesian, 2966 # though CosLat needs base for the latitude. 2967 if isinstance(representation, SphericalDifferential): 2968 return cls(representation.d_lon, representation.d_lat) 2969 elif isinstance(representation, (SphericalCosLatDifferential, 2970 UnitSphericalCosLatDifferential)): 2971 d_lon = cls._get_d_lon(representation.d_lon_coslat, base) 2972 return cls(d_lon, representation.d_lat) 2973 elif isinstance(representation, PhysicsSphericalDifferential): 2974 return cls(representation.d_phi, -representation.d_theta) 2975 2976 return super().from_representation(representation, base) 2977 2978 def transform(self, matrix, base, transformed_base): 2979 """Transform differential using a 3x3 matrix in a Cartesian basis. 2980 2981 This returns a new differential and does not modify the original one. 2982 2983 Parameters 2984 ---------- 2985 matrix : (3,3) array-like 2986 A 3x3 (or stack thereof) matrix, such as a rotation matrix. 2987 base : instance of ``cls.base_representation`` 2988 Base relative to which the differentials are defined. If the other 2989 class is a differential representation, the base will be converted 2990 to its ``base_representation``. 2991 transformed_base : instance of ``cls.base_representation`` 2992 Base relative to which the transformed differentials are defined. 2993 If the other class is a differential representation, the base will 2994 be converted to its ``base_representation``. 2995 """ 2996 # the transformation matrix does not need to be a rotation matrix, 2997 # so the unit-distance is not guaranteed. For speed, we check if the 2998 # matrix is in O(3) and preserves lengths. 2999 if np.all(is_O3(matrix)): # remain in unit-rep 3000 # TODO! implement without Cartesian intermediate step. 3001 # some of this can be moved to the parent class. 3002 diff = super().transform(matrix, base, transformed_base) 3003 3004 else: # switch to dimensional representation 3005 du = self.d_lon.unit / base.lon.unit # derivative unit 3006 diff = self._dimensional_differential( 3007 d_lon=self.d_lon, d_lat=self.d_lat, d_distance=0 * du 3008 ).transform(matrix, base, transformed_base) 3009 3010 return diff 3011 3012 def _scale_operation(self, op, *args, scaled_base=False): 3013 if scaled_base: 3014 return self.copy() 3015 else: 3016 return super()._scale_operation(op, *args) 3017 3018 3019class SphericalDifferential(BaseSphericalDifferential): 3020 """Differential(s) of points in 3D spherical coordinates. 3021 3022 Parameters 3023 ---------- 3024 d_lon, d_lat : `~astropy.units.Quantity` 3025 The differential longitude and latitude. 3026 d_distance : `~astropy.units.Quantity` 3027 The differential distance. 3028 copy : bool, optional 3029 If `True` (default), arrays will be copied. If `False`, arrays will 3030 be references, though possibly broadcast to ensure matching shapes. 3031 """ 3032 base_representation = SphericalRepresentation 3033 _unit_differential = UnitSphericalDifferential 3034 3035 def __init__(self, d_lon, d_lat=None, d_distance=None, copy=True): 3036 super().__init__(d_lon, d_lat, d_distance, copy=copy) 3037 if not self._d_lon.unit.is_equivalent(self._d_lat.unit): 3038 raise u.UnitsError('d_lon and d_lat should have equivalent units.') 3039 3040 def represent_as(self, other_class, base=None): 3041 # All spherical differentials can be done without going to Cartesian, 3042 # though CosLat needs base for the latitude. 3043 if issubclass(other_class, UnitSphericalDifferential): 3044 return other_class(self.d_lon, self.d_lat) 3045 elif issubclass(other_class, RadialDifferential): 3046 return other_class(self.d_distance) 3047 elif issubclass(other_class, SphericalCosLatDifferential): 3048 return other_class(self._d_lon_coslat(base), self.d_lat, 3049 self.d_distance) 3050 elif issubclass(other_class, UnitSphericalCosLatDifferential): 3051 return other_class(self._d_lon_coslat(base), self.d_lat) 3052 elif issubclass(other_class, PhysicsSphericalDifferential): 3053 return other_class(self.d_lon, -self.d_lat, self.d_distance) 3054 else: 3055 return super().represent_as(other_class, base) 3056 3057 @classmethod 3058 def from_representation(cls, representation, base=None): 3059 # Other spherical differentials can be done without going to Cartesian, 3060 # though CosLat needs base for the latitude. 3061 if isinstance(representation, SphericalCosLatDifferential): 3062 d_lon = cls._get_d_lon(representation.d_lon_coslat, base) 3063 return cls(d_lon, representation.d_lat, representation.d_distance) 3064 elif isinstance(representation, PhysicsSphericalDifferential): 3065 return cls(representation.d_phi, -representation.d_theta, 3066 representation.d_r) 3067 3068 return super().from_representation(representation, base) 3069 3070 def _scale_operation(self, op, *args, scaled_base=False): 3071 if scaled_base: 3072 return self.__class__(self.d_lon, self.d_lat, op(self.d_distance, *args)) 3073 else: 3074 return super()._scale_operation(op, *args) 3075 3076 3077class BaseSphericalCosLatDifferential(BaseDifferential): 3078 """Differentials from points on a spherical base representation. 3079 3080 With cos(lat) assumed to be included in the longitude differential. 3081 """ 3082 @classmethod 3083 def _get_base_vectors(cls, base): 3084 """Get unit vectors and scale factors from (unit)spherical base. 3085 3086 Parameters 3087 ---------- 3088 base : instance of ``self.base_representation`` 3089 The points for which the unit vectors and scale factors should be 3090 retrieved. 3091 3092 Returns 3093 ------- 3094 unit_vectors : dict of `CartesianRepresentation` 3095 In the directions of the coordinates of base. 3096 scale_factors : dict of `~astropy.units.Quantity` 3097 Scale factors for each of the coordinates. The scale factor for 3098 longitude does not include the cos(lat) factor. 3099 3100 Raises 3101 ------ 3102 TypeError : if the base is not of the correct type 3103 """ 3104 cls._check_base(base) 3105 return base.unit_vectors(), base.scale_factors(omit_coslat=True) 3106 3107 def _d_lon(self, base): 3108 """Convert longitude differential with cos(lat) to one without. 3109 3110 Parameters 3111 ---------- 3112 base : instance of ``cls.base_representation`` 3113 The base from which the latitude will be taken. 3114 """ 3115 self._check_base(base) 3116 return self.d_lon_coslat / np.cos(base.lat) 3117 3118 @classmethod 3119 def _get_d_lon_coslat(cls, d_lon, base): 3120 """Convert longitude differential d_lon to d_lon_coslat. 3121 3122 Parameters 3123 ---------- 3124 d_lon : `~astropy.units.Quantity` 3125 Value of the longitude differential without ``cos(lat)``. 3126 base : instance of ``cls.base_representation`` 3127 The base from which the latitude will be taken. 3128 """ 3129 cls._check_base(base) 3130 return d_lon * np.cos(base.lat) 3131 3132 def _combine_operation(self, op, other, reverse=False): 3133 """Combine two differentials, or a differential with a representation. 3134 3135 If ``other`` is of the same differential type as ``self``, the 3136 components will simply be combined. If both are different parts of 3137 a `~astropy.coordinates.SphericalDifferential` (e.g., a 3138 `~astropy.coordinates.UnitSphericalDifferential` and a 3139 `~astropy.coordinates.RadialDifferential`), they will combined 3140 appropriately. 3141 3142 If ``other`` is a representation, it will be used as a base for which 3143 to evaluate the differential, and the result is a new representation. 3144 3145 Parameters 3146 ---------- 3147 op : `~operator` callable 3148 Operator to apply (e.g., `~operator.add`, `~operator.sub`, etc. 3149 other : `~astropy.coordinates.BaseRepresentation` subclass instance 3150 The other differential or representation. 3151 reverse : bool 3152 Whether the operands should be reversed (e.g., as we got here via 3153 ``self.__rsub__`` because ``self`` is a subclass of ``other``). 3154 """ 3155 if (isinstance(other, BaseSphericalCosLatDifferential) and 3156 not isinstance(self, type(other)) or 3157 isinstance(other, RadialDifferential)): 3158 all_components = set(self.components) | set(other.components) 3159 first, second = (self, other) if not reverse else (other, self) 3160 result_args = {c: op(getattr(first, c, 0.), getattr(second, c, 0.)) 3161 for c in all_components} 3162 return SphericalCosLatDifferential(**result_args) 3163 3164 return super()._combine_operation(op, other, reverse) 3165 3166 3167class UnitSphericalCosLatDifferential(BaseSphericalCosLatDifferential): 3168 """Differential(s) of points on a unit sphere. 3169 3170 Parameters 3171 ---------- 3172 d_lon_coslat, d_lat : `~astropy.units.Quantity` 3173 The longitude and latitude of the differentials. 3174 copy : bool, optional 3175 If `True` (default), arrays will be copied. If `False`, arrays will 3176 be references, though possibly broadcast to ensure matching shapes. 3177 """ 3178 base_representation = UnitSphericalRepresentation 3179 attr_classes = {'d_lon_coslat': u.Quantity, 3180 'd_lat': u.Quantity} 3181 3182 @classproperty 3183 def _dimensional_differential(cls): 3184 return SphericalCosLatDifferential 3185 3186 def __init__(self, d_lon_coslat, d_lat=None, copy=True): 3187 super().__init__(d_lon_coslat, d_lat, copy=copy) 3188 if not self._d_lon_coslat.unit.is_equivalent(self._d_lat.unit): 3189 raise u.UnitsError('d_lon_coslat and d_lat should have equivalent ' 3190 'units.') 3191 3192 @classmethod 3193 def from_cartesian(cls, other, base): 3194 # Go via the dimensional equivalent, so that the longitude and latitude 3195 # differentials correctly take into account the norm of the base. 3196 dimensional = cls._dimensional_differential.from_cartesian(other, base) 3197 return dimensional.represent_as(cls) 3198 3199 def to_cartesian(self, base): 3200 if isinstance(base, SphericalRepresentation): 3201 scale = base.distance 3202 elif isinstance(base, PhysicsSphericalRepresentation): 3203 scale = base.r 3204 else: 3205 return super().to_cartesian(base) 3206 3207 base = base.represent_as(UnitSphericalRepresentation) 3208 return scale * super().to_cartesian(base) 3209 3210 def represent_as(self, other_class, base=None): 3211 # Only have enough information to represent other unit-spherical. 3212 if issubclass(other_class, UnitSphericalDifferential): 3213 return other_class(self._d_lon(base), self.d_lat) 3214 3215 return super().represent_as(other_class, base) 3216 3217 @classmethod 3218 def from_representation(cls, representation, base=None): 3219 # All spherical differentials can be done without going to Cartesian, 3220 # though w/o CosLat needs base for the latitude. 3221 if isinstance(representation, SphericalCosLatDifferential): 3222 return cls(representation.d_lon_coslat, representation.d_lat) 3223 elif isinstance(representation, (SphericalDifferential, 3224 UnitSphericalDifferential)): 3225 d_lon_coslat = cls._get_d_lon_coslat(representation.d_lon, base) 3226 return cls(d_lon_coslat, representation.d_lat) 3227 elif isinstance(representation, PhysicsSphericalDifferential): 3228 d_lon_coslat = cls._get_d_lon_coslat(representation.d_phi, base) 3229 return cls(d_lon_coslat, -representation.d_theta) 3230 3231 return super().from_representation(representation, base) 3232 3233 def transform(self, matrix, base, transformed_base): 3234 """Transform differential using a 3x3 matrix in a Cartesian basis. 3235 3236 This returns a new differential and does not modify the original one. 3237 3238 Parameters 3239 ---------- 3240 matrix : (3,3) array-like 3241 A 3x3 (or stack thereof) matrix, such as a rotation matrix. 3242 base : instance of ``cls.base_representation`` 3243 Base relative to which the differentials are defined. If the other 3244 class is a differential representation, the base will be converted 3245 to its ``base_representation``. 3246 transformed_base : instance of ``cls.base_representation`` 3247 Base relative to which the transformed differentials are defined. 3248 If the other class is a differential representation, the base will 3249 be converted to its ``base_representation``. 3250 """ 3251 # the transformation matrix does not need to be a rotation matrix, 3252 # so the unit-distance is not guaranteed. For speed, we check if the 3253 # matrix is in O(3) and preserves lengths. 3254 if np.all(is_O3(matrix)): # remain in unit-rep 3255 # TODO! implement without Cartesian intermediate step. 3256 diff = super().transform(matrix, base, transformed_base) 3257 3258 else: # switch to dimensional representation 3259 du = self.d_lat.unit / base.lat.unit # derivative unit 3260 diff = self._dimensional_differential( 3261 d_lon_coslat=self.d_lon_coslat, d_lat=self.d_lat, 3262 d_distance=0 * du 3263 ).transform(matrix, base, transformed_base) 3264 3265 return diff 3266 3267 def _scale_operation(self, op, *args, scaled_base=False): 3268 if scaled_base: 3269 return self.copy() 3270 else: 3271 return super()._scale_operation(op, *args) 3272 3273 3274class SphericalCosLatDifferential(BaseSphericalCosLatDifferential): 3275 """Differential(s) of points in 3D spherical coordinates. 3276 3277 Parameters 3278 ---------- 3279 d_lon_coslat, d_lat : `~astropy.units.Quantity` 3280 The differential longitude (with cos(lat) included) and latitude. 3281 d_distance : `~astropy.units.Quantity` 3282 The differential distance. 3283 copy : bool, optional 3284 If `True` (default), arrays will be copied. If `False`, arrays will 3285 be references, though possibly broadcast to ensure matching shapes. 3286 """ 3287 base_representation = SphericalRepresentation 3288 _unit_differential = UnitSphericalCosLatDifferential 3289 attr_classes = {'d_lon_coslat': u.Quantity, 3290 'd_lat': u.Quantity, 3291 'd_distance': u.Quantity} 3292 3293 def __init__(self, d_lon_coslat, d_lat=None, d_distance=None, copy=True): 3294 super().__init__(d_lon_coslat, d_lat, d_distance, copy=copy) 3295 if not self._d_lon_coslat.unit.is_equivalent(self._d_lat.unit): 3296 raise u.UnitsError('d_lon_coslat and d_lat should have equivalent ' 3297 'units.') 3298 3299 def represent_as(self, other_class, base=None): 3300 # All spherical differentials can be done without going to Cartesian, 3301 # though some need base for the latitude to remove cos(lat). 3302 if issubclass(other_class, UnitSphericalCosLatDifferential): 3303 return other_class(self.d_lon_coslat, self.d_lat) 3304 elif issubclass(other_class, RadialDifferential): 3305 return other_class(self.d_distance) 3306 elif issubclass(other_class, SphericalDifferential): 3307 return other_class(self._d_lon(base), self.d_lat, self.d_distance) 3308 elif issubclass(other_class, UnitSphericalDifferential): 3309 return other_class(self._d_lon(base), self.d_lat) 3310 elif issubclass(other_class, PhysicsSphericalDifferential): 3311 return other_class(self._d_lon(base), -self.d_lat, self.d_distance) 3312 3313 return super().represent_as(other_class, base) 3314 3315 @classmethod 3316 def from_representation(cls, representation, base=None): 3317 # Other spherical differentials can be done without going to Cartesian, 3318 # though we need base for the latitude to remove coslat. 3319 if isinstance(representation, SphericalDifferential): 3320 d_lon_coslat = cls._get_d_lon_coslat(representation.d_lon, base) 3321 return cls(d_lon_coslat, representation.d_lat, 3322 representation.d_distance) 3323 elif isinstance(representation, PhysicsSphericalDifferential): 3324 d_lon_coslat = cls._get_d_lon_coslat(representation.d_phi, base) 3325 return cls(d_lon_coslat, -representation.d_theta, 3326 representation.d_r) 3327 3328 return super().from_representation(representation, base) 3329 3330 def _scale_operation(self, op, *args, scaled_base=False): 3331 if scaled_base: 3332 return self.__class__(self.d_lon_coslat, self.d_lat, op(self.d_distance, *args)) 3333 else: 3334 return super()._scale_operation(op, *args) 3335 3336 3337class RadialDifferential(BaseDifferential): 3338 """Differential(s) of radial distances. 3339 3340 Parameters 3341 ---------- 3342 d_distance : `~astropy.units.Quantity` 3343 The differential distance. 3344 copy : bool, optional 3345 If `True` (default), arrays will be copied. If `False`, arrays will 3346 be references, though possibly broadcast to ensure matching shapes. 3347 """ 3348 base_representation = RadialRepresentation 3349 3350 def to_cartesian(self, base): 3351 return self.d_distance * base.represent_as( 3352 UnitSphericalRepresentation).to_cartesian() 3353 3354 def norm(self, base=None): 3355 return self.d_distance 3356 3357 @classmethod 3358 def from_cartesian(cls, other, base): 3359 return cls(other.dot(base.represent_as(UnitSphericalRepresentation)), 3360 copy=False) 3361 3362 @classmethod 3363 def from_representation(cls, representation, base=None): 3364 if isinstance(representation, (SphericalDifferential, 3365 SphericalCosLatDifferential)): 3366 return cls(representation.d_distance) 3367 elif isinstance(representation, PhysicsSphericalDifferential): 3368 return cls(representation.d_r) 3369 else: 3370 return super().from_representation(representation, base) 3371 3372 def _combine_operation(self, op, other, reverse=False): 3373 if isinstance(other, self.base_representation): 3374 if reverse: 3375 first, second = other.distance, self.d_distance 3376 else: 3377 first, second = self.d_distance, other.distance 3378 return other.__class__(op(first, second), copy=False) 3379 elif isinstance(other, (BaseSphericalDifferential, 3380 BaseSphericalCosLatDifferential)): 3381 all_components = set(self.components) | set(other.components) 3382 first, second = (self, other) if not reverse else (other, self) 3383 result_args = {c: op(getattr(first, c, 0.), getattr(second, c, 0.)) 3384 for c in all_components} 3385 return SphericalDifferential(**result_args) 3386 3387 else: 3388 return super()._combine_operation(op, other, reverse) 3389 3390 3391class PhysicsSphericalDifferential(BaseDifferential): 3392 """Differential(s) of 3D spherical coordinates using physics convention. 3393 3394 Parameters 3395 ---------- 3396 d_phi, d_theta : `~astropy.units.Quantity` 3397 The differential azimuth and inclination. 3398 d_r : `~astropy.units.Quantity` 3399 The differential radial distance. 3400 copy : bool, optional 3401 If `True` (default), arrays will be copied. If `False`, arrays will 3402 be references, though possibly broadcast to ensure matching shapes. 3403 """ 3404 base_representation = PhysicsSphericalRepresentation 3405 3406 def __init__(self, d_phi, d_theta=None, d_r=None, copy=True): 3407 super().__init__(d_phi, d_theta, d_r, copy=copy) 3408 if not self._d_phi.unit.is_equivalent(self._d_theta.unit): 3409 raise u.UnitsError('d_phi and d_theta should have equivalent ' 3410 'units.') 3411 3412 def represent_as(self, other_class, base=None): 3413 # All spherical differentials can be done without going to Cartesian, 3414 # though CosLat needs base for the latitude. For those, explicitly 3415 # do the equivalent of self._d_lon_coslat in SphericalDifferential. 3416 if issubclass(other_class, SphericalDifferential): 3417 return other_class(self.d_phi, -self.d_theta, self.d_r) 3418 elif issubclass(other_class, UnitSphericalDifferential): 3419 return other_class(self.d_phi, -self.d_theta) 3420 elif issubclass(other_class, SphericalCosLatDifferential): 3421 self._check_base(base) 3422 d_lon_coslat = self.d_phi * np.sin(base.theta) 3423 return other_class(d_lon_coslat, -self.d_theta, self.d_r) 3424 elif issubclass(other_class, UnitSphericalCosLatDifferential): 3425 self._check_base(base) 3426 d_lon_coslat = self.d_phi * np.sin(base.theta) 3427 return other_class(d_lon_coslat, -self.d_theta) 3428 elif issubclass(other_class, RadialDifferential): 3429 return other_class(self.d_r) 3430 3431 return super().represent_as(other_class, base) 3432 3433 @classmethod 3434 def from_representation(cls, representation, base=None): 3435 # Other spherical differentials can be done without going to Cartesian, 3436 # though we need base for the latitude to remove coslat. For that case, 3437 # do the equivalent of cls._d_lon in SphericalDifferential. 3438 if isinstance(representation, SphericalDifferential): 3439 return cls(representation.d_lon, -representation.d_lat, 3440 representation.d_distance) 3441 elif isinstance(representation, SphericalCosLatDifferential): 3442 cls._check_base(base) 3443 d_phi = representation.d_lon_coslat / np.sin(base.theta) 3444 return cls(d_phi, -representation.d_lat, representation.d_distance) 3445 3446 return super().from_representation(representation, base) 3447 3448 def _scale_operation(self, op, *args, scaled_base=False): 3449 if scaled_base: 3450 return self.__class__(self.d_phi, self.d_theta, op(self.d_r, *args)) 3451 else: 3452 return super()._scale_operation(op, *args) 3453 3454 3455class CylindricalDifferential(BaseDifferential): 3456 """Differential(s) of points in cylindrical coordinates. 3457 3458 Parameters 3459 ---------- 3460 d_rho : `~astropy.units.Quantity` ['speed'] 3461 The differential cylindrical radius. 3462 d_phi : `~astropy.units.Quantity` ['angular speed'] 3463 The differential azimuth. 3464 d_z : `~astropy.units.Quantity` ['speed'] 3465 The differential height. 3466 copy : bool, optional 3467 If `True` (default), arrays will be copied. If `False`, arrays will 3468 be references, though possibly broadcast to ensure matching shapes. 3469 """ 3470 base_representation = CylindricalRepresentation 3471 3472 def __init__(self, d_rho, d_phi=None, d_z=None, copy=False): 3473 super().__init__(d_rho, d_phi, d_z, copy=copy) 3474 if not self._d_rho.unit.is_equivalent(self._d_z.unit): 3475 raise u.UnitsError("d_rho and d_z should have equivalent units.") 3476