Repository: sunpy/sunpy

Issue:
Slim down GOES/SUVI tests?
### Describe the performance issue

In total there are 30 tests in `sunpy/net/dataretriever/sources/tests/test_goes_suvi.py`, which cumulatively seem to contribute a lot of time to the remote tests, ~7 minutes locally (although that's not running them in parallel). A lot of these are parametrized tests, so I'm wondering if we could thin them out a bit to save time on CI whilst still testing all the functionality?

### To Reproduce

_No response_

### Proposed fix

_No response_

Discussion:
sh-abinash-sh: 
ci/circleci: py312-figure-devdeps is failing!
nabobalis: Can you please not just comment if a CI check fail. 
sh-abinash-sh: > Can you please not just comment if a CI check fail.

ohhh sorry for the disturbance.

Patch:
diff --git a/.cruft.json b/.cruft.json
index cf1022f4d..49fc085e7 100644
--- a/.cruft.json
+++ b/.cruft.json
@@ -1,6 +1,6 @@
 {
   "template": "https://github.com/sunpy/package-template",
-  "commit": "c66950b04d0a535002d122c9d5bc0f23534b530c",
+  "commit": "c60ccf2ddc71d3cf126cea15132a15da307864f7",
   "checkout": null,
   "context": {
     "cookiecutter": {
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f9e63a483..88272b0f8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -188,20 +188,21 @@ jobs:
         contains(github.event.pull_request.labels.*.name, 'Run publish')
       )
     needs: [test, docs]
-    uses: OpenAstronomy/github-actions-workflows/.github/workflows/publish.yml@v1
+    uses: sunpy/github-actions-workflows/.github/workflows/publish.yml@main
     with:
       upload_to_anaconda: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') }}
       anaconda_user: scientific-python-nightly-wheels
       anaconda_package: sunpy
       anaconda_keep_n_latest: 1
       sdist: false
-      test_extras: 'tests-only'
-      test_command: 'pytest -p no:warnings --doctest-rst --pyargs sunpy.io.tests.test_ana'
+      test_extras: 'tests'
+      test_command: 'pytest -p no:warnings --doctest-rst --pyargs sunpy'
       submodules: false
       targets: |
         - cp3{10,11,12,13}-macosx_arm64
         - cp3{10,11,12,13}-macosx_x86_64
-        - cp3{10,11,12,13}-manylinux_aarch64
+        - target: cp3{10,11,12,13}-manylinux_aarch64
+          runs-on: arm
         - cp3{10,11,12,13}-manylinux_x86_64
     secrets:
       pypi_token: ${{ secrets.pypi_token }}
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index f540f017e..77e1a6409 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -2,7 +2,7 @@ exclude: ".*(.csv|.fits|.fts|.fit|.header|.txt|tca.*|.json|.asdf)$|^CITATION.rst
 repos:
     # This should be before any formatting hooks like isort
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: "v0.8.2"
+    rev: "v0.8.3"
     hooks:
       - id: ruff
         args: ["--fix"]
diff --git a/changelog/7803.feature.1.rst b/changelog/7803.feature.1.rst
new file mode 100644
index 000000000..da33a5a85
--- /dev/null
+++ b/changelog/7803.feature.1.rst
@@ -0,0 +1,1 @@
+Added the `~sunpy.coordinates.HelioprojectiveRadial` coordinate frame.
diff --git a/changelog/7803.feature.2.rst b/changelog/7803.feature.2.rst
new file mode 100644
index 000000000..02e94b5d6
--- /dev/null
+++ b/changelog/7803.feature.2.rst
@@ -0,0 +1,1 @@
+Added a function :func:`~sunpy.map.make_hpr_header` to easily make a FITS WCS header for the `~sunpy.coordinates.HelioprojectiveRadial` coordinate frame.
diff --git a/changelog/7803.feature.3.rst b/changelog/7803.feature.3.rst
new file mode 100644
index 000000000..c9c426b23
--- /dev/null
+++ b/changelog/7803.feature.3.rst
@@ -0,0 +1,1 @@
+Added a function :func:`sunpy.visualization.show_hpr_impact_angle` to modify a plot axis of a `~sunpy.coordinates.HelioprojectiveRadial` `~sunpy.map.Map` to show impact angle rather than declination for the tick labels.
diff --git a/docs/reference/coordinates/index.rst b/docs/reference/coordinates/index.rst
index dc6f4c936..23fa43ecf 100644
--- a/docs/reference/coordinates/index.rst
+++ b/docs/reference/coordinates/index.rst
@@ -67,6 +67,10 @@ Supported Coordinate Systems
      - HPC
      - `~sunpy.coordinates.frames.Helioprojective`
      -
+   * - Helioprojective Radial
+     - HPR
+     - `~sunpy.coordinates.frames.HelioprojectiveRadial`
+     - Uses declination (-90 deg at disk center) rather than impact angle (0 deg at disk center)
    * - Geocentric Earth Equatorial (Mean)
      - GEI
      - `~sunpy.coordinates.frames.GeocentricEarthEquatorial`
diff --git a/docs/whatsnew/6.1.rst b/docs/whatsnew/6.1.rst
index 2bfbfd75e..0ed1111b9 100644
--- a/docs/whatsnew/6.1.rst
+++ b/docs/whatsnew/6.1.rst
@@ -23,6 +23,16 @@ The minimum required versions of the following packages have been updated:
 
 - matplotlib>=3.6.0
 
+Support for helioprojective radial coordinates and maps
+=======================================================
+
+Helioprojective radial (HPR) coordinates are now supported using the `~sunpy.coordinates.HelioprojectiveRadial` coordinate frame, and HPR FITS files are also supported.
+Per the FITS WCS machinery, the impact-angle component (``theta``) is replaced by the a declination component (``delta``) by subtracting 90 degrees from ``theta``.
+An HPR coordinate has a ``theta`` property to quickly obtain the impact angle, and there is a visualization helper function (:func:`~sunpy.visualization.show_hpr_impact_angle`) to modify a plot axis.
+Finally, there is a helper function (:func:`~sunpy.map.make_hpr_header`) to create HPR FITS headers.
+
+.. minigallery:: sunpy.coordinates.HelioprojectiveRadial
+
 Additional new map dependency
 =============================
 
diff --git a/examples/map_transformations/reprojection_helioprojective_radial.py b/examples/map_transformations/reprojection_helioprojective_radial.py
new file mode 100644
index 000000000..2946ee640
--- /dev/null
+++ b/examples/map_transformations/reprojection_helioprojective_radial.py
@@ -0,0 +1,106 @@
+"""
+============================================================
+Reprojecting a Helioprojective Map to Helioprojective Radial
+============================================================
+
+In this example we use `reproject` to transform a helioprojective
+`~sunpy.map.Map` to helioprojective radial.
+"""
+# sphinx_gallery_thumbnail_number = 2
+
+import matplotlib.pyplot as plt
+
+import astropy.units as u
+
+import sunpy.data.sample
+import sunpy.map
+from sunpy.coordinates import HelioprojectiveRadial
+from sunpy.map.header_helper import make_hpr_header
+from sunpy.visualization import show_hpr_impact_angle
+
+###############################################################################
+# Let's start with an AIA map in the helioprojective Cartesian
+# (`~sunpy.coordinates.Helioprojective`) frame.
+
+aia_map = sunpy.map.Map(sunpy.data.sample.AIA_193_IMAGE)
+
+###############################################################################
+# We now plot the map and overlay grid lines for position angle in the
+# helioprojective radial (`~sunpy.coordinates.HelioprojectiveRadial`) frame.
+
+fig = plt.figure()
+ax = fig.add_subplot(projection=aia_map)
+aia_map.plot(axes=ax)
+
+overlay = ax.get_coords_overlay(
+    HelioprojectiveRadial(
+        obstime=aia_map.reference_date,
+        observer=aia_map.observer_coordinate
+    )
+)
+
+overlay['delta'].add_tickable_gridline('limb', 960*u.arcsec - 90*u.deg)
+
+overlay['psi'].grid(color='red', linestyle='dashed')
+overlay['psi'].set_ticks(spacing=30*u.deg)
+overlay['psi'].set_ticks_visible(False)
+overlay['psi'].set_ticklabel(color='red')
+overlay['psi'].set_ticklabel_position(('limb',))
+overlay['psi'].set_axislabel('Helioprojective Position Angle', color='red')
+overlay['psi'].set_axislabel_position('r')
+
+overlay['delta'].set_ticks_visible(False)
+overlay['delta'].set_ticklabel_visible(False)
+overlay['delta'].set_axislabel('')
+
+###############################################################################
+# In order to reproject this map to a helioprojective radial frame, we use
+# :func:`sunpy.map.make_hpr_header` to create an appropriate FITS WCS header.
+
+hpr_header = make_hpr_header(
+    aia_map.observer_coordinate,
+    (1000, 360),
+    1.8*u.arcsec
+)
+
+###############################################################################
+# With the new header, reproject the data into the new coordinate frame.
+# The :meth:`~sunpy.map.GenericMap.reproject_to` defaults to using
+# the fast :func:`reproject.reproject_interp` algorithm, but a different
+# algorithm can be specified (e.g., :func:`reproject.reproject_adaptive`).
+
+outmap = aia_map.reproject_to(hpr_header)
+
+###############################################################################
+# Now we plot the reprojected map.  First we add an overlay for the original
+# helioprojective Cartesian frame, but disable its associated ticks and labels.
+
+fig = plt.figure()
+ax = fig.add_subplot(projection=outmap)
+outmap.plot(axes=ax)
+
+ax.coords[0].set_ticks(spacing=30*u.deg)
+ax.set_aspect(0.3)
+
+overlay = ax.get_coords_overlay(aia_map.coordinate_frame)
+overlay.grid(color='blue', linestyle='dashed')
+for coord in overlay:
+    coord.set_ticks(spacing=500*u.arcsec)
+    coord.set_ticks_visible(False)
+    coord.set_ticklabel_visible(False)
+    coord.set_axislabel('')
+
+# sphinx_gallery_defer_figures
+
+###############################################################################
+# Due to the FITS WCS machinery, the impact-angle component of helioprojective
+# radial is instead stored as a declination by subtracting 90 degrees, and the
+# vertical axis being declination can be unfamiliar.  To change the declination
+# tick labels to impact-angle tick labels, we use
+# :func:`~sunpy.visualization.show_hpr_impact_angle` to modify
+# the rendering of the tick labels on the appropriate axis (``ax.coords[1]``).
+
+show_hpr_impact_angle(ax.coords[1])
+ax.coords[1].set_format_unit('arcsec')
+
+plt.show()
diff --git a/sunpy/coordinates/_transformations.py b/sunpy/coordinates/_transformations.py
index df11630bc..dc8272ce2 100644
--- a/sunpy/coordinates/_transformations.py
+++ b/sunpy/coordinates/_transformations.py
@@ -57,6 +57,7 @@ from .frames import (
     HeliographicCarrington,
     HeliographicStonyhurst,
     Helioprojective,
+    HelioprojectiveRadial,
     SolarMagnetic,
 )
 
@@ -581,6 +582,68 @@ def hpc_to_hpc(from_coo, to_frame):
     return hpc
 
 
+def _matrix_hpc_to_hpr():
+    # Returns the transformation matrix that permutes/swaps axes from HPC to HPR
+
+    # HPR spherical coordinates are a right-handed frame with these equivalent Cartesian axes:
+    #   HPR_X = HPC_Z
+    #   HPR_Y = -HPC_Y
+    #   HPR_Z = -HPC_X
+    # (HPC_X and HPC_Y are not to be confused with HPC_Tx and HPC_Ty)
+    return np.array([[0, 0, 1],
+                     [0, -1, 0],
+                     [-1, 0, 0]])
+
+
+@frame_transform_graph.transform(FunctionTransformWithFiniteDifference,
+                                 Helioprojective, HelioprojectiveRadial)
+@_transformation_debug("HPC->HPR")
+def hpc_to_hpr(hpc_coord, hpr_frame):
+    """
+    Convert from Helioprojective Cartesian to Helioprojective Radial.
+    """
+    _check_observer_defined(hpc_coord)
+    _check_observer_defined(hpr_frame)
+
+    # Transform the HPR observer (in HGS) to the HPR obstime in case it's different
+    observer = _transform_obstime(hpr_frame.observer, hpr_frame.obstime)
+
+    # Loopback transform HPC coord to obstime and observer of HPR frame
+    int_frame = Helioprojective(obstime=observer.obstime, observer=observer)
+    int_coord = hpc_coord.transform_to(int_frame)
+
+    # Permute/swap axes from HPC to HPR equivalent Cartesian
+    newrepr = int_coord.cartesian.transform(_matrix_hpc_to_hpr())
+
+    return hpr_frame.realize_frame(newrepr)
+
+
+@frame_transform_graph.transform(FunctionTransformWithFiniteDifference,
+                                 HelioprojectiveRadial, Helioprojective)
+@_transformation_debug("HPR->HPC")
+def hpr_to_hpc(hpr_coord, hpc_frame):
+    """
+    Convert from Helioprojective Radial to Helioprojective Cartesian.
+    """
+    _check_observer_defined(hpr_coord)
+    _check_observer_defined(hpc_frame)
+
+    # Permute/swap axes from HPR to HPC equivalent Cartesian
+    newrepr = hpr_coord.cartesian.transform(matrix_transpose(_matrix_hpc_to_hpr()))
+
+    # Transform the HPR observer (in HGS) to the HPR obstime in case it's different
+    observer = _transform_obstime(hpr_coord.observer, hpr_coord.obstime)
+
+    # Complete the conversion of HPR to HPC at the obstime and observer of the HPR coord
+    int_coord = Helioprojective(newrepr, obstime=observer.obstime, observer=observer)
+
+    # Loopback transform HPC as needed
+    return int_coord.transform_to(hpc_frame)
+
+
+frame_transform_graph._add_merged_transform(HelioprojectiveRadial, Helioprojective, HelioprojectiveRadial)
+
+
 def _rotation_matrix_reprs_to_reprs(start_representation, end_representation):
     """
     Return the matrix for the direct rotation from one representation to a second representation.
@@ -1249,7 +1312,7 @@ def _make_sunpy_graph():
     # Frames to keep in the transformation graph
     keep_list = ['icrs', 'hcrs', 'heliocentrictrueecliptic', 'heliocentricmeanecliptic',
                  'heliographic_stonyhurst', 'heliographic_carrington',
-                 'heliocentric', 'helioprojective',
+                 'heliocentric', 'helioprojective', 'helioprojectiveradial',
                  'heliocentricearthecliptic', 'geocentricsolarecliptic',
                  'heliocentricinertial', 'geocentricearthequatorial',
                  'geomagnetic', 'solarmagnetic', 'geocentricsolarmagnetospheric',
@@ -1322,7 +1385,7 @@ def _tweak_graph(docstr):
 
     # Set the nodes for SunPy frames to be white
     sunpy_frames = ['HeliographicStonyhurst', 'HeliographicCarrington',
-                    'Heliocentric', 'Helioprojective',
+                    'Heliocentric', 'Helioprojective', 'HelioprojectiveRadial',
                     'HeliocentricEarthEcliptic', 'GeocentricSolarEcliptic',
                     'HeliocentricInertial', 'GeocentricEarthEquatorial',
                     'Geomagnetic', 'SolarMagnetic', 'GeocentricSolarMagnetospheric']
diff --git a/sunpy/coordinates/frames.py b/sunpy/coordinates/frames.py
index 10fd11a8f..2badaf3bc 100644
--- a/sunpy/coordinates/frames.py
+++ b/sunpy/coordinates/frames.py
@@ -36,7 +36,7 @@ _J2000 = Time('J2000.0', scale='tt')
 
 __all__ = ['SunPyBaseCoordinateFrame', 'BaseHeliographic', 'BaseMagnetic',
            'HeliographicStonyhurst', 'HeliographicCarrington',
-           'Heliocentric', 'Helioprojective',
+           'Heliocentric', 'Helioprojective', 'HelioprojectiveRadial',
            'HeliocentricEarthEcliptic', 'GeocentricSolarEcliptic',
            'HeliocentricInertial', 'GeocentricEarthEquatorial',
            'Geomagnetic', 'SolarMagnetic', 'GeocentricSolarMagnetospheric']
@@ -684,6 +684,125 @@ class Helioprojective(SunPyBaseCoordinateFrame):
             cls._assumed_screen = old_assumed_screen
 
 
+@add_common_docstring(**_frame_parameters())
+class HelioprojectiveRadial(SunPyBaseCoordinateFrame):
+    """
+    A coordinate or frame in the Helioprojective Radial system.
+
+    This is an observer-based spherical coordinate system, with:
+
+    - ``psi`` is the position angle of the coordinate, measured eastward from solar
+      north
+    - ``delta`` is the declination angle, which is the impact angle (the angle
+      between the observer-Sun line and the observer-coordinate line) minus 90
+      degrees
+    - ``r`` is the observer-coordinate distance
+
+    .. note::
+        The declination angle, rather than the impact angle, is used as a component
+        in order to match the FITS WCS definition.  The impact angle can be readily
+        retrieved using the `theta` property.
+
+    Parameters
+    ----------
+    {data}
+    psi : `~astropy.coordinates.Angle` or `~astropy.units.Quantity`
+        The position angle. Not needed if ``data`` is given.
+    delta : `~astropy.coordinates.Angle` or `~astropy.units.Quantity`
+        The declination angle. Not needed if ``data`` is given.
+    r: `~astropy.coordinates.Angle` or `~astropy.units.Quantity`
+        The observer-coordinate distance.  Not needed if ``data`` is given.
+    {rsun}
+    {observer}
+    {common}
+
+    See Also
+    --------
+    Helioprojective
+
+    Examples
+    --------
+    >>> from astropy.coordinates import SkyCoord
+    >>> import sunpy.coordinates
+    >>> import astropy.units as u
+
+    >>> sc = SkyCoord(0*u.deg, -90*u.deg, 5*u.km,
+    ...               obstime="2010/01/01T00:00:00", observer="earth", frame="helioprojectiveradial")
+    >>> sc
+    <SkyCoord (HelioprojectiveRadial: obstime=2010-01-01T00:00:00.000, rsun=695700.0 km, observer=<HeliographicStonyhurst Coordinate for 'earth'>): (psi, delta, distance) in (deg, deg, km)
+        (0., -90., 5.)>
+    >>> sc.theta
+    <Angle 0. arcsec>
+
+    >>> sc = SkyCoord(30*u.deg, -89.9*u.deg,
+    ...               obstime="2010/01/01T00:00:00", observer="earth", frame="helioprojectiveradial")
+    >>> sc
+    <SkyCoord (HelioprojectiveRadial: obstime=2010-01-01T00:00:00.000, rsun=695700.0 km, observer=<HeliographicStonyhurst Coordinate for 'earth'>): (psi, delta) in deg
+        (30., -89.9)>
+    >>> sc.theta
+    <Angle 360. arcsec>
+
+    >>> sc = SkyCoord(CartesianRepresentation(1e5*u.km, -2e5*u.km, -1*u.AU),
+    ...               obstime="2011/01/05T00:00:50", observer="earth", frame="helioprojectiveradial")
+    >>> sc
+    <SkyCoord (HelioprojectiveRadial: obstime=2011-01-05T00:00:50.000, rsun=695700.0 km, observer=<HeliographicStonyhurst Coordinate for 'earth'>): (psi, delta, distance) in (deg, deg, km)
+        (296.56505118, -89.91435897, 1.49598038e+08)>
+    >>> sc.theta
+    <Angle 308.30772022 arcsec>
+
+    .. minigallery:: sunpy.coordinates.HelioprojectiveRadial
+    """
+    _wrap_angle = 360*u.deg
+
+    default_representation = SphericalRepresentation
+
+    frame_specific_representation_info = {
+        SphericalRepresentation: [RepresentationMapping('lon', 'psi', u.deg),
+                                  RepresentationMapping('lat', 'delta', u.deg),
+                                  RepresentationMapping('distance', 'distance', None)],
+        SphericalDifferential: [RepresentationMapping('d_lon', 'd_psi', u.deg/u.s),
+                                RepresentationMapping('d_lat', 'd_delta', u.deg/u.s),
+                                RepresentationMapping('d_distance', 'd_distance', u.km/u.s)],
+        UnitSphericalRepresentation: [RepresentationMapping('lon', 'psi', u.deg),
+                                      RepresentationMapping('lat', 'delta', u.deg)],
+    }
+
+    rsun = QuantityAttribute(default=_RSUN, unit=u.km)
+    observer = ObserverCoordinateAttribute(HeliographicStonyhurst)
+
+    @property
+    def theta(self):
+        """
+        Returns the impact angle, which is the declination angle plus 90 degrees.
+        """
+        return (90*u.deg + self.spherical.lat).to(u.arcsec)
+
+    def make_3d(self):
+        """
+        Returns a 3D version of this coordinate.
+
+        If the coordinate is 2D, the default assumption is that the coordinate is on
+        the surface of the Sun, and the distance component is calculated
+        accordingly.  Under this assumption, if the 2D coordinate is outside the
+        disk, the distance component will be NaN.
+
+        The assumption can be changed using one of the screens in
+        `sunpy.coordinates.screens`.
+
+        Returns
+        -------
+        `~sunpy.coordinates.frames.HelioprojectiveRadial`
+            The 3D version of this coordinate.
+        """
+        # Skip if we already are 3D
+        if not self._is_2d:
+            return self
+
+        # Make 3D by going through HPC, which thus will make use of any screen
+        hpc_frame = Helioprojective(obstime=self.obstime, observer=self.observer, rsun=self.rsun)
+        return self.transform_to(hpc_frame).make_3d().transform_to(self)
+
+
 @add_common_docstring(**_frame_parameters())
 class HeliocentricEarthEcliptic(SunPyBaseCoordinateFrame):
     """
diff --git a/sunpy/coordinates/wcs_utils.py b/sunpy/coordinates/wcs_utils.py
index 8d84aeb50..72706d7d9 100644
--- a/sunpy/coordinates/wcs_utils.py
+++ b/sunpy/coordinates/wcs_utils.py
@@ -4,6 +4,7 @@ import astropy.wcs.utils
 from astropy.coordinates import BaseCoordinateFrame, SkyCoord
 from astropy.wcs import WCS
 from astropy.wcs.utils import obsgeo_to_frame
+from astropy.wcs.wcsapi.fitswcs import custom_ctype_to_ucd_mapping
 
 from sunpy import log
 from .frames import (
@@ -11,6 +12,7 @@ from .frames import (
     HeliographicCarrington,
     HeliographicStonyhurst,
     Helioprojective,
+    HelioprojectiveRadial,
     SunPyBaseCoordinateFrame,
 )
 
@@ -109,6 +111,7 @@ def _sunpy_frame_class_from_ctypes(ctypes):
 
     mapping = {
         Helioprojective: {'HPLN', 'HPLT'},
+        HelioprojectiveRadial: {'HRLN', 'HRLT'},
         HeliographicStonyhurst: {'HGLN', 'HGLT'},
         HeliographicCarrington: {'CRLN', 'CRLT'},
         Heliocentric: {'SOLX', 'SOLY'},
@@ -176,20 +179,24 @@ def solar_frame_to_wcs_mapping(frame, projection='TAN'):
             wcs.wcs.dateobs = frame.obstime.utc.isot
 
         if isinstance(frame, Helioprojective):
-            xcoord = 'HPLN' + '-' + projection
-            ycoord = 'HPLT' + '-' + projection
+            xcoord = f'HPLN-{projection}'
+            ycoord = f'HPLT-{projection}'
             wcs.wcs.cunit = ['arcsec', 'arcsec']
+        elif isinstance(frame, HelioprojectiveRadial):
+            xcoord = f'HRLN-{projection}'
+            ycoord = f'HRLT-{projection}'
+            wcs.wcs.cunit = ['deg', 'arcsec']
         elif isinstance(frame, Heliocentric):
             xcoord = 'SOLX'
             ycoord = 'SOLY'
             wcs.wcs.cunit = ['deg', 'deg']
         elif isinstance(frame, HeliographicCarrington):
-            xcoord = 'CRLN' + '-' + projection
-            ycoord = 'CRLT' + '-' + projection
+            xcoord = f'CRLN-{projection}'
+            ycoord = f'CRLT-{projection}'
             wcs.wcs.cunit = ['deg', 'deg']
         elif isinstance(frame, HeliographicStonyhurst):
-            xcoord = 'HGLN' + '-' + projection
-            ycoord = 'HGLT' + '-' + projection
+            xcoord = f'HGLN-{projection}'
+            ycoord = f'HGLT-{projection}'
             wcs.wcs.cunit = ['deg', 'deg']
         else:
             # A subclass not supported by the core library
@@ -205,3 +212,6 @@ def solar_frame_to_wcs_mapping(frame, projection='TAN'):
 
 astropy.wcs.utils.WCS_FRAME_MAPPINGS.append([solar_wcs_frame_mapping])
 astropy.wcs.utils.FRAME_WCS_MAPPINGS.append([solar_frame_to_wcs_mapping])
+
+custom_ctype_to_ucd_mapping({"HRLN": "custom:pos.helioprojectiveradial.lon"})
+custom_ctype_to_ucd_mapping({"HRLT": "custom:pos.helioprojectiveradial.lat"})
diff --git a/sunpy/io/special/asdf/converters/frames.py b/sunpy/io/special/asdf/converters/frames.py
index e568ccc8e..a59343582 100644
--- a/sunpy/io/special/asdf/converters/frames.py
+++ b/sunpy/io/special/asdf/converters/frames.py
@@ -31,6 +31,10 @@ SUNPY_FRAME_CONVERTERS = [
         "tag:sunpy.org:sunpy/coordinates/frames/helioprojective-*",
         "sunpy.coordinates.frames.Helioprojective"
     ),
+    SunpyFrameConverter(
+        "tag:sunpy.org:sunpy/coordinates/frames/helioprojectiveradial-*",
+        "sunpy.coordinates.frames.HelioprojectiveRadial"
+    ),
     SunpyFrameConverter(
         "tag:sunpy.org:sunpy/coordinates/frames/heliocentricinertial-*",
         "sunpy.coordinates.frames.HeliocentricInertial"
diff --git a/sunpy/io/special/asdf/entry_points.py b/sunpy/io/special/asdf/entry_points.py
index 728b43e51..8e20407e0 100644
--- a/sunpy/io/special/asdf/entry_points.py
+++ b/sunpy/io/special/asdf/entry_points.py
@@ -40,7 +40,9 @@ def get_extensions():
     # order here is important as asdf will pick the first matching
     # extension, start with the newest
     _manifest_uris = [
+        "asdf://sunpy.org/sunpy/manifests/sunpy-1.2.1",
         "asdf://sunpy.org/sunpy/manifests/sunpy-1.2.0",
+        "asdf://sunpy.org/sunpy/manifests/sunpy-1.1.2",
         "asdf://sunpy.org/sunpy/manifests/sunpy-1.1.1",
         "asdf://sunpy.org/sunpy/manifests/sunpy-1.1.0",
         "asdf://sunpy.org/sunpy/manifests/sunpy-1.0.0",
diff --git a/sunpy/io/special/asdf/resources/manifests/sunpy-1.1.2.yaml b/sunpy/io/special/asdf/resources/manifests/sunpy-1.1.2.yaml
new file mode 100644
index 000000000..7fad232a2
--- /dev/null
+++ b/sunpy/io/special/asdf/resources/manifests/sunpy-1.1.2.yaml
@@ -0,0 +1,61 @@
+%YAML 1.1
+---
+id: asdf://sunpy.org/sunpy/manifests/sunpy-1.1.2
+extension_uri: asdf://sunpy.org/extensions/sunpy-1.1.2
+title: SunPy ASDF Manifest
+description: ASDF schemas and tags for SunPy classes.
+
+tags:
+  - tag_uri: "tag:sunpy.org:sunpy/map/generic_map-1.0.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/generic_map-1.0.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/map/generic_map-1.1.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/generic_map-1.1.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/map/generic_map-1.1.1"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/generic_map-1.1.1"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/heliographic_stonyhurst-1.1.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/heliographic_stonyhurst-1.1.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/heliographic_stonyhurst-1.0.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/heliographic_stonyhurst-1.0.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/heliographic_carrington-1.2.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/heliographic_carrington-1.2.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/heliographic_carrington-1.1.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/heliographic_carrington-1.1.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/heliographic_carrington-1.0.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/heliographic_carrington-1.0.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/helioprojective-1.0.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/helioprojective-1.0.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/helioprojectiveradial-1.0.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/helioprojectiveradial-1.0.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/heliocentricinertial-1.0.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/heliocentricinertial-1.0.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/heliocentricearthecliptic-1.0.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/heliocentricearthecliptic-1.0.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/heliocentric-1.0.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/heliocentric-1.0.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/geocentricsolarecliptic-1.0.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/geocentricsolarecliptic-1.0.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/geocentricearthequatorial-1.0.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/geocentricearthequatorial-1.0.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/geomagnetic-1.0.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/geomagnetic-1.0.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/solarmagnetic-1.0.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/solarmagnetic-1.0.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/geocentricsolarmagnetospheric-1.0.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/geocentricsolarmagnetospheric-1.0.0"
diff --git a/sunpy/io/special/asdf/resources/manifests/sunpy-1.2.1.yaml b/sunpy/io/special/asdf/resources/manifests/sunpy-1.2.1.yaml
new file mode 100644
index 000000000..8b5ccfba9
--- /dev/null
+++ b/sunpy/io/special/asdf/resources/manifests/sunpy-1.2.1.yaml
@@ -0,0 +1,48 @@
+%YAML 1.1
+---
+id: asdf://sunpy.org/sunpy/manifests/sunpy-1.2.1
+extension_uri: asdf://sunpy.org/extensions/sunpy-1.2.1
+title: SunPy ASDF Manifest
+description: ASDF schemas and tags for SunPy classes.
+asdf_standard_requirement:
+  gte: 1.6.0
+
+tags:
+  - tag_uri: "tag:sunpy.org:sunpy/map/generic_map-1.2.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/generic_map-1.2.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/geocentricearthequatorial-1.1.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/geocentricearthequatorial-1.1.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/geocentricsolarecliptic-1.1.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/geocentricsolarecliptic-1.1.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/geocentricsolarmagnetospheric-1.1.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/geocentricsolarmagnetospheric-1.1.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/geomagnetic-1.1.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/geomagnetic-1.1.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/heliocentric-1.1.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/heliocentric-1.1.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/heliocentricearthecliptic-1.1.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/heliocentricearthecliptic-1.1.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/heliocentricinertial-1.1.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/heliocentricinertial-1.1.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/heliographic_carrington-1.3.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/heliographic_carrington-1.3.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/heliographic_stonyhurst-1.2.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/heliographic_stonyhurst-1.2.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/helioprojective-1.1.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/helioprojective-1.1.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/helioprojectiveradial-1.1.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/helioprojectiveradial-1.1.0"
+
+  - tag_uri: "tag:sunpy.org:sunpy/coordinates/frames/solarmagnetic-1.1.0"
+    schema_uri: "asdf://sunpy.org/sunpy/schemas/solarmagnetic-1.1.0"
diff --git a/sunpy/io/special/asdf/resources/schemas/helioprojectiveradial-1.0.0.yaml b/sunpy/io/special/asdf/resources/schemas/helioprojectiveradial-1.0.0.yaml
new file mode 100644
index 000000000..ed9c5e621
--- /dev/null
+++ b/sunpy/io/special/asdf/resources/schemas/helioprojectiveradial-1.0.0.yaml
@@ -0,0 +1,59 @@
+%YAML 1.1
+---
+$schema: "http://stsci.edu/schemas/yaml-schema/draft-01"
+id: "asdf://sunpy.org/sunpy/schemas/helioprojectiveradial-1.0.0"
+
+title: |
+  Represents a Helioprojective Radial coordinate object from SunPy
+
+examples:
+  -
+    - A Helioprojective Radial frame without data
+    - asdf-standard-1.5.0
+    - |
+        !<tag:sunpy.org:sunpy/coordinates/frames/helioprojectiveradial-1.0.0>
+          frame_attributes:
+            rsun: !unit/quantity-1.1.0 {unit: !unit/unit-1.0.0 km, value: 695700.0}
+  -
+    - A Helioprojective Radial frame with an observer and an obstime
+    - asdf-standard-1.5.0
+    - |
+        !<tag:sunpy.org:sunpy/coordinates/frames/helioprojectiveradial-1.0.0>
+          frame_attributes:
+            observer: !<tag:sunpy.org:sunpy/coordinates/frames/heliographic_stonyhurst-1.1.0>
+              data: !<tag:astropy.org:astropy/coordinates/representation-1.0.0>
+                components:
+                  distance: !unit/quantity-1.1.0 {unit: !unit/unit-1.0.0 AU, value: 0.9833554225272192}
+                  lat: !<tag:astropy.org:astropy/coordinates/latitude-1.0.0> {unit: !unit/unit-1.0.0 deg,
+                    value: -2.982157245087939}
+                  lon: !<tag:astropy.org:astropy/coordinates/longitude-1.0.0>
+                    unit: !unit/unit-1.0.0 deg
+                    value: 0.0
+                    wrap_angle: !<tag:astropy.org:astropy/coordinates/angle-1.0.0> {unit: !unit/unit-1.0.0 deg,
+                      value: 180.0}
+                type: SphericalRepresentation
+              frame_attributes:
+                obstime: !time/time-1.1.0 '2011-01-01T01:01:01.000'
+                rsun: !unit/quantity-1.1.0 {unit: !unit/unit-1.0.0 km, value: 695700.0}
+            obstime: !time/time-1.1.0 '2011-01-01T01:01:01.000'
+            rsun: !unit/quantity-1.1.0 {unit: !unit/unit-1.0.0 km, value: 695700.0}
+
+allOf:
+  - $ref: "http://astropy.org/schemas/astropy/coordinates/frames/baseframe-1.0.0"
+  - properties:
+      frame_attributes:
+        type: object
+        properties:
+          observer:
+            anyOf:
+              - tag: "tag:sunpy.org:sunpy/coordinates/frames/heliographic_stonyhurst-1.*"
+              - type:
+                  string
+          rsun:
+            tag: "tag:stsci.edu:asdf/unit/quantity-1.*"
+          obstime:
+            tag: "tag:stsci.edu:asdf/time/time-1.*"
+
+        required: [rsun]
+        additionalProperties: False
+...
diff --git a/sunpy/io/special/asdf/resources/schemas/helioprojectiveradial-1.1.0.yaml b/sunpy/io/special/asdf/resources/schemas/helioprojectiveradial-1.1.0.yaml
new file mode 100644
index 000000000..2c7c3d092
--- /dev/null
+++ b/sunpy/io/special/asdf/resources/schemas/helioprojectiveradial-1.1.0.yaml
@@ -0,0 +1,59 @@
+%YAML 1.1
+---
+$schema: "http://stsci.edu/schemas/yaml-schema/draft-01"
+id: "asdf://sunpy.org/sunpy/schemas/helioprojectiveradial-1.1.0"
+
+title: |
+  Represents a Helioprojective Radial coordinate object from SunPy
+
+examples:
+  -
+    - A Helioprojective Radial frame without data
+    - asdf-standard-1.6.0
+    - |
+        !<tag:sunpy.org:sunpy/coordinates/frames/helioprojectiveradial-1.1.0>
+          frame_attributes:
+            rsun: !unit/quantity-1.2.0 {unit: !unit/unit-1.0.0 km, value: 695700.0}
+  -
+    - A Helioprojective Radial frame with an observer and an obstime
+    - asdf-standard-1.6.0
+    - |
+        !<tag:sunpy.org:sunpy/coordinates/frames/helioprojectiveradial-1.1.0>
+          frame_attributes:
+            observer: !<tag:sunpy.org:sunpy/coordinates/frames/heliographic_stonyhurst-1.2.0>
+              data: !<tag:astropy.org:astropy/coordinates/representation-1.1.0>
+                components:
+                  distance: !unit/quantity-1.2.0 {unit: !unit/unit-1.0.0 AU, value: 0.9833554225272192}
+                  lat: !<tag:astropy.org:astropy/coordinates/latitude-1.1.0> {unit: !unit/unit-1.0.0 deg,
+                    value: -2.982157245087939}
+                  lon: !<tag:astropy.org:astropy/coordinates/longitude-1.1.0>
+                    unit: !unit/unit-1.0.0 deg
+                    value: 0.0
+                    wrap_angle: !<tag:astropy.org:astropy/coordinates/angle-1.1.0> {unit: !unit/unit-1.0.0 deg,
+                      value: 180.0}
+                type: SphericalRepresentation
+              frame_attributes:
+                obstime: !time/time-1.2.0 '2011-01-01T01:01:01.000'
+                rsun: !unit/quantity-1.2.0 {unit: !unit/unit-1.0.0 km, value: 695700.0}
+            obstime: !time/time-1.2.0 '2011-01-01T01:01:01.000'
+            rsun: !unit/quantity-1.2.0 {unit: !unit/unit-1.0.0 km, value: 695700.0}
+
+allOf:
+  - $ref: "http://astropy.org/schemas/astropy/coordinates/frames/baseframe-1.1.0"
+  - properties:
+      frame_attributes:
+        type: object
+        properties:
+          observer:
+            anyOf:
+              - tag: "tag:sunpy.org:sunpy/coordinates/frames/heliographic_stonyhurst-1.*"
+              - type:
+                  string
+          rsun:
+            tag: "tag:stsci.edu:asdf/unit/quantity-1.*"
+          obstime:
+            tag: "tag:stsci.edu:asdf/time/time-1.*"
+
+        required: [rsun]
+        additionalProperties: False
+...
diff --git a/sunpy/map/header_helper.py b/sunpy/map/header_helper.py
index fb86c0c90..2f7c9322c 100644
--- a/sunpy/map/header_helper.py
+++ b/sunpy/map/header_helper.py
@@ -8,7 +8,7 @@ from sunpy import log
 from sunpy.coordinates import frames, sun
 from sunpy.util import MetaDict
 
-__all__ = ['make_fitswcs_header', 'get_observer_meta', 'make_heliographic_header']
+__all__ = ['make_fitswcs_header', 'get_observer_meta', 'make_heliographic_header', 'make_hpr_header']
 
 
 @u.quantity_input(equivalencies=u.spectral())
@@ -391,3 +391,97 @@ def make_heliographic_header(date, observer_coordinate, shape, *, frame, project
 
     header = make_fitswcs_header(shape, frame_out, scale=scale, projection_code=projection_code)
     return header
+
+
+@u.quantity_input
+def make_hpr_header(observer_coordinate, shape, theta_binsize: u.Quantity[u.arcsec], *,
+                    theta_min: u.Quantity[u.arcsec] = 0.0*u.arcsec,
+                    psi_center: u.Quantity[u.deg] = 180.0*u.deg):
+    """
+    Construct a FITS-WCS header for a helioprojective radial coordinate frame.
+
+    The header uses the plate carrée projection ("CAR" in FITS-WCS) with the
+    position angle (``psi``) on the horizontal axis and impact angle (``theta``) on
+    the vertical axis.  To accommodate the FITS-WCS machinery, the vertical axis is
+    actually the declination (``delta``), which is the impact angle minus 90 degrees.
+
+    Parameters
+    ----------
+    observer_coordinate :
+        Observer coordinate, with corresponding observation time.
+    shape : [int, int]
+        Output map shape, number of pixels in (theta, psi).
+    theta_binsize : `~astropy.units.Quantity`
+        The size of each pixel in the impact-angle direction.
+    theta_min : `~astropy.units.Quantity`
+        The minimum impact angle.  Defaults to 0 arcsec.
+    psi_center : `~astropy.units.Quantity`
+        The center of the map in position angle.  Defaults to 180 degrees.
+
+    Returns
+    -------
+    `~sunpy.util.MetaDict`
+
+    See Also
+    --------
+    sunpy.map.header_helper.make_fitswcs_header : A more generic header helper that can be used if more customisation is required.
+    sunpy.coordinates.HelioprojectiveRadial : The coordinate-frame class
+
+    Examples
+    --------
+    >>> import astropy.units as u
+    >>> from sunpy.coordinates import get_earth
+
+    >>> observer = get_earth('2024-09-16 01:02:03')
+    >>> make_hpr_header(observer, (180, 360), theta_binsize=10*u.arcsec)
+    MetaDict([('wcsaxes': '2')
+    ('crpix1': '180.5')
+    ('crpix2': '32400.5')
+    ('cdelt1': '1.0')
+    ('cdelt2': '10.0')
+    ('cunit1': 'deg')
+    ('cunit2': 'arcsec')
+    ('ctype1': 'HRLN-CAR')
+    ('ctype2': 'HRLT-CAR')
+    ('crval1': '180.0')
+    ('crval2': '0.0')
+    ('lonpole': '0.0')
+    ('latpole': '90.0')
+    ('mjdref': '0.0')
+    ('date-obs': '2024-09-16T01:02:03.000')
+    ('rsun_ref': '695700000.0')
+    ('dsun_obs': '150399816551.15')
+    ('hgln_obs': '0.0')
+    ('hglt_obs': '7.1884873184853')
+    ('naxis': '2')
+    ('naxis1': '360')
+    ('naxis2': '180')
+    ('pc1_1': '1.0')
+    ('pc1_2': '-0.0')
+    ('pc2_1': '0.0')
+    ('pc2_2': '1.0')
+    ('rsun_obs': '954.1164393469891')])
+
+    .. minigallery:: sunpy.map.make_hpr_header
+    """
+    # The reference coord must be on the equator (delta = 0 deg)
+    reference_coord = SkyCoord(
+        psi_center,
+        0*u.deg,
+        frame="helioprojectiveradial",
+        obstime=observer_coordinate.obstime,
+        observer=observer_coordinate,
+        rsun=getattr(observer_coordinate, "rsun", None),
+    )
+
+    # Calculate the correct location for this reference coord, which will likely be far off the data array
+    reference_pixel = [
+        shape[1] / 2. - 0.5,
+        ((90*u.deg - theta_min) / theta_binsize).to_value(u.one) - 0.5
+    ] * u.pix
+
+    scale = u.Quantity([360*u.deg / int(shape[1]), theta_binsize]) / u.pix
+
+    header = make_fitswcs_header(shape, reference_coord, reference_pixel=reference_pixel,
+                                 scale=scale, projection_code="CAR")
+    return header
diff --git a/sunpy/visualization/visualization.py b/sunpy/visualization/visualization.py
index ff2d495de..b2835e948 100644
--- a/sunpy/visualization/visualization.py
+++ b/sunpy/visualization/visualization.py
@@ -5,7 +5,10 @@ from functools import wraps
 
 import matplotlib.pyplot as plt
 
-__all__ = ['peek_show', "axis_labels_from_ctype"]
+import astropy.units as u
+from astropy.visualization.wcsaxes import CoordinateHelper
+
+__all__ = ['peek_show', "axis_labels_from_ctype", "show_hpr_impact_angle"]
 
 
 def peek_show(func):
@@ -45,11 +48,13 @@ def axis_labels_from_ctype(ctype, unit):
     labels = {'HGLN': 'Heliographic Longitude',
               'CRLN': 'Carrington Longitude',
               'HPLN': 'Helioprojective Longitude (Solar-X)',
+              'HRLN': 'Helioprojective Position Angle',
               'SOLX': 'Heliocentric X',
 
               'HGLT': 'Latitude',
               'CRLT': 'Latitude',
               'HPLT': 'Helioprojective Latitude (Solar-Y)',
+              'HRLT': 'Helioprojective Declination',
               'SOLY': 'Heliocentric Y'}
 
     label = labels.get(ctype_short, f"{ctype}")
@@ -57,3 +62,38 @@ def axis_labels_from_ctype(ctype, unit):
         label += f' [{unit}]'
 
     return label
+
+
+def show_hpr_impact_angle(declination_axis):
+    """
+    Modify a declination axis for Helioprojective Radial plots to show impact angle
+    instead.
+
+    In order to accommodate the FITS-WCS machinery, the declination is used instead
+    of the impact angle (which is the declination plus 90 degrees).  This function
+    changes the tick-label formatter for an axis so that it adds 90 degrees before
+    rendering the labels.
+
+    Parameters
+    ----------
+    coordinate_axis : `astropy.visualization.wcsaxes.CoordinateHelper`
+        The coordinate axis for Helioprojective Radial declination (``delta``)
+
+    See Also
+    --------
+    sunpy.coordinates.HelioprojectiveRadial
+
+    Examples
+    --------
+    .. minigallery:: sunpy.visualization.show_hpr_impact_angle
+    """
+    if not isinstance(declination_axis, CoordinateHelper):
+        raise TypeError("The input should be one of the WCSAxes coordinate axes.")
+
+    old_format_method = declination_axis.formatter
+
+    def new_format_method(values, spacing, format="auto"):
+        return old_format_method(values + 90*u.deg, spacing, format=format)
+    # TODO: Astropy >= 7.0 use declination_axis.set_major_formatter(new_format_method)
+    declination_axis._formatter_locator.formatter = new_format_method
+    declination_axis.set_axislabel('Helioprojective Impact Angle')


Test patch:
diff --git a/sunpy/coordinates/tests/test_frames.py b/sunpy/coordinates/tests/test_frames.py
index fd59cd5f7..e7d79a7b6 100644
--- a/sunpy/coordinates/tests/test_frames.py
+++ b/sunpy/coordinates/tests/test_frames.py
@@ -21,6 +21,7 @@ from sunpy.coordinates.frames import (
     HeliographicCarrington,
     HeliographicStonyhurst,
     Helioprojective,
+    HelioprojectiveRadial,
 )
 from sunpy.coordinates.sun import angular_radius
 from sunpy.time import parse_time
@@ -269,6 +270,64 @@ def test_hpc_is_visible_tolerance():
     assert hpc.is_visible(tolerance=1*u.m)
 
 
+# ==============================================================================
+# Helioprojective Radial Tests
+# ==============================================================================
+
+@pytest.mark.parametrize(('args', 'kwargs'),
+                         two_D_parameters + [(None, {'psi': 0 * u.deg,
+                                                     'delta': 0 * u.arcsec})])
+def test_create_hpr_2d(args, kwargs):
+    hpr1 = init_frame(HelioprojectiveRadial, args, kwargs)
+
+    assert isinstance(hpr1, HelioprojectiveRadial)
+    assert isinstance(hpr1._data, UnitSphericalRepresentation)
+
+    assert hpr1.psi.unit is u.deg
+    assert hpr1.delta.unit is u.deg
+    assert_quantity_allclose(hpr1.psi, 0*u.deg)
+    assert_quantity_allclose(hpr1.delta, 0*u.deg)
+
+    assert_quantity_allclose(hpr1.theta, 90*u.deg)
+
+
+@pytest.mark.parametrize(
+    ('args', 'kwargs'),
+    three_D_parameters + [(None, {'psi': 0 * u.deg,
+                                  'delta': 0 * u.arcsec,
+                                  'distance': 1 * u.Mm}),
+                          ([0 * u.deg, 0 * u.arcsec], {'distance': 1 * u.Mm})])
+def test_create_hpr_3d(args, kwargs):
+    hpr1 = init_frame(HelioprojectiveRadial, args, kwargs)
+
+    assert isinstance(hpr1, HelioprojectiveRadial)
+    assert isinstance(hpr1._data, SphericalRepresentation)
+
+    assert hpr1.psi.unit is u.deg
+    assert hpr1.delta.unit is u.deg
+    assert hpr1.distance.unit is u.Mm
+    assert_quantity_allclose(hpr1.psi, 0*u.deg)
+    assert_quantity_allclose(hpr1.delta, 0*u.deg)
+    assert_quantity_allclose(hpr1.distance, 1*u.Mm)
+
+    assert_quantity_allclose(hpr1.theta, 90*u.deg)
+
+    # Since hpr1 is already 3D, make_3d() should simply return the original object
+    hpr2 = hpr1.make_3d()
+    assert hpr2 is hpr1
+
+
+def test_hpr_distance():
+    hpr1 = HelioprojectiveRadial(0*u.deg, -90*u.deg,
+                                 observer=HeliographicStonyhurst(0*u.deg, 0*u.deg, 1*u.AU))
+
+    hpr2 = hpr1.make_3d()
+
+    assert_quantity_allclose(hpr2.psi, 0*u.deg)
+    assert_quantity_allclose(hpr2.delta, -90*u.deg)
+    assert_quantity_allclose(hpr2.distance, DSUN_METERS - RSUN_METERS)
+
+
 # ==============================================================================
 # ## Heliographic Tests
 # ==============================================================================
diff --git a/sunpy/coordinates/tests/test_transformations.py b/sunpy/coordinates/tests/test_transformations.py
index 135d6f775..adfe2f055 100644
--- a/sunpy/coordinates/tests/test_transformations.py
+++ b/sunpy/coordinates/tests/test_transformations.py
@@ -33,6 +33,7 @@ from sunpy.coordinates import (
     HeliographicCarrington,
     HeliographicStonyhurst,
     Helioprojective,
+    HelioprojectiveRadial,
     SolarMagnetic,
     SphericalScreen,
     propagate_with_solar_surface,
@@ -121,6 +122,25 @@ def test_hpc_hpc_null():
     assert hpc_out.observer == hpc_new.observer
 
 
+@pytest.mark.parametrize(('Tx', 'Ty', 'psi', 'delta', 'theta'),
+                         [(0*u.arcsec, 0*u.arcsec, 0*u.deg, -90*u.deg, 0*u.arcsec),
+                          (360*u.arcsec, 0*u.arcsec, 270*u.deg, -89.9*u.deg, 360*u.arcsec),
+                          (0*u.arcsec, -720*u.arcsec, 180*u.deg, -89.8*u.deg, 720*u.arcsec),
+                          (360*u.arcsec, -720*u.arcsec, 206.564946*u.deg, -89.776393*u.deg, 804.984145*u.arcsec)])
+def test_hpc_hpr(Tx, Ty, psi, delta, theta):
+    observer = HeliographicStonyhurst(0*u.deg, 0*u.deg, radius=1*u.AU)
+    hpc = Helioprojective(Tx, Ty, observer=observer)
+
+    hpr = hpc.transform_to(HelioprojectiveRadial(observer=observer))
+    assert_quantity_allclose(hpr.psi, psi)
+    assert_quantity_allclose(hpr.delta, delta)
+    assert_quantity_allclose(hpr.theta, theta)
+
+    hpc_back = hpr.transform_to(hpc)
+    assert_quantity_allclose(hpc_back.Tx, Tx)
+    assert_quantity_allclose(hpc_back.Ty, Ty)
+
+
 def test_hpc_hpc_spherical_screen():
     D0 = 20*u.R_sun
     L0 = 67.5*u.deg
diff --git a/sunpy/map/tests/test_header_helper.py b/sunpy/map/tests/test_header_helper.py
index cca798643..79edc6d3b 100644
--- a/sunpy/map/tests/test_header_helper.py
+++ b/sunpy/map/tests/test_header_helper.py
@@ -5,12 +5,13 @@ import pytest
 
 import astropy.units as u
 from astropy.coordinates import SkyCoord
+from astropy.time import Time
 from astropy.wcs import WCS
 
 import sunpy.map
 from sunpy.coordinates import frames, sun
 from sunpy.map import make_fitswcs_header
-from sunpy.map.header_helper import make_heliographic_header
+from sunpy.map.header_helper import make_heliographic_header, make_hpr_header
 from sunpy.util.metadata import MetaDict
 
 
@@ -305,3 +306,18 @@ def test_make_heliographic_header_invalid_inputs(aia171_test_map):
     header_test_below = make_heliographic_header(aia171_test_map.date, aia171_test_map.observer_coordinate, [90, 180], frame='carrington', map_center_longitude=-1*u.deg)
     assert header_test_above['crval1'] == 1.0
     assert header_test_below['crval1'] == 359.0
+
+
+def test_make_hpr_header(hgs_coord):
+    header = make_hpr_header(hgs_coord, (100, 720), theta_binsize=0.1*u.deg)
+    assert header['ctype1'] == 'HRLN-CAR'
+    assert header['ctype2'] == 'HRLT-CAR'
+    assert header['naxis1'] == 720
+    assert header['naxis2'] == 100
+    assert header['crpix1'] == 360.5
+    assert header['crpix2'] == 900.5
+    assert header['crval1'] == 180.0
+    assert header['crval2'] == 0.0
+    assert header['hgln_obs'] == -50.0
+    assert header['hglt_obs'] == 50.0
+    assert Time(header['date-obs']) == hgs_coord.obstime
diff --git a/sunpy/net/dataretriever/sources/tests/test_goes_suvi.py b/sunpy/net/dataretriever/sources/tests/test_goes_suvi.py
index 796dcfd32..4f192442b 100644
--- a/sunpy/net/dataretriever/sources/tests/test_goes_suvi.py
+++ b/sunpy/net/dataretriever/sources/tests/test_goes_suvi.py
@@ -1,13 +1,9 @@
-import tempfile
-
 import pytest
-from hypothesis import given
+from hypothesis import given, settings
 
 import astropy.units as u
-from astropy.time import Time
 
 import sunpy.net.dataretriever.sources.goes as goes
-from sunpy.net import Fido
 from sunpy.net import attrs as a
 from sunpy.net.dataretriever.client import QueryResponse
 from sunpy.net.tests.strategies import time_attr
@@ -19,208 +15,94 @@ def suvi_client():
     return goes.SUVIClient()
 
 
+@settings(max_examples=5)
 @given(time_attr())
 def test_can_handle_query(time):
     # Don't use the fixture, as hypothesis complains
     suvi_client = goes.SUVIClient()
-    ans1 = suvi_client._can_handle_query(time, a.Instrument.suvi)
-    assert ans1 is True
-    ans2 = suvi_client._can_handle_query(time, a.Instrument.suvi,
-                                         a.Wavelength(131 * u.Angstrom))
-    assert ans2 is True
-    ans3 = suvi_client._can_handle_query(time, a.Instrument.suvi,
-                                         a.Wavelength(131 * u.Angstrom),
-                                         a.Level.two)
-    assert ans3 is True
-    ans4 = suvi_client._can_handle_query(time)
-    assert ans4 is False
-    ans5 = suvi_client._can_handle_query(time, a.Instrument.aia)
-    assert ans5 is False
-    ans6 = suvi_client._can_handle_query(time, a.Instrument.suvi,
-                                         a.goes.SatelliteNumber(16))
-    assert ans6 is True
-
-
-@pytest.mark.remote_data
-def test_search_different_units(suvi_client):
-    result_ang = suvi_client.search(a.Time("2024-05-08 00:00:00.000", "2024-05-08 00:04:00.000 "),
-                         a.Instrument.suvi,
-                         a.Wavelength(171* u.AA),
-                         a.Level('2'))[0]
-    result_nm = suvi_client.search(a.Time("2024-05-08 00:00:00.000", "2024-05-08 00:04:00.000 "),
-                         a.Instrument.suvi,
-                         a.Wavelength(17.1* u.nm),
-                         a.Level('2'))[0]
-    assert len(result_ang) == len(result_nm)
+    assert suvi_client._can_handle_query(time, a.Instrument.suvi) is True
+    assert suvi_client._can_handle_query(time, a.Instrument.suvi, a.Wavelength(131 * u.Angstrom)) is True
+    assert suvi_client._can_handle_query(time) is False
+    assert suvi_client._can_handle_query(time, a.Instrument.aia) is False
+    assert suvi_client._can_handle_query(time, a.Instrument.suvi, a.goes.SatelliteNumber(16))
 
 
 def mock_query_object(suvi_client):
-    """
-    Creating a Query Response object and prefilling it with some information
-    """
-    # Creating a Query Response Object
-    start = '2019/05/25 00:50'
-    end = '2019/05/25 00:52'
-    wave = 94 * u.Angstrom
     obj = {
-        'Start Time': parse_time(start),
-        'End Time': parse_time(end),
+        'Start Time': parse_time('2019/05/25 00:50'),
+        'End Time': parse_time('2019/05/25 00:52'),
         'Instrument': 'SUVI',
         'Physobs': 'flux',
         'Source': 'GOES',
         'Provider': 'NOAA',
         'Level': '2',
-        'Wavelength': wave,
-        'url': ('https://data.ngdc.noaa.gov/platforms/solar-space-observing-satellites'
-                '/goes/goes16/l2/data/suvi-l2-ci094/2019/05/25/'
-                'dr_suvi-l2-ci094_g16_s20190525T005200Z_e20190525T005600Z_v1-0-0.fits')
+        'Wavelength': 94 * u.Angstrom,
+        'url': 'https://mock.url/suvi-l2-ci094.fits'
     }
-    results = QueryResponse([obj], client=suvi_client)
-    return results
-
-
-def test_attr_reg():
-    a.Instrument.suvi = a.Instrument("SUVI")
-    a.goes.SatelliteNumber.A16 = a.goes.SatelliteNumber("16")
-
-
-@pytest.mark.remote_data
-def test_fetch_working(suvi_client):
-    """
-    Tests if the online server for goes_suvi is working.
-    This also checks if the mock is working well.
-    """
-    start = '2019/05/25 00:50'
-    end = '2019/05/25 00:52'
-    wave = 94 * u.Angstrom
-    goes_sat = a.goes.SatelliteNumber.sixteen
-    tr = a.Time(start, end)
-    qr1 = suvi_client.search(tr, a.Instrument.suvi, a.Wavelength(wave), goes_sat, a.Level(2))
-
-    # Mock QueryResponse object
-    mock_qr = mock_query_object(suvi_client)
-
-    # Compare if two objects have the same attribute
-
-    mock_qr = mock_qr[0]
-    qr = qr1[0]
-
-    assert mock_qr['Source'] == qr['Source']
-    assert mock_qr['Provider'] == qr['Provider']
-    assert mock_qr['Physobs'] == qr['Physobs']
-    assert mock_qr['Instrument'] == qr['Instrument']
-    assert mock_qr['url'] == qr['url']
-
-    assert qr1['Start Time'] == Time("2019-05-25T00:52:00.000")
-    assert qr1['End Time'] == Time("2019-05-25T00:56:00.000")
-
-    with tempfile.TemporaryDirectory() as tmpdirname:
-        download_list = suvi_client.fetch(qr1, path=tmpdirname)
-    assert len(download_list) == len(qr1)
-
-
-@pytest.mark.remote_data
-@pytest.mark.parametrize(('start', 'end', 'wave', 'expected_num_files'),
-                         [('2019/05/25 00:50', '2019/05/25 00:52', 94, 1),
-                          ('2019/05/25 00:50', '2019/05/25 00:52', 131, 1),
-                          ('2019/05/25 00:50', '2019/05/25 00:52', 171, 1),
-                          ('2019/05/25 00:50', '2019/05/25 00:52', 195, 1),
-                          ('2019/05/25 00:50', '2019/05/25 00:52', 284, 1),
-                          ('2019/05/25 00:50', '2019/05/25 00:52', 304, 1)]
-                         )
-def test_get_url_for_time_range_level2(suvi_client, start, end, wave, expected_num_files):
-    goes_sat = a.goes.SatelliteNumber.sixteen
-    qresponse = suvi_client.search(a.Time(start, end), a.Wavelength(wave * u.Angstrom), goes_sat, a.Level(2))
-    urls = [i['url'] for i in qresponse]
-    assert isinstance(urls, list)
-    assert len(urls) == expected_num_files
+    return QueryResponse([obj], client=suvi_client)
 
 
 @pytest.mark.remote_data
-@pytest.mark.parametrize(('start', 'end', 'expected_num_files'),
-                         [('2019/05/25 00:50', '2019/05/25 00:52', 6)]
-                         )
-def test_get_url_for_time_range_level2_allwave(suvi_client, start, end, expected_num_files):
-    """check that we get all wavelengths if no wavelength is given"""
+@pytest.mark.parametrize(('start', 'end', 'wave', 'level', 'expected_num_files'), [
+    ('2019/05/25 00:50', '2019/05/25 00:54', 94, '1b', 6),
+    ('2019/05/25 00:50', '2019/05/25 00:54', 304, '2', 1),
+])
+def test_combined_search(suvi_client, start, end, wave, level, expected_num_files):
     goes_sat = a.goes.SatelliteNumber.sixteen
-    qresponse = suvi_client.search(a.Time(start, end), goes_sat, a.Level(2))
-    urls = [i['url'] for i in qresponse]
-    assert isinstance(urls, list)
-    assert len(urls) == expected_num_files
+    qresponse = suvi_client.search(a.Time(start, end), a.Wavelength(wave * u.Angstrom), goes_sat, a.Level(level))
+    assert len(qresponse) == expected_num_files
 
 
 @pytest.mark.remote_data
-@pytest.mark.parametrize(('start', 'end', 'wave', 'expected_num_files'),
-                         [('2019/05/25 00:50', '2019/05/25 00:54', 94, 6),
-                          ('2019/05/25 00:50', '2019/05/25 00:54', 131, 3),
-                          ('2019/05/25 00:50', '2019/05/25 00:54', 171, 2),
-                          ('2019/05/25 00:50', '2019/05/25 00:54', 195, 7),
-                          ('2019/05/25 00:50', '2019/05/25 00:54', 284, 2),
-                          ('2019/05/25 00:50', '2019/05/25 00:54', 304, 4)]
-                         )
-def test_get_url_for_time_range_level1b(suvi_client, start, end, wave, expected_num_files):
-    """check that we get all wavelengths if no wavelength is given"""
-    goes_sat = a.goes.SatelliteNumber.sixteen
-    qresponse = suvi_client.search(a.Time(start, end), a.Wavelength(
-        wave * u.Angstrom), goes_sat, a.Level('1b'))
-    urls = [i['url'] for i in qresponse]
-    assert isinstance(urls, list)
-    assert len(urls) == expected_num_files
+def test_get_all_wavelengths_level2(suvi_client):
+    """Check retrieval for all wavelengths without specifying one."""
+    qresponse = suvi_client.search(a.Time('2019/05/25 00:50', '2019/05/25 00:52'),
+                                   a.goes.SatelliteNumber.sixteen, a.Level(2))
+    assert len(qresponse) == 6
+    wavelengths = [w.value for w in qresponse['Wavelength']]
+    expected_wavelengths = {94, 131, 171, 195, 284, 304}
+    assert set(wavelengths) == expected_wavelengths
 
 
 @pytest.mark.remote_data
-@pytest.mark.parametrize(('start', 'end', 'wave', 'expected_num_files'),
-                         [('2019/05/25 00:50', '2019/05/25 00:54', 94, 6),
-                          ('2019/05/25 00:50', '2019/05/25 00:54', 131, 3),
-                          ('2019/05/25 00:50', '2019/05/25 00:54', 171, 2),
-                          ('2019/05/25 00:50', '2019/05/25 00:54', 195, 7),
-                          ('2019/05/25 00:50', '2019/05/25 00:54', 284, 2),
-                          ('2019/05/25 00:50', '2019/05/25 00:54', 304, 4)]
-                         )
-def test_fido_onewave_level1b(start, end, wave, expected_num_files):
+def test_fetch_real_file(suvi_client, tmp_path):
+    start = '2019/05/25 00:50'
+    end = '2019/05/25 00:52'
+    wave = 94 * u.Angstrom
     goes_sat = a.goes.SatelliteNumber.sixteen
-    result = Fido.search(a.Time(start, end), a.Instrument.suvi, goes_sat,
-                         a.Wavelength(wave * u.Angstrom), a.Level('1b'))
-    assert result.file_num == expected_num_files
+    tr = a.Time(start, end)
+    download_list = suvi_client.fetch(
+        suvi_client.search(tr, a.Instrument.suvi, a.Wavelength(wave), goes_sat, a.Level(2)),
+        path=str(tmp_path)
+    )
+    assert len(download_list) == len(
+        suvi_client.search(tr, a.Instrument.suvi, a.Wavelength(wave), goes_sat, a.Level(2))
+    )
 
 
 @pytest.mark.remote_data
-@pytest.mark.parametrize(('start', 'end', 'wave1', 'wave2', 'expected_num_files'),
-                         [('2019/05/25 00:50', '2019/05/25 00:54', 1, 100, 6),
-                          ('2019/05/25 00:50', '2019/05/25 00:54', 1, 150, 9),
-                          ('2019/05/25 00:50', '2019/05/25 00:54', 1, 180, 11),
-                          ('2019/05/25 00:50', '2019/05/25 00:54', 1, 200, 18),
-                          ('2019/05/25 00:50', '2019/05/25 00:54', 1, 300, 20),
-                          ('2019/05/25 00:50', '2019/05/25 00:54', 1, 310, 24)]
-                         )
-def test_fido_waverange_level1b(start, end, wave1, wave2, expected_num_files):
-    """check that we get all wavelengths if no wavelength is given"""
+def test_fetch_mocked_file(suvi_client, mocker):
+    start = '2019/05/25 00:50'
+    end = '2019/05/25 00:52'
+    wave = 94 * u.Angstrom
     goes_sat = a.goes.SatelliteNumber.sixteen
-    result = Fido.search(a.Time(start, end), a.Instrument.suvi, goes_sat,
-                         a.Wavelength(wave1 * u.Angstrom, wave2 * u.Angstrom),
-                         a.Level('1b'))
-    assert result.file_num == expected_num_files
-
+    tr = a.Time(start, end)
+    mocker.patch.object(suvi_client, 'search', return_value=mock_query_object(suvi_client))
+    mocker.patch.object(suvi_client, 'fetch', return_value=['mockfile.fits'])
+    download_list = suvi_client.fetch(
+        suvi_client.search(tr, a.Instrument.suvi, a.Wavelength(wave), goes_sat, a.Level(2))
+    )
+    assert download_list == ['mockfile.fits']
+    assert len(download_list) == len(
+        suvi_client.search(tr, a.Instrument.suvi, a.Wavelength(wave), goes_sat, a.Level(2))
+    )
 
-@pytest.mark.remote_data
-@pytest.mark.parametrize(('start', 'end', 'expected_num_files'),
-                         [('2019/05/25 00:50', '2019/05/25 00:52', 6)]
-                         )
-def test_query(suvi_client, start, end, expected_num_files):
-    goes_sat = a.goes.SatelliteNumber.sixteen
-    qr1 = suvi_client.search(a.Time(start, end), a.Instrument.suvi, goes_sat, a.Level.two)
-    assert isinstance(qr1, QueryResponse)
-    assert len(qr1) == expected_num_files
-    assert qr1['Start Time'][0] == parse_time('2019/05/25 00:52')
-    assert qr1['End Time'][1] == parse_time('2019/05/25 00:56')
+def test_attr_reg():
+    a.Instrument.suvi = a.Instrument("SUVI")
+    a.goes.SatelliteNumber.A16 = a.goes.SatelliteNumber("16")
 
 
 def test_show(suvi_client):
     mock_qr = mock_query_object(suvi_client)
-    qrshow0 = mock_qr.show()
-    qrshow1 = mock_qr.show('Start Time', 'Instrument')
-    allcols = {'Start Time', 'End Time', 'Instrument', 'Physobs', 'Source',
-               'Provider', 'Level', 'Wavelength', 'url'}
-    assert not allcols.difference(qrshow0.colnames)
-    assert qrshow1.colnames == ['Start Time', 'Instrument']
-    assert qrshow0['Instrument'][0] == 'SUVI'
+    qrshow = mock_qr.show()
+    assert {'Start Time', 'End Time', 'Instrument', 'Physobs', 'Source', 'Provider', 'Level', 'Wavelength', 'url'}.issubset(set(qrshow.colnames))
diff --git a/sunpy/tests/figure_hashes_mpl_382_ft_261_astropy_600_animators_111.json b/sunpy/tests/figure_hashes_mpl_382_ft_261_astropy_600_animators_111.json
index d13341fbc..a65fdc5c5 100644
--- a/sunpy/tests/figure_hashes_mpl_382_ft_261_astropy_600_animators_111.json
+++ b/sunpy/tests/figure_hashes_mpl_382_ft_261_astropy_600_animators_111.json
@@ -72,5 +72,6 @@
   "sunpy.visualization.colormaps.tests.test_cm.test_cmap_visual": "fbbd93e66471a92f042543226f7e091f3c3b7f6079bbf418863019e63aaa4db9",
   "sunpy.visualization.tests.test_drawing.test_draw_equator_aia171": "abc52d5e8254b32ed42942926a4ee92d7b0aaf441bfe8c54cdcd98d6a7a62eba",
   "sunpy.visualization.tests.test_drawing.test_draw_prime_meridian_aia171": "33310a8ac04edf54fd1b219c81e22aadee046aa71f594c44a855d23fe4afb6f8",
-  "sunpy.visualization.tests.test_drawing.test_heliographic_equator_prime_meridian": "3cb195a44e899ef46de7365bd0a0c580b7ea69662bdc560a1bbb1686c2ad48cf"
+  "sunpy.visualization.tests.test_drawing.test_heliographic_equator_prime_meridian": "3cb195a44e899ef46de7365bd0a0c580b7ea69662bdc560a1bbb1686c2ad48cf",
+  "sunpy.visualization.tests.test_visualization.test_show_hpr_impact_angle": "90331c364f08e711bbb6ebd33b671911634630cb6335fa85c40b2eb64ddf44a7"
 }
diff --git a/sunpy/tests/figure_hashes_mpl_dev_ft_261_astropy_dev_animators_dev.json b/sunpy/tests/figure_hashes_mpl_dev_ft_261_astropy_dev_animators_dev.json
index 2f97ea8f4..323552a6d 100644
--- a/sunpy/tests/figure_hashes_mpl_dev_ft_261_astropy_dev_animators_dev.json
+++ b/sunpy/tests/figure_hashes_mpl_dev_ft_261_astropy_dev_animators_dev.json
@@ -72,5 +72,6 @@
   "sunpy.visualization.colormaps.tests.test_cm.test_cmap_visual": "d9d6a951d97a55eed9317daf8dedba1baebc1378b59fccf31fc2f7c896e7de3d",
   "sunpy.visualization.tests.test_drawing.test_draw_equator_aia171": "e10ed71a0bb1ebd65020723e97c290e9ef22fa303af545529117ef808d8d8c80",
   "sunpy.visualization.tests.test_drawing.test_draw_prime_meridian_aia171": "c3cfdafcb5dd168035473c10d3aea0f83af4def909836e6ce1dd07f589ba6ca5",
-  "sunpy.visualization.tests.test_drawing.test_heliographic_equator_prime_meridian": "3cb195a44e899ef46de7365bd0a0c580b7ea69662bdc560a1bbb1686c2ad48cf"
+  "sunpy.visualization.tests.test_drawing.test_heliographic_equator_prime_meridian": "3cb195a44e899ef46de7365bd0a0c580b7ea69662bdc560a1bbb1686c2ad48cf",
+  "sunpy.visualization.tests.test_visualization.test_show_hpr_impact_angle": "43f6c3fea4238064ce503d699cba6457b3eb07ee58ea2a52f43556f42ee7efc8"
 }
diff --git a/sunpy/visualization/tests/test_visualization.py b/sunpy/visualization/tests/test_visualization.py
new file mode 100644
index 000000000..fb7d58b63
--- /dev/null
+++ b/sunpy/visualization/tests/test_visualization.py
@@ -0,0 +1,48 @@
+from matplotlib.figure import Figure
+
+import astropy.units as u
+from astropy.wcs import WCS
+
+from sunpy.tests.helpers import figure_test
+from sunpy.visualization import show_hpr_impact_angle
+
+
+@figure_test
+def test_show_hpr_impact_angle():
+    wcs = WCS({
+        'ctype1': 'HRLN-CAR',
+        'ctype2': 'HRLT-CAR',
+        'cdelt1': 1,
+        'cdelt2': 1,
+        'crpix1': 180.5,
+        'crpix2': 90.5,
+        'crval1': 180,
+        'crval2': 0,
+    })
+
+    fig = Figure(figsize=(9, 4))
+    ax1 = fig.add_subplot(121, projection=wcs)
+    ax1.grid()
+
+    ax1.set_xlim(-0.5, 360 - 0.5)
+    ax1.set_ylim(-0.5, 180 - 0.5)
+    ax1.coords[0].set_ticks(spacing=90*u.deg)
+    ax1.coords[1].set_ticks(spacing=30*u.deg)
+    ax1.set_aspect(2)
+
+    ax1.set_title("Shows declination by default")
+
+    ax2 = fig.add_subplot(122, projection=wcs)
+    ax2.grid()
+
+    show_hpr_impact_angle(ax2.coords[1])
+
+    ax2.set_xlim(-0.5, 360 - 0.5)
+    ax2.set_ylim(-0.5, 180 - 0.5)
+    ax2.coords[0].set_ticks(spacing=90*u.deg)
+    ax2.coords[1].set_ticks(spacing=30*u.deg)
+    ax2.set_aspect(2)
+
+    ax2.set_title("Changed to show impact angle")
+
+    return fig