Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add BaseBodycentricRepresentation #14851

Merged
merged 3 commits into from Jun 23, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions astropy/coordinates/representation/__init__.py
Expand Up @@ -8,6 +8,7 @@
from .cylindrical import CylindricalRepresentation, CylindricalDifferential
from .geodetic import (
BaseGeodeticRepresentation,
BaseBodycentricRepresentation,
mhvk marked this conversation as resolved.
Show resolved Hide resolved
WGS84GeodeticRepresentation,
WGS72GeodeticRepresentation,
GRS80GeodeticRepresentation,
Expand Down Expand Up @@ -56,6 +57,7 @@
"CylindricalDifferential",
"PhysicsSphericalDifferential",
"BaseGeodeticRepresentation",
"BaseBodycentricRepresentation",
"WGS84GeodeticRepresentation",
"WGS72GeodeticRepresentation",
"GRS80GeodeticRepresentation",
Expand Down
87 changes: 86 additions & 1 deletion astropy/coordinates/representation/geodetic.py
@@ -1,5 +1,6 @@
# Licensed under a 3-clause BSD style license - see LICENSE.rst

import numpy as np
import erfa

from astropy import units as u
Expand Down Expand Up @@ -45,6 +46,7 @@
to quantities holding correct values (with units of length and dimensionless,
respectively), or alternatively an ``_ellipsoid`` attribute to the relevant ERFA
index (as passed in to `erfa.eform`).
Longitudes are east positive and span from 0 to 360 degrees by default.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: might as well leave this out for now.

Instead, I think here is the place to mention how lon, lat, and height are defined relative to the ellipsoid.

Alternatively, you could clarify the definition in geodetic_base_doc above (and then not use it for bodycentric)

"""

attr_classes = {"lon": Longitude, "lat": Latitude, "height": u.Quantity}
Expand Down Expand Up @@ -94,10 +96,93 @@
Converts 3D rectangular cartesian coordinates (assumed geocentric) to
geodetic coordinates.
"""
# Compute geodetic/planetodetic angles
lon, lat, height = erfa.gc2gde(
cls._equatorial_radius, cls._flattening, cart.get_xyz(xyz_axis=-1)
)
return cls(lon, lat, height, copy=False)
return cls(

Check warning on line 103 in astropy/coordinates/representation/geodetic.py

View check run for this annotation

Codecov / codecov/patch

astropy/coordinates/representation/geodetic.py#L103

Added line #L103 was not covered by tests
cmarmo marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick, but could you get this back to the default format? Be sure to delete the final , after copy=False, otherwise black will break it over several lines again.

lon,
lat,
height,
copy=False,
)


@format_doc(geodetic_base_doc)
class BaseBodycentricRepresentation(BaseRepresentation):
"""Representation of points in bodycentric 3D coordinates.

Subclasses need to set attributes ``_equatorial_radius`` and ``_flattening``
to quantities holding correct values (with units of length and dimensionless,
respectively).
Longitudes are east positive and span from 0 to 360 degrees by default.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, I'd remove this for now and instead describe how the parameters are used (and how it is different from geodetic).

Alternatively, do not use geodetic_base_doc, but instead write a complete docstring here that also describes how lat is defined.

"""

attr_classes = {"lon": Longitude, "lat": Latitude, "height": u.Quantity}

def __init_subclass__(cls, **kwargs):
if (
"_equatorial_radius" not in cls.__dict__
or "_flattening" not in cls.__dict__
):
raise AttributeError(

Check warning on line 128 in astropy/coordinates/representation/geodetic.py

View check run for this annotation

Codecov / codecov/patch

astropy/coordinates/representation/geodetic.py#L128

Added line #L128 was not covered by tests
f"{cls.__name__} requires '_equatorial_radius' and '_flattening'."
)
super().__init_subclass__(**kwargs)

def __init__(self, lon, lat=None, height=None, copy=True):
if height is None and not isinstance(lon, self.__class__):
height = 0 << u.m

Check warning on line 135 in astropy/coordinates/representation/geodetic.py

View check run for this annotation

Codecov / codecov/patch

astropy/coordinates/representation/geodetic.py#L134-L135

Added lines #L134 - L135 were not covered by tests

super().__init__(lon, lat, height, copy=copy)
if not self.height.unit.is_equivalent(u.m):
raise u.UnitTypeError(

Check warning on line 139 in astropy/coordinates/representation/geodetic.py

View check run for this annotation

Codecov / codecov/patch

astropy/coordinates/representation/geodetic.py#L137-L139

Added lines #L137 - L139 were not covered by tests
f"{self.__class__.__name__} requires height with units of length."
)

def to_cartesian(self):
"""
Converts bodycentric coordinates to 3D rectangular (geocentric)
cartesian coordinates.
"""
coslat = np.cos(self.lat)
sinlat = np.sin(self.lat)
coslon = np.cos(self.lon)
sinlon = np.sin(self.lon)
x_spheroid = self._equatorial_radius * coslat * coslon
y_spheroid = self._equatorial_radius * coslat * sinlon
z_spheroid = self._equatorial_radius * (1 - self._flattening) * sinlat
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These variables are unused.

Suggested change
x_spheroid = self._equatorial_radius * coslat * coslon
y_spheroid = self._equatorial_radius * coslat * sinlon
z_spheroid = self._equatorial_radius * (1 - self._flattening) * sinlat

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed! They are no longer needed as the calculation is done for r already.

r = (

Check warning on line 155 in astropy/coordinates/representation/geodetic.py

View check run for this annotation

Codecov / codecov/patch

astropy/coordinates/representation/geodetic.py#L148-L155

Added lines #L148 - L155 were not covered by tests
self._equatorial_radius
* np.sqrt(coslat**2 + ((1 - self._flattening) * sinlat) ** 2)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could use np.hypot() here:

Suggested change
* np.sqrt(coslat**2 + ((1 - self._flattening) * sinlat) ** 2)
* np.hypot(coslat, (1 - self._flattening) * sinlat)

+ self.height
)
x = r * coslon * coslat
y = r * sinlon * coslat
z = r * sinlat
return CartesianRepresentation(x=x, y=y, z=z, copy=False)

Check warning on line 163 in astropy/coordinates/representation/geodetic.py

View check run for this annotation

Codecov / codecov/patch

astropy/coordinates/representation/geodetic.py#L160-L163

Added lines #L160 - L163 were not covered by tests

@classmethod
def from_cartesian(cls, cart):
"""
Converts 3D rectangular cartesian coordinates (assumed geocentric) to
bodycentric coordinates.
"""
# Compute bodycentric latitude
p = np.hypot(cart.x, cart.y)
d = np.hypot(p, cart.z)
lat = np.arctan2(cart.z, p)
p_spheroid = cls._equatorial_radius * np.cos(lat)
z_spheroid = (cls._equatorial_radius * (1 - cls._flattening)) * np.sin(lat)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Total nitpick, but outer parentheses are not necessary.

r_spheroid = np.hypot(p_spheroid, z_spheroid)
height = d - r_spheroid
lon = np.arctan2(cart.y, cart.x)
return cls(

Check warning on line 180 in astropy/coordinates/representation/geodetic.py

View check run for this annotation

Codecov / codecov/patch

astropy/coordinates/representation/geodetic.py#L172-L180

Added lines #L172 - L180 were not covered by tests
cmarmo marked this conversation as resolved.
Show resolved Hide resolved
lon,
lat,
height,
copy=False,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, could you bring this on a single line?

)


@format_doc(geodetic_base_doc)
Expand Down
105 changes: 89 additions & 16 deletions astropy/coordinates/tests/test_geodetic_representations.py
Expand Up @@ -8,6 +8,7 @@
CartesianRepresentation,
REPRESENTATION_CLASSES,
BaseGeodeticRepresentation,
BaseBodycentricRepresentation,
GRS80GeodeticRepresentation,
WGS72GeodeticRepresentation,
WGS84GeodeticRepresentation,
Expand All @@ -29,8 +30,46 @@ class CustomGeodetic(BaseGeodeticRepresentation):
_equatorial_radius = 4000000.0 * u.m


class CustomSphericGeodetic(BaseGeodeticRepresentation):
_flattening = 0.0
_equatorial_radius = 4000000.0 * u.m


class CustomSphericBodycentric(BaseBodycentricRepresentation):
_flattening = 0.0
_equatorial_radius = 4000000.0 * u.m


class IAUMARS2000GeodeticRepresentation(BaseGeodeticRepresentation):
_equatorial_radius = 3396190.0 * u.m
_flattening = 0.5886007555512007 * u.percent


class IAUMARS2000BodycentricRepresentation(BaseBodycentricRepresentation):
_equatorial_radius = 3396190.0 * u.m
_flattening = 0.5886007555512007 * u.percent

cmarmo marked this conversation as resolved.
Show resolved Hide resolved

def test_geodetic_bodycentric_equivalence_spherical_bodies():
initial_cartesian = CartesianRepresentation(
x=[1, 3000.0] * u.km, y=[7000.0, 4.0] * u.km, z=[5.0, 6000.0] * u.km
)

gd_transformed = CustomSphericGeodetic.from_representation(initial_cartesian)
bc_transformed = CustomSphericBodycentric.from_representation(initial_cartesian)
assert_quantity_allclose(gd_transformed.lon, bc_transformed.lon)
assert_quantity_allclose(gd_transformed.lat, bc_transformed.lat)
assert_quantity_allclose(gd_transformed.height, bc_transformed.height)


@pytest.mark.parametrize(
"geodeticrepresentation", [CustomGeodetic, WGS84GeodeticRepresentation]
"geodeticrepresentation",
[
CustomGeodetic,
WGS84GeodeticRepresentation,
IAUMARS2000GeodeticRepresentation,
IAUMARS2000BodycentricRepresentation,
],
)
def test_cartesian_geodetic_roundtrip(geodeticrepresentation):
# Test array-valued input in the process.
Expand All @@ -48,7 +87,13 @@ def test_cartesian_geodetic_roundtrip(geodeticrepresentation):


@pytest.mark.parametrize(
"geodeticrepresentation", [CustomGeodetic, WGS84GeodeticRepresentation]
"geodeticrepresentation",
[
CustomGeodetic,
WGS84GeodeticRepresentation,
IAUMARS2000GeodeticRepresentation,
IAUMARS2000BodycentricRepresentation,
],
)
def test_geodetic_cartesian_roundtrip(geodeticrepresentation):
initial_geodetic = geodeticrepresentation(
Expand Down Expand Up @@ -122,41 +167,69 @@ def test_geodetic_to_geocentric():
vvd(xyz[2], -3040908.6861467111, 1e-7, "eraGd2gc", "2/3", status)


def test_default_height_is_zero():
gd = WGS84GeodeticRepresentation(10 * u.deg, 20 * u.deg)
@pytest.mark.parametrize(
"representation",
[
WGS84GeodeticRepresentation,
IAUMARS2000BodycentricRepresentation,
],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this fit on one line?

Suggested change
[
WGS84GeodeticRepresentation,
IAUMARS2000BodycentricRepresentation,
],
[WGS84GeodeticRepresentation, IAUMARS2000BodycentricRepresentation],

If it does then the other similar cases should be updated analogously.

)
def test_default_height_is_zero(representation):
gd = representation(10 * u.deg, 20 * u.deg)
assert gd.lon == 10 * u.deg
assert gd.lat == 20 * u.deg
assert gd.height == 0 * u.m


def test_non_angle_error():
with pytest.raises(u.UnitTypeError):
WGS84GeodeticRepresentation(20 * u.m, 20 * u.deg, 20 * u.m)
@pytest.mark.parametrize(
"representation",
[
WGS84GeodeticRepresentation,
IAUMARS2000BodycentricRepresentation,
],
)
def test_non_angle_error(representation):
with pytest.raises(u.UnitTypeError, match="require units equivalent to 'rad'"):
representation(20 * u.m, 20 * u.deg, 20 * u.m)


def test_non_length_error():
@pytest.mark.parametrize(
"representation",
[
WGS84GeodeticRepresentation,
IAUMARS2000BodycentricRepresentation,
],
)
def test_non_length_error(representation):
with pytest.raises(u.UnitTypeError, match="units of length"):
WGS84GeodeticRepresentation(10 * u.deg, 20 * u.deg, 30)
representation(10 * u.deg, 20 * u.deg, 30)


def test_geodetic_subclass_bad_ellipsoid():
def test_subclass_bad_ellipsoid():
# Test incomplete initialization.

msg = "module 'erfa' has no attribute 'foo'"
with pytest.raises(AttributeError, match=msg):

class InvalidCustomGeodeticEllipsoid(BaseGeodeticRepresentation):
class InvalidCustomEllipsoid(BaseGeodeticRepresentation):
_ellipsoid = "foo"

assert "foo" not in ELLIPSOIDS
assert "customgeodeticellipsoiderror" not in REPRESENTATION_CLASSES
assert "invalidcustomellipsoid" not in REPRESENTATION_CLASSES


def test_geodetic_subclass_missing_equatorial_radius():
msg = "MissingCustomGeodeticAttribute requires '_ellipsoid' or '_equatorial_radius' and '_flattening'."
@pytest.mark.parametrize(
"baserepresentation",
[
BaseGeodeticRepresentation,
BaseBodycentricRepresentation,
],
)
def test_geodetic_subclass_missing_equatorial_radius(baserepresentation):
msg = "'_equatorial_radius' and '_flattening'."
with pytest.raises(AttributeError, match=msg):

class MissingCustomGeodeticAttribute(BaseGeodeticRepresentation):
class MissingCustomAttribute(baserepresentation):
_flattening = 0.075 * u.dimensionless_unscaled

assert "customgeodeticerror" not in REPRESENTATION_CLASSES
assert "missingcustomattribute" not in REPRESENTATION_CLASSES
5 changes: 3 additions & 2 deletions astropy/coordinates/tests/test_representation.py
Expand Up @@ -1870,8 +1870,9 @@ def test_represent_as(self):
# TODO: Converting a CartesianDifferential to a
# RadialDifferential fails, even on `main`
continue
elif name.endswith("geodetic"):
# TODO: Geodetic representations do not have differentials yet
elif "geodetic" in name or "bodycentric" in name:
# TODO: spheroidal representations (geodetic or bodycentric)
# do not have differentials yet
continue
new_rep = rep1.represent_as(
REPRESENTATION_CLASSES[name], DIFFERENTIAL_CLASSES[name]
Expand Down
2 changes: 2 additions & 0 deletions docs/changes/coordinates/14851.feature.rst
@@ -0,0 +1,2 @@
Add ``BaseBodycentricRepresentation``, a new spheroidal representation for bodycentric
latitudes and longitudes spanning from 0 to 360 degrees.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's remove the "spanning from 0 to 360 degrees" here too.

29 changes: 24 additions & 5 deletions docs/coordinates/representations.rst
Expand Up @@ -32,9 +32,11 @@ The built-in representation classes are:
(``rho``), azimuthal angle (``phi``), and height (``z``).


Astropy also offers a `~astropy.coordinates.BaseGeodeticRepresentation` useful to
Astropy also offers a `~astropy.coordinates.BaseGeodeticRepresentation` and
a `~astropy.coordinates.BaseBodycentricRepresentation` useful to
create specific representations on spheroidal bodies.
This is used internally for the standard Earth ellipsoids used in
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't mean to have this part about the internal use to be removed!

`~astropy.coordinates.BaseGeodeticRepresentation` is used internally for the standard
Earth ellipsoids used in
`~astropy.coordinates.EarthLocation`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe combine with last line while you are making the last changes.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I'm not sure to understand this comment....

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Meant to combine the two lines to give```
Earth ellipsoids used in ~astropy.coordinates.EarthLocation

(`~astropy.coordinates.WGS84GeodeticRepresentation`,
`~astropy.coordinates.WGS72GeodeticRepresentation`, and
Expand All @@ -48,6 +50,13 @@ between the vertical to the surface at a specific point of the spheroid and its
projection onto the equatorial plane.
The latitude is a value ranging from -90 to 90 degrees, the longitude from 0 to 360
degrees.
`~astropy.coordinates.BaseBodycentricRepresentation` is the coordinate representation
mhvk marked this conversation as resolved.
Show resolved Hide resolved
recommended by the Cartographic Coordinates & Rotational Elements Working Group
(see for example its `2019 report <https://rdcu.be/b32WL>`_): the bodicentric latitude
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bodicentric -> bodycentric

and longitude are spherical latitude and longitude originated at the barycenter of the
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

originated at -> relative to

body, the height is the distance from the spheroid surface on the line of sight.
mhvk marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"the height is the distance from the spheroid surface on the line of sight" -> "height is the distance from the surface (measured radially)."

The latitude is a value ranging from -90 to 90 degrees, the longitude from 0 to 360
degrees.

.. Note::
For information about using and changing the representation of
Expand Down Expand Up @@ -706,11 +715,13 @@ In pseudo-code, this means that a class will look like::

.. _astropy-coordinates-create-geodetic:

Creating Your Own Geodetic Representation
-----------------------------------------
Creating Your Own Geodetic and Bodycentric Representation
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe make plural: "Representations"

---------------------------------------------------------

If you would like to use geodetic coordinates on planetary bodies other than the Earth,
you can define a new class that inherits from `~astropy.coordinates.BaseGeodeticRepresentation`.
you can define a new class that inherits from
`~astropy.coordinates.BaseGeodeticRepresentation` or
`~astropy.coordinates.BaseBodycentricRepresentation`.
The equatorial radius and flattening must be both assigned via the attributes
`_equatorial_radius` and `_flattening`.

Expand All @@ -721,3 +732,11 @@ For example the spheroid describing Mars as in the

_equatorial_radius = 3393400.0 * u.m
_flattening = 0.518650 * u.percent

The bodycentric coordinate system representing Mars as in the
`2000 IAU standard <https://doi.org/10.1023/A:1013939327465>`_ could be defined as::

class IAUMARS2000BodycentricRepresentation(BaseBodycentricRepresentation):

_equatorial_radius = 3396190.0 * u.m
_flattening = 0.5886008 * u.percent