Repository: sunpy/sunpy

Issue:
Observer-based RotatedSunFrame can't transform to a non-observer-based frame
```python
>>> from astropy import units as u
>>> from astropy.coordinates import SkyCoord

>>> from sunpy.coordinates import HeliographicStonyhurst, Helioprojective, RotatedSunFrame

>>> r = RotatedSunFrame(0*u.arcsec, 0*u.arcsec, base=Helioprojective(observer='earth', obstime='2021-01-01'))
>>> r
<RotatedSunHelioprojective Coordinate (base=<Helioprojective Frame (obstime=2021-01-01T00:00:00.000, rsun=695700.0 km, observer=<HeliographicStonyhurst Coordinate for 'earth'>)>, duration=0.0 d, rotation_model=howard): (Tx, Ty) in arcsec
    (0., 0.)>

>>> r.transform_to(HeliographicStonyhurst(obstime=r.base.obstime))
...
ConvertError: This transformation cannot be performed because the Heliocentric frame has observer=None.
```
This is because neither `RotatedSunHelioprojective` nor `HeliographicStonyhurst` have the `observer` frame attribute, but the HCC->HGS transformation step needs to have a defined observer.  (The `observer` frame attribute that you see in the repr for `RotatedSunHelioprojective` is a frame attribute of the `base` frame attribute.)  There are several approaches to fix this, but I need to investigate which approach makes the most sense.

Discussion:
pep8speaks: Hello @ayshih! Thanks for opening this PR. We checked the lines you've touched for [PEP 8](https://www.python.org/dev/peps/pep-0008) issues, and found:



* In the file [`sunpy/coordinates/tests/test_metaframes.py`](https://github.com/sunpy/sunpy/blob/8d2280bad277d9aaba4f48c6661adc9396d8fa89/sunpy/coordinates/tests/test_metaframes.py):

> [Line 96:101](https://github.com/sunpy/sunpy/blob/8d2280bad277d9aaba4f48c6661adc9396d8fa89/sunpy/coordinates/tests/test_metaframes.py#L96): [E501](https://duckduckgo.com/?q=pep8%20E501) line too long (104 > 100 characters)
> [Line 97:101](https://github.com/sunpy/sunpy/blob/8d2280bad277d9aaba4f48c6661adc9396d8fa89/sunpy/coordinates/tests/test_metaframes.py#L97): [E501](https://duckduckgo.com/?q=pep8%20E501) line too long (104 > 100 characters)


ayshih: Using hypothesis tests for benchmarking, the transformations are roughly twice as fast.

Before this PR:
```
sunpy/coordinates/tests/test_metaframes.py::test_rotatedsun_transforms[HeliographicStonyhurst]:
    - Typical runtimes: 65-72 ms, ~ 15% in data generation
sunpy/coordinates/tests/test_metaframes.py::test_rotatedsun_transforms[HeliographicCarrington]:
    - Typical runtimes: 156-170 ms, ~ 6% in data generation
sunpy/coordinates/tests/test_metaframes.py::test_rotatedsun_transforms[HeliocentricInertial]:
    - Typical runtimes: 26-30 ms, ~ 36% in data generation
```
After this PR:
```
sunpy/coordinates/tests/test_metaframes.py::test_rotatedsun_transforms[HeliographicStonyhurst]:
    - Typical runtimes: 27-30 ms, ~ 35% in data generation
sunpy/coordinates/tests/test_metaframes.py::test_rotatedsun_transforms[HeliographicCarrington]:
    - Typical runtimes: 96-104 ms, ~ 10% in data generation
sunpy/coordinates/tests/test_metaframes.py::test_rotatedsun_transforms[HeliocentricInertial]:
    - Typical runtimes: 57-63 ms, ~ 17% in data generation
```

Patch:
diff --git a/changelog/5084.bugfix.rst b/changelog/5084.bugfix.rst
new file mode 100644
index 000000000..219f7294f
--- /dev/null
+++ b/changelog/5084.bugfix.rst
@@ -0,0 +1,1 @@
+Fixed a bug where some `~sunpy.coordinates.metaframes.RotatedSunFrame` transformations could fail with an "observer=None" error.
diff --git a/changelog/5084.trivial.rst b/changelog/5084.trivial.rst
new file mode 100644
index 000000000..cbe968d9d
--- /dev/null
+++ b/changelog/5084.trivial.rst
@@ -0,0 +1,1 @@
+Refactored `~sunpy.coordinates.metaframes.RotatedSunFrame` transformations for improved performance.
diff --git a/sunpy/coordinates/metaframes.py b/sunpy/coordinates/metaframes.py
index eb9f01fb9..0d2d298c1 100644
--- a/sunpy/coordinates/metaframes.py
+++ b/sunpy/coordinates/metaframes.py
@@ -12,7 +12,7 @@ from sunpy import log
 from sunpy.time import parse_time
 from sunpy.time.time import _variables_for_parse_time_docstring
 from sunpy.util.decorators import add_common_docstring
-from .frames import BaseHeliographic, HeliocentricInertial, SunPyBaseCoordinateFrame
+from .frames import BaseHeliographic, HeliographicStonyhurst, SunPyBaseCoordinateFrame
 from .offset_frame import NorthOffsetFrame
 from .transformations import _transformation_debug
 
@@ -69,20 +69,19 @@ def _make_rotatedsun_cls(framecls):
     def rotatedsun_to_rotatedsun(from_rotatedsun_coord, to_rotatedsun_frame):
         """Transform between two rotated-Sun frames."""
         # This transform goes through the parent frames on each side.
-        # from_frame -> from_frame.base -> to_frame.base -> to_frame
-        intermediate_from = from_rotatedsun_coord.transform_to(from_rotatedsun_coord.base)
-        intermediate_to = intermediate_from.transform_to(to_rotatedsun_frame.base)
-        return intermediate_to.transform_to(to_rotatedsun_frame)
-
-    @frame_transform_graph.transform(FunctionTransform, framecls, _RotatedSunFramecls)
-    @_transformation_debug(f"{framecls.__name__}->{_RotatedSunFramecls.__name__}")
-    def reference_to_rotatedsun(reference_coord, rotatedsun_frame):
-        # Transform to HCI
-        hci_frame = HeliocentricInertial(obstime=rotatedsun_frame.base.obstime)
-        hci_coord = reference_coord.transform_to(hci_frame)  # obstime change handled here
-        oldrepr = hci_coord.spherical
-
-        # Rotate the coordinate in HCI
+        # from_frame -> HGS -> to_frame
+        int_frame = HeliographicStonyhurst(obstime=from_rotatedsun_coord.base.obstime)
+        int_coord = from_rotatedsun_coord.transform_to(int_frame)
+        return int_coord.transform_to(to_rotatedsun_frame)
+
+    @frame_transform_graph.transform(FunctionTransform, HeliographicStonyhurst, _RotatedSunFramecls)
+    @_transformation_debug(f"HGS->{_RotatedSunFramecls.__name__}")
+    def reference_to_rotatedsun(hgs_coord, rotatedsun_frame):
+        int_frame = HeliographicStonyhurst(obstime=rotatedsun_frame.base.obstime)
+        int_coord = hgs_coord.transform_to(int_frame)  # obstime change handled here
+        oldrepr = int_coord.spherical
+
+        # Rotate the coordinate in HGS
         from sunpy.physics.differential_rotation import diff_rot
         log.debug(f"Applying {rotatedsun_frame.duration} of solar rotation")
         newlon = oldrepr.lon - diff_rot(rotatedsun_frame.duration,
@@ -91,20 +90,20 @@ def _make_rotatedsun_cls(framecls):
                                         frame_time='sidereal')
         newrepr = SphericalRepresentation(newlon, oldrepr.lat, oldrepr.distance)
 
-        # Transform back from HCI
-        new_coord = hci_coord.realize_frame(newrepr).transform_to(rotatedsun_frame.base)
+        # Transform from HGS
+        new_coord = int_coord.realize_frame(newrepr).transform_to(rotatedsun_frame.base)
         return rotatedsun_frame.realize_frame(new_coord.data)
 
-    @frame_transform_graph.transform(FunctionTransform, _RotatedSunFramecls, framecls)
-    @_transformation_debug(f"{_RotatedSunFramecls.__name__}->{framecls.__name__}")
-    def rotatedsun_to_reference(rotatedsun_coord, reference_frame):
-        # Transform to HCI
+    @frame_transform_graph.transform(FunctionTransform, _RotatedSunFramecls, HeliographicStonyhurst)
+    @_transformation_debug(f"{_RotatedSunFramecls.__name__}->HGS")
+    def rotatedsun_to_reference(rotatedsun_coord, hgs_frame):
+        # Transform to HGS
         from_coord = rotatedsun_coord.base.realize_frame(rotatedsun_coord.data)
-        hci_frame = HeliocentricInertial(obstime=rotatedsun_coord.base.obstime)
-        hci_coord = from_coord.transform_to(hci_frame)
-        oldrepr = hci_coord.spherical
+        int_frame = HeliographicStonyhurst(obstime=rotatedsun_coord.base.obstime)
+        int_coord = from_coord.transform_to(int_frame)
+        oldrepr = int_coord.spherical
 
-        # Rotate the coordinate in HCI
+        # Rotate the coordinate in HGS
         from sunpy.physics.differential_rotation import diff_rot
         log.debug(f"Applying {rotatedsun_coord.duration} of solar rotation")
         newlon = oldrepr.lon + diff_rot(rotatedsun_coord.duration,
@@ -113,9 +112,9 @@ def _make_rotatedsun_cls(framecls):
                                         frame_time='sidereal')
         newrepr = SphericalRepresentation(newlon, oldrepr.lat, oldrepr.distance)
 
-        # Transform back from HCI
-        hci_coord = hci_frame.realize_frame(newrepr)
-        return hci_coord.transform_to(reference_frame)  # obstime change handled here
+        # Transform from HGS
+        int_coord = int_frame.realize_frame(newrepr)
+        return int_coord.transform_to(hgs_frame)  # obstime change handled here
 
     _rotatedsun_cache[framecls] = _RotatedSunFramecls
     return _RotatedSunFramecls


Test patch:
diff --git a/sunpy/coordinates/tests/test_metaframes.py b/sunpy/coordinates/tests/test_metaframes.py
index f2b5e31a8..cc14ea4d5 100644
--- a/sunpy/coordinates/tests/test_metaframes.py
+++ b/sunpy/coordinates/tests/test_metaframes.py
@@ -9,6 +9,7 @@ from astropy.time import Time
 import sunpy.coordinates.frames as f
 from sunpy.coordinates.metaframes import RotatedSunFrame, _rotatedsun_cache
 from sunpy.physics.differential_rotation import diff_rot
+from sunpy.sun import constants
 from .helpers import assert_longitude_allclose
 from .strategies import latitudes, longitudes, times
 
@@ -92,8 +93,8 @@ def test_class_creation(indirect_fixture):
 
     # Check that one-leg transformations have been created
     assert len(frame_transform_graph.get_transform(rot_class, rot_class).transforms) == 1
-    assert len(frame_transform_graph.get_transform(base_class, rot_class).transforms) == 1
-    assert len(frame_transform_graph.get_transform(rot_class, base_class).transforms) == 1
+    assert len(frame_transform_graph.get_transform(f.HeliographicStonyhurst, rot_class).transforms) == 1
+    assert len(frame_transform_graph.get_transform(rot_class, f.HeliographicStonyhurst).transforms) == 1
 
     # Check that the base frame is in the cache
     assert base_class in _rotatedsun_cache
@@ -201,6 +202,11 @@ def test_rotatedsun_transforms(frame, lon, lat, obstime, rotated_time1, rotated_
     else:
         base = frame(lon=lon, lat=lat, obstime=obstime)
 
+    # Map any 2D coordinate to the surface of the Sun
+    if base.spherical.distance.unit is u.one and u.allclose(base.spherical.distance, 1*u.one):
+        newrepr = base.spherical * constants.radius
+        base = base.realize_frame(newrepr)
+
     # Test the RotatedSunFrame->base transformation
     rsf1 = RotatedSunFrame(base=base, rotated_time=rotated_time1)
     result1 = rsf1.transform_to(base)
@@ -259,3 +265,14 @@ def test_obstime_change_loopback(indirect_fixture):
     loopback = rot_frame.transform_to(int_frame).transform_to(rot_frame)
 
     assert_longitude_allclose(loopback.lon, rot_frame.lon, atol=1e-10*u.deg)
+
+
+@pytest.mark.parametrize("indirect_fixture",
+                         ["rot_hgs", "rot_hgc", "rot_hci", "rot_hcc", "rot_hpc", "rot_hme"], indirect=True)
+def test_tranformation_to_nonobserver_frame(indirect_fixture):
+    base_class, rot_frame = indirect_fixture
+
+    hgs_frame = f.HeliographicStonyhurst(obstime='2020-01-01')
+    hgs_coord = rot_frame.transform_to(hgs_frame)
+
+    assert hgs_coord.obstime == hgs_frame.obstime