Repository: sunpy/sunpy

Issue:
Overhaul how we handle dates/times for maps
### Provide a general description of the issue or problem.

This is spun out from #7606.  I propose an overhaul of how we handle dates/times for maps.  To briefly summarize the FITS standard:

- DATE-OBS is commonly used in older files, and usually (but not always!) refers to the start of the observation
- DATE-BEG refers to the start of the observation
- DATE-END refers to the end of the observation
- DATE-AVG refers to the "average" time of the observation, and the standard is suggestive that (but not explicit) that the observer metadata is coupled to this time.  Also, the standard does not prescribe a specific way to calculate this average.

To briefly summarize our current code, GenericMap has the following properties:

- `.date_start`, which is just DATE-BEG
- `.date_end`, which is just DATE-END
- `.date_average`, which is DATE-AVG (if present), or falls back to the arithmetic mean of DATE-BEG and DATE-END (if present)
- `.date`, which is in order of decreasing priority: DATE-OBS > `.date_average` > `.date_start` > `.date_end` > the current time

Issues:
- The `.wcs` property is instantiated with only one of the possible times, with `.wcs.wcs.dateobs` set to `.date`, and the other times are not preserved.  Also, since the coordinate frame constructed from this WCS uses `.date`, it may not be accurate if DATE-OBS is present but is not the same time as the observer metadata (e.g., DATE-AVG).
- The `.observer_coordinate` property is instantiated with its time as `.date`, and as above, that may not be accurate if DATE-OBS is present but is not the same time as the observer metadata (e.g., DATE-AVG).

Proposed overhaul plan:

1. Re-define `.date_average` to be the reference time for observer metadata.  The new logic would be in order of decreasing priority: DATE-AVG > `.date`.  We should remove the assumption that the arithmetic mean of DATE-BEG and DATE-END is the correct way to construct the average time.
2. Re-define `.date` to be the "canonical" time to refer to an observation, which is commonly the start of the observation, and would be the time that, e.g., is used in plot titles.  The new priority would be DATE-OBS > `.date_start` > DATE-AVG > `.date_end` > session time (see https://github.com/sunpy/sunpy/issues/5543#issuecomment-932221515).
3. Re-define `.observer_coordinate` so that it uses `.date_average` instead of `.date`
4. Re-define `.wcs` so that it populates the following:
  - `.wcs.wcs.dateobs` with `.date` (note that this will never be `None`)
  - `.wcs.wcs.dateavg` with `.date_average` (note that this will never be `None`)
  - `.wcs.wcs.datebeg` with `.date_start` if not `None`
  - `.wcs.wcs.dateend` with `.date_end` if not `None`
5. Define our WCS->frame mapping so that it prioritizes `.wcs.dateavg` over `.wcs.dateobs`.  This is actually already the case, but I list it for completeness: https://github.com/sunpy/sunpy/blob/558e55e5f2c1f573d10db464d29061b582b8ece2/sunpy/coordinates/wcs_utils.py#L37

Considerations:
1. If we do the above, we might want to change the name of `.date_average`, and potentially of `.date`, to make it more clear and to enable some deprecation.
2. There would be the potential confusion that `.observer_coordinate.date` would be equal to `.date_average`, and potentially different from `.date.`.

Discussion:
Cadair: @ayshih's commit looks good to me, I think this is ready if Albert does.
nabobalis: This should get a whatsnew entry. 

Patch:
diff --git a/.cruft.json b/.cruft.json
index 0378f5628..c556cbccf 100644
--- a/.cruft.json
+++ b/.cruft.json
@@ -1,6 +1,6 @@
 {
   "template": "https://github.com/sunpy/package-template",
-  "commit": "587aad1a92d72e28b93be4bc26b4992b4f7521ae",
+  "commit": "4721cdbdc9577fbf66f5e0f30fd4133a7723aeb9",
   "checkout": null,
   "context": {
     "cookiecutter": {
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9b5541054..8b47d2085 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -107,7 +107,6 @@ jobs:
       toxdeps: tox-pypi-filter
       posargs: -n auto --color=yes
       envs: |
-        - linux: base_deps
         - linux: py312-devdeps
         - linux: core_deps
     secrets:
@@ -207,7 +206,7 @@ jobs:
         github.event_name == 'pull_request' &&
         contains(github.event.pull_request.labels.*.name, 'Run publish')
       )
-    needs: [test, docs]
+    needs: [test, docs, publish]
     uses: OpenAstronomy/github-actions-workflows/.github/workflows/publish.yml@main
     with:
       upload_to_anaconda: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') }}
@@ -216,7 +215,7 @@ jobs:
       anaconda_keep_n_latest: 1
       sdist: false
       test_extras: 'tests-only'
-      # only test ana here to save time as it's the only compiled code
+      # Only test ANA here to save time as it's the only compiled code (FOR NOW)
       test_command: 'pytest -p no:warnings --doctest-rst --pyargs sunpy.io.tests.test_ana'
       submodules: false
       targets: |
diff --git a/changelog/7452.feature.rst b/changelog/7452.feature.rst
new file mode 100644
index 000000000..f73e3e63d
--- /dev/null
+++ b/changelog/7452.feature.rst
@@ -0,0 +1,1 @@
+Added :func:`~sunpy.coordinates.spice.get_rotation_matrix` to obtain the rotation matrix between the orientations of two SPICE frames, which is particularly useful for transforming vector fields.
diff --git a/changelog/7682.breaking.1.rst b/changelog/7682.breaking.1.rst
new file mode 100644
index 000000000..125921234
--- /dev/null
+++ b/changelog/7682.breaking.1.rst
@@ -0,0 +1,1 @@
+The `.EUIMap` class now returns the ``DATE-BEG`` key for `.GenericMap.date` while continuing to use ``DATE-AVG`` as the reference date for the coordinate system.
diff --git a/changelog/7682.breaking.2.rst b/changelog/7682.breaking.2.rst
new file mode 100644
index 000000000..495c47eff
--- /dev/null
+++ b/changelog/7682.breaking.2.rst
@@ -0,0 +1,2 @@
+The `.GenericMap.date` key priority order has changed to be consistent with it representing the "canonical" observation time.
+``DATE-OBS`` continues to have the highest priority, but now ``DATE-BEG`` has higher priority than ``DATE-AVG``.
diff --git a/changelog/7682.breaking.rst b/changelog/7682.breaking.rst
new file mode 100644
index 000000000..2ae924c58
--- /dev/null
+++ b/changelog/7682.breaking.rst
@@ -0,0 +1,2 @@
+A new property `.GenericMap.reference_date` has been added to decouple the reference date for the coordinate system from the "canonical" observation time.
+This new property is now passed through to the map's WCS object as ``dateavg`` and is the time used for `.GenericMap.coordinate_frame` and `.GenericMap.observer_coordinate`.
diff --git a/docs/whatsnew/6.0.rst b/docs/whatsnew/6.0.rst
index 85d5f6119..268e60c6d 100644
--- a/docs/whatsnew/6.0.rst
+++ b/docs/whatsnew/6.0.rst
@@ -57,6 +57,15 @@ The following extra group have been added:
 Additionally, the "all" extra group is a meta group that includes all optional dependencies.
 The "dask" extra group has also been removed.
 
+Decoupling dates in `~sunpy.map.Map`
+====================================
+Previously, `~sunpy.map.Map` used the single `~sunpy.map.GenericMap.date` property to serve two, potentially conflicting purposes: the "canonical" observation time and the reference time for the coordinate system.
+In order to properly support map sources where those two times are not the same, there is a new `~sunpy.map.GenericMap.reference_date` property to serve as the reference time for the coordinate system.
+`~sunpy.map.GenericMap.date` continues to serve as the canonical observation time, which is commonly the start of the observation period.
+See the documentation for the respective properties for the logic of parsing the metadata.
+
+An example of a user-facing impact is that, for certain map sources, the time used by the `~sunpy.map.GenericMap.coordinate_frame` and `~sunpy.map.GenericMap.observer_coordinate` properties can be different from the time printed in the default title of plots.
+
 Removal of ``sunpy.database``
 =============================
 
diff --git a/sunpy/coordinates/spice.py b/sunpy/coordinates/spice.py
index a1f171244..c7514428c 100644
--- a/sunpy/coordinates/spice.py
+++ b/sunpy/coordinates/spice.py
@@ -77,7 +77,7 @@ from sunpy.time import parse_time
 from sunpy.time.time import _variables_for_parse_time_docstring
 from sunpy.util.decorators import add_common_docstring
 
-__all__ = ['SpiceBaseCoordinateFrame', 'get_body', 'get_fov', 'initialize', 'install_frame']
+__all__ = ['SpiceBaseCoordinateFrame', 'get_body', 'get_fov', 'initialize', 'install_frame', 'get_rotation_matrix']
 
 
 # Note that this epoch is very slightly different from the typical definition of J2000.0 (in TT)
@@ -467,3 +467,71 @@ def get_fov(instrument, time, *, resolution=100):
                    obstime=obstime,
                    representation_type='unitspherical')
     return fov
+
+
+@add_common_docstring(**_variables_for_parse_time_docstring())
+def get_rotation_matrix(source_frame, target_frame, from_time, to_time=None):
+    """
+    Get the rotation matrix between the orientations of two SPICE frames.
+
+    Parameters
+    ----------
+    source_frame : `str`
+        The source frame specified by SPICE frame name.
+
+    target_frame : `str`
+        The target frame specified by SPICE frame name.
+
+    from_time : {parse_time_types}
+        The time of the source frame.
+
+    to_time : {parse_time_types}
+        The time of the target frame. Defaults to ``None``, which means
+        ``from_time`` is used.
+
+    Returns
+    -------
+    `~numpy.ndarray`
+        A 3x3 rotation matrix for the change in orientation.
+
+    Examples
+    --------
+    >>> from sunpy.coordinates.spice import get_rotation_matrix
+    >>> source_frame = "J2000"
+    >>> target_frame = "Galactic"
+    >>> from_time = '2001-01-01T00:00:00'
+    >>> rotation_matrix = get_rotation_matrix(source_frame, target_frame, from_time)
+    >>> rotation_matrix
+    array([[-0.05487554, -0.8734371 , -0.48383499],
+           [ 0.49410945, -0.44482959,  0.74698225],
+           [-0.86766614, -0.19807639,  0.45598379]])
+
+    This rotation matrix can be used to re-orient a vector (field), e.g.:
+
+    >>> vec_components = [1, 0, 0] * u.T
+    >>> transformed_matrix = rotation_matrix @ vec_components
+    >>> transformed_matrix
+    <Quantity [-0.05487554, 0.49410945, -0.86766614] T>
+    """
+    source_frame_spice = source_frame.upper()
+    target_frame_spice = target_frame.upper()
+
+    from_time = parse_time(from_time)
+    to_time = parse_time(to_time) if to_time else from_time
+
+    from_time_et = _convert_to_et(from_time)
+    to_time_et = _convert_to_et(to_time)
+
+    # First transformation: from source frame at from_time to J2000
+    from_source_to_j2000 = spiceypy.sxform(source_frame_spice, "J2000", from_time_et)
+
+    # Second transformation: from J2000 at to_time to target frame
+    from_j2000_to_target = spiceypy.sxform("J2000", target_frame_spice, to_time_et)
+
+    # Combine: source -> J2000 -> target
+    combined_transform = spiceypy.mxmg(from_j2000_to_target, from_source_to_j2000)
+
+    # Extract the rotation matrix (upper left 3x3 block)
+    rotation_matrix = combined_transform[:3, :3]
+
+    return rotation_matrix
diff --git a/sunpy/coordinates/wcs_utils.py b/sunpy/coordinates/wcs_utils.py
index c2ffcce22..8d84aeb50 100644
--- a/sunpy/coordinates/wcs_utils.py
+++ b/sunpy/coordinates/wcs_utils.py
@@ -34,7 +34,7 @@ def solar_wcs_frame_mapping(wcs):
     if hasattr(wcs, "coordinate_frame"):
         return wcs.coordinate_frame
 
-    dateobs = wcs.wcs.dateavg or wcs.wcs.dateobs or None
+    dateobs = wcs.wcs.dateavg or wcs.wcs.dateobs or wcs.wcs.datebeg or wcs.wcs.dateend or None
 
     # Get observer coordinate from the WCS auxiliary information
     # Note: the order of the entries is important, as it determines which set
diff --git a/sunpy/map/mapbase.py b/sunpy/map/mapbase.py
index 7b5f7fdfb..db6609641 100644
--- a/sunpy/map/mapbase.py
+++ b/sunpy/map/mapbase.py
@@ -594,6 +594,25 @@ class GenericMap(NDData):
     def wcs(self):
         """
         The `~astropy.wcs.WCS` property of the map.
+
+        Notes
+        -----
+        ``dateobs`` is always populated with the "canonical" observation time as
+        provided by the `.date` property.  This will commonly be the DATE-OBS key if it
+        is in the metadata, but see that property for the logic otherwise.
+
+        ``dateavg`` is always populated with the reference date of the coordinate system
+        as provided by the `.reference_date` property.  This will commonly be the
+        DATE-AVG key if it is in the metadata, but see that property for the logic
+        otherwise.
+
+        ``datebeg`` is conditonally populated with the start of the observation period
+        as provided by the `.date_start` property, which normally returns a value only
+        if the DATE-BEG key is in the metadata.
+
+        ``dateend`` is conditonally populated with the end of the observation period as
+        provided by the `.date_end` property, which normally returns a value only if the
+        DATE-END key is in the metadata.
         """
         w2 = astropy.wcs.WCS(naxis=2)
 
@@ -611,9 +630,15 @@ class GenericMap(NDData):
         # FITS standard doesn't allow both PC_ij *and* CROTA keywords
         w2.wcs.crota = (0, 0)
         w2.wcs.cunit = self.spatial_units
-        w2.wcs.dateobs = self.date.isot
         w2.wcs.aux.rsun_ref = self.rsun_meters.to_value(u.m)
 
+        w2.wcs.dateobs = self.date.utc.isot
+        w2.wcs.dateavg = self.reference_date.utc.isot
+        if self.date_start is not None:
+            w2.wcs.datebeg = self.date_start.utc.isot
+        if self.date_end is not None:
+            w2.wcs.dateend = self.date_end.utc.isot
+
         # Set observer coordinate information except when we know it is not appropriate (e.g., HGS)
         sunpy_frame = sunpy.coordinates.wcs_utils._sunpy_frame_class_from_ctypes(w2.wcs.ctype)
         if sunpy_frame is None or hasattr(sunpy_frame, 'observer'):
@@ -642,6 +667,11 @@ class GenericMap(NDData):
         """
         An `astropy.coordinates.BaseCoordinateFrame` instance created from the coordinate
         information for this Map, or None if the frame cannot be determined.
+
+        Notes
+        -----
+        The ``obstime`` for the coordinate frame uses the `.reference_date` property,
+        which may be different from the `.date` property.
         """
         try:
             return astropy.wcs.utils.wcs_to_celestial_frame(self.wcs)
@@ -861,35 +891,80 @@ class GenericMap(NDData):
             time = time or self._get_date('date_obs')
         return time
 
+    @property
+    def reference_date(self):
+        """
+        The reference date for the coordinate system.
+
+        This date is used to define the ``obstime`` of the coordinate frame and often
+        the ``obstime`` of the observer.  Be aware that this date can be different from
+        the "canonical" observation time (see the `.GenericMap.date` property).
+
+        The reference date is determined using this order of preference:
+
+        1. The ``DATE-AVG`` key in the meta.
+        2. The ``DATE-OBS`` key in the meta.
+        3. The ``DATE-BEG`` key in the meta.
+        4. The ``DATE-END`` key in the meta.
+        5. The `.GenericMap.date` property as a fallback (which, if not
+           overridden, would be the current time if the above keywords are missing).
+
+        See Also
+        --------
+        date : The observation time.
+        date_start : The start time of the observation.
+        date_end : The end time of the observation.
+        date_average : The average time of the observation.
+
+        Notes
+        -----
+        The FITS standard implies that, but does not expressly require, the DATE-AVG keyword
+        to be the reference date.
+        """
+        return (
+            self._get_date('date-avg') or
+            self._get_date('date-obs') or
+            self._get_date('date-beg') or
+            self._get_date('date-end') or
+            self.date
+        )
+
     @property
     def date(self):
         """
-        Image observation time.
+        The observation time.
 
-        For different combinations of map metadata this can return either
-        the start time, end time, or a time between these. It is recommended
-        to use `~sunpy.map.GenericMap.date_average`,
-        `~sunpy.map.GenericMap.date_start`, or `~sunpy.map.GenericMap.date_end`
-        instead if you need one of these specific times.
+        This time is the "canonical" way to refer to an observation, which is commonly
+        the start of the observation, but can be a different time.  In comparison, the
+        `.GenericMap.date_start` property is unambigiously the start of the observation.
 
-        Taken from, in order of preference:
+        The observation time is determined using this order of preference:
 
-        1. The DATE-OBS FITS keyword
-        2. `~sunpy.map.GenericMap.date_average`
-        3. `~sunpy.map.GenericMap.date_start`
-        4. `~sunpy.map.GenericMap.date_end`
+        1. The ``DATE-OBS`` or ``DATE_OBS`` FITS keywords
+        2. `.GenericMap.date_start`
+        3. `.GenericMap.date_average`
+        4. `.GenericMap.date_end`
         5. The current time
-        """
-        time = self._date_obs
-        time = time or self.date_average
-        time = time or self.date_start
-        time = time or self.date_end
+
+        See Also
+        --------
+        reference_date : The reference date for the the coordinate system
+        date_start : The start time of the observation.
+        date_end : The end time of the observation.
+        date_average : The average time of the observation.
+        """
+        time = (
+            self._date_obs or
+            self.date_start or
+            self.date_average or
+            self.date_end
+        )
 
         if time is None:
             if self._default_time is None:
                 warn_metadata("Missing metadata for observation time, "
                               "setting observation time to current time. "
-                              "Set the 'DATE-AVG' FITS keyword to prevent this warning.")
+                              "Set the 'DATE-OBS' FITS keyword to prevent this warning.")
                 self._default_time = parse_time('now')
             time = self._default_time
 
@@ -1176,12 +1251,17 @@ class GenericMap(NDData):
     def observer_coordinate(self):
         """
         The Heliographic Stonyhurst Coordinate of the observer.
+
+        Notes
+        -----
+        The ``obstime`` for this coordinate uses the `.reference_date` property, which
+        may be different from the `.date` property.
         """
         warning_message = []
         for keys, kwargs in self._supported_observer_coordinates:
             missing_keys = set(keys) - self.meta.keys()
             if not missing_keys:
-                sc = SkyCoord(obstime=self.date, **kwargs)
+                sc = SkyCoord(obstime=self.reference_date, **kwargs)
                 # If the observer location is supplied in Carrington coordinates,
                 # the coordinate's `observer` attribute should be set to "self"
                 if isinstance(sc.frame, HeliographicCarrington):
@@ -1211,7 +1291,7 @@ class GenericMap(NDData):
             warning_message = (["Missing metadata for observer: assuming Earth-based observer."]
                                + warning_message + [""])
             warn_metadata("\n".join(warning_message), stacklevel=3)
-            return get_earth(self.date)
+            return get_earth(self.reference_date)
 
     @property
     def heliographic_latitude(self):
diff --git a/sunpy/map/sources/soho.py b/sunpy/map/sources/soho.py
index 1404268cc..346f2dfa6 100644
--- a/sunpy/map/sources/soho.py
+++ b/sunpy/map/sources/soho.py
@@ -196,11 +196,11 @@ class MDIMap(GenericMap):
 
     @property
     def _date_obs(self):
-        if 'T' in self.meta['date-obs']:
+        if 'T' in self.meta.get('date-obs', ''):
             # Helioviewer MDI files have the full date in DATE_OBS, but we still
             # want to let normal FITS files use DATE-OBS
             return parse_time(self.meta['date-obs'])
-        else:
+        elif 'date_obs' in self.meta:
             return parse_time(self.meta['date_obs'])
 
     @property
diff --git a/sunpy/map/sources/solo.py b/sunpy/map/sources/solo.py
index 82e6fa720..5d8187a24 100644
--- a/sunpy/map/sources/solo.py
+++ b/sunpy/map/sources/solo.py
@@ -46,11 +46,6 @@ class EUIMap(GenericMap):
             # The level number is prepended by the letter L
             return int(self.meta.get('level')[1:])
 
-    @property
-    def date(self):
-        # Prioritize date-average over date-obs
-        return self.date_average or super().date
-
     @property
     def waveunit(self):
         # EUI JP2000 files do not have the WAVEUNIT key in the metadata.
diff --git a/tox.ini b/tox.ini
index ff5df5269..a9bed1a61 100644
--- a/tox.ini
+++ b/tox.ini
@@ -8,7 +8,6 @@ envlist =
     py310-oldestdeps
     py312-figure{,-devdeps}
     build_docs{,-gallery}
-    base_deps
     codestyle
     core_deps
 
@@ -134,16 +133,6 @@ commands =
     {posargs}
     python -c 'import pathlib; print("Documentation available under file://\{0\}".format(pathlib.Path(r"{toxinidir}") / "docs" / "_build" / "index.html"))'
 
-[testenv:base_deps]
-description = Check the test suite does not fail if all optional dependencies are missing
-extras =
-deps =
-    astropy
-    numpy
-    parfive[ftp]
-commands =
-    python -c "import sunpy; sunpy.self_test()"
-
 [testenv:codestyle]
 pypi_filter =
 skip_install = true


Test patch:
diff --git a/sunpy/coordinates/tests/test_spice.py b/sunpy/coordinates/tests/test_spice.py
index 55a2e957e..44e64ecc5 100644
--- a/sunpy/coordinates/tests/test_spice.py
+++ b/sunpy/coordinates/tests/test_spice.py
@@ -216,3 +216,16 @@ def test_to_helioprojective(spice_test):
     sunpy_coord = hpc.geocentricsolarecliptic
     assert_quantity_allclose(sunpy_coord.lon, spice_coord.lon, rtol=1e-6)
     assert_quantity_allclose(sunpy_coord.lat, spice_coord.lat, rtol=1e-6)
+
+def test_get_rotation_matrix(spice_test):
+    result1 = spice.get_rotation_matrix('HEEQ', 'J2000', '2024-07-04')
+    expected_result1 = np.array([[ 0.20454304,  0.97118061,  0.12235349],
+                                 [-0.87442558,  0.23746563, -0.42307208],
+                                 [-0.43993415, -0.02045257,  0.8977971]])
+    np.testing.assert_allclose(result1, expected_result1, atol=1e-6)
+
+    result2 = spice.get_rotation_matrix('HEEQ', 'HEEQ', '2024-07-04', '2024-07-18')
+    expected_result2 = np.array([[ 0.97314148,  0.23020788,  0],
+                                 [-0.23020788,  0.97314148, 0],
+                                 [0, 0, 1]])
+    np.testing.assert_allclose(result2, expected_result2, atol=1e-6)
diff --git a/sunpy/map/sources/tests/test_wispr_source.py b/sunpy/map/sources/tests/test_wispr_source.py
index 3a195b603..b8603a225 100644
--- a/sunpy/map/sources/tests/test_wispr_source.py
+++ b/sunpy/map/sources/tests/test_wispr_source.py
@@ -256,7 +256,7 @@ def test_is_datasource_for(wispr_map):
 def test_observer_coordinate(wispr_map):
     obs_coord = wispr_map.observer_coordinate
     assert isinstance(obs_coord, SkyCoord)
-    assert obs_coord.obstime.isot == wispr_map.meta['date-obs']
+    assert obs_coord.obstime.isot == wispr_map.meta['date-avg']
 
 
 def test_observatory(wispr_map):
diff --git a/sunpy/map/tests/test_mapbase.py b/sunpy/map/tests/test_mapbase.py
index 9505567d5..149263f5c 100644
--- a/sunpy/map/tests/test_mapbase.py
+++ b/sunpy/map/tests/test_mapbase.py
@@ -257,8 +257,8 @@ date_begend = date_dict['DATE-BEG'] + (date_dict['DATE-END'] - date_dict['DATE-B
 
 @pytest.mark.parametrize(("keys", "expected_date"),
                          ([['DATE-AVG', 'DATE-OBS', 'DATE-BEG', 'DATE-END'], date_dict['DATE-OBS']],
-                          [['DATE-AVG', 'DATE-BEG', 'DATE-END'], date_dict['DATE-AVG']],
-                          [['DATE-BEG', 'DATE-END'], date_begend],
+                          [['DATE-AVG', 'DATE-BEG', 'DATE-END'], date_dict['DATE-BEG']],
+                          [['DATE-BEG', 'DATE-END'], date_dict['DATE-BEG']],
                           [['DATE-BEG'], date_dict['DATE-BEG']],
                           [['DATE-END'], date_dict['DATE-END']],
                           [[], 'now']
diff --git a/sunpy/net/helio/tests/test_helio.py b/sunpy/net/helio/tests/test_helio.py
index a656af55b..ba2c0cf85 100644
--- a/sunpy/net/helio/tests/test_helio.py
+++ b/sunpy/net/helio/tests/test_helio.py
@@ -19,6 +19,15 @@ from sunpy.util.exceptions import SunpyUserWarning
 # https://github.com/sunpy/sunpy/issues/4401 is fixed
 pytestmark = [pytest.mark.filterwarnings('ignore:Unverified HTTPS request is being made')]
 
+@pytest.fixture(scope="session")
+def client():
+    try:
+        client = HECClient()
+        return client
+    # If no links are found, the client should raise a ValueError
+    except ValueError:
+        pytest.xfail("No HELIO working links found.")
+
 
 def wsdl_endpoints():
     """
@@ -75,7 +84,8 @@ def hec_urls():
 
 
 @pytest.mark.remote_data
-def test_webservice_parser():
+def test_webservice_parser(client):  # NOQA: ARG001
+    # The client is used to check if HELIO is working
     result = webservice_parser()
     assert isinstance(result, list)
 
@@ -259,15 +269,6 @@ def test_ssl_verify_error(mock_webservice, mock_taverna, mock_link, mock_zeep, c
     assert len(query) == 0
     assert "Set the 'NO_VERIFY_HELIO_SSL' environment variable disable SSL verification for Helio." in caplog.text
 
-@pytest.fixture(scope="session")
-def client():
-    try:
-        client = HECClient()
-        return client
-    # If no links are found, the client should raise a ValueError
-    except ValueError:
-        pytest.xfail("No HELIO working links found.")
-
 
 @pytest.mark.remote_data
 def test_get_table_names(client):
diff --git a/sunpy/tests/self_test.py b/sunpy/tests/self_test.py
index c2aa90181..375107509 100644
--- a/sunpy/tests/self_test.py
+++ b/sunpy/tests/self_test.py
@@ -1,4 +1,3 @@
-
 def print_missing_dependencies_report(missing, package="sunpy"):
     printed = False
     required_missing = missing.pop("required")
@@ -49,15 +48,16 @@ def self_test(*, package=None, online=False, online_only=False, figure_only=Fals
     print("Starting sunpy self test...")
     print()
     print("Checking for packages needed to run sunpy:")
-    missing = missing_dependencies_by_extra(exclude_extras=["asdf", "dask", "dev", "all", "docs"])
-    test_missing = missing.pop("tests")
-    printed = print_missing_dependencies_report(missing)
+    missing_deps = missing_dependencies_by_extra(exclude_extras=["asdf", "dask", "dev", "all", "docs"])
+    missing_tests = missing_deps.pop("tests")
+    missing_tests = {**missing_tests, **missing_deps.pop("tests-only")}
+    printed = print_missing_dependencies_report(missing_deps)
     if not printed:
         print("All required and optional sunpy dependencies are installed.")
-    if test_missing:
+    if missing_tests:
         print("You do not have all the required dependencies installed to run the sunpy test suite.")
         print()
-        for dep in test_missing:
+        for dep in missing_tests:
             print(f"  * {dep}")
         print()
         print("If are using conda, you will want to run conda install <package name>")
@@ -66,13 +66,14 @@ def self_test(*, package=None, online=False, online_only=False, figure_only=Fals
         return 1
     print()
     print("Starting the sunpy test suite:")
-    test_args = _self_test_args(package=package, online=online,
-                                online_only=online_only, figure_only=figure_only, verbose=verbose)
+    test_args = _self_test_args(
+        package=package, online=online, online_only=online_only, figure_only=figure_only, verbose=verbose
+    )
+
     import pytest
 
     if verbose:
         print(f"Running pytest with arguments: {test_args}")
-
     return pytest.main(test_args)
 
 
diff --git a/sunpy/timeseries/tests/test_timeseries_factory.py b/sunpy/timeseries/tests/test_timeseries_factory.py
index 3c6c43b23..25016007c 100644
--- a/sunpy/timeseries/tests/test_timeseries_factory.py
+++ b/sunpy/timeseries/tests/test_timeseries_factory.py
@@ -18,6 +18,7 @@ import sunpy.net.attrs as a
 import sunpy.timeseries
 from sunpy.data.test import get_test_data_filenames, get_test_filepath, rootdir
 from sunpy.net import Fido
+from sunpy.tests.helpers import skip_numpy2
 from sunpy.time import parse_time
 from sunpy.util import SunpyUserWarning
 from sunpy.util.datatype_factory_base import NoMatchError
@@ -98,6 +99,7 @@ def test_from_url():
     assert isinstance(ts[1], sunpy.timeseries.GenericTimeSeries)
 
 
+@skip_numpy2
 def test_read_cdf():
     ts_psp = sunpy.timeseries.TimeSeries(psp_filepath)
     assert len(ts_psp) == 2