Repository: simpeg/simpeg (python)

Issue:
BUG: Inconsistency between docstring and result of `simpeg.potential_fields.gravity.survey.Survey.components()`
### Describe the issue:

There is an inconsistency between the docstring of the `components` property of `simpeg.potential_fields.gravity.survey.Survey`, and the output of the function. The relevant lines of code are here:

https://github.com/simpeg/simpeg/blob/2e3689625225955cc338dadad13cb3cdb4310e8b/simpeg/potential_fields/gravity/survey.py#L77-L86

The docstring indicates the method returns the number of components at each receiver. However the function returns instead the list of strings representing the components of the first receiver.

There are a few cases where this causes unintended outputs.

### Unintended output 1: Expect an int, get an array of strings

This is the simplest case. We have 2 components defined for the receiver, so the expected output is 2. However, the list of strings representing the components of the first receiver, i.e., `['gx', 'gz']`.

```python
r = gravity.receivers.Point([0, 0, 0], ["gx", "gz"])

source_field = gravity.sources.SourceField(receiver_list=[r])

survey = gravity.survey.Survey(source_field)

# Inspect the components of the survey object
print(survey.components)

# Returns: ['gx', 'gz']
# Expected: 2
```

### Unintended output 2: Different components for different receivers

Here the same number of receivers are defined for each receiver, but different types of component are defined at each receiver. Here the output is the component(s) of the first receiver. What should the expected result be here though? Should it be 1, as each receiver (in the list) has 1 component? Or should it be 2, as there are 2 components in the receiver list?

```python
from simpeg.potential_fields import gravity

rx = gravity.receivers.Point([0, 0, 0], ["gx"])
rz = gravity.receivers.Point([0, 0, 0], ["gz"])

source_field = gravity.sources.SourceField(receiver_list=[rx, rz])

survey = gravity.survey.Survey(source_field)

# Inspect the components of the survey object
print(survey.components)

# Returns: ['gx']
# Expected: 1 or 2
```

### Unintended output 3: Different numbers of components for each point receiver

One can define different numbers of components for different receivers. Here the value returned is the component(s) of the first receiver. But I think here the expected output is not well defined.

```python
r1 = gravity.receivers.Point([0, 0, 0], ["gx"])
r2 = gravity.receivers.Point([0, 0, 0], ["gx", "gz"])

source_field = gravity.sources.SourceField(receiver_list=[r1, r2])

survey = gravity.survey.Survey(source_field)

# Inspect the components of the survey object
print(survey.components)

# Returns: ['gx']
# Expected: ???
```

## Final thoughts

 - A fix for this will make the docstring and implementation consistent. However, it should be clear what the intended use is.
 - This inconsistency may be present in other survey classes. I haven't had a chance to look yet. I'll update here if I find more.

### Reproducible code example:

```python
from simpeg.potential_fields import gravity

r = gravity.receivers.Point([0, 0, 0], ["gx", "gz"])

source_field = gravity.sources.SourceField(receiver_list=[r])

survey = gravity.survey.Survey(source_field)

# Inspect the components of the survey object
print(survey.components)

# Returns: ['gx', 'gz']
# Expected: 2
```

### Error message:

```shell

```

### Runtime information:

--------------------------------------------------------------------------------
  Date: Thu Feb 20 16:57:00 2025 PST

                OS : Darwin (macOS 14.6.1)
            CPU(s) : 12
           Machine : arm64
      Architecture : 64bit
               RAM : 36.0 GiB
       Environment : Python
       File system : apfs

  Python 3.12.2 | packaged by conda-forge | (main, Feb 16 2024, 20:54:21)
  [Clang 16.0.6 ]

            simpeg : 0.23.0
        discretize : 0.11.1
       pymatsolver : 0.3.1
             numpy : 2.2.2
             scipy : 1.15.1
        matplotlib : 3.10.0
            geoana : 0.7.2
            libdlf : 0.3.0
           sklearn : 1.6.1
            pandas : 2.2.3
             sympy : 1.13.1
           IPython : 8.30.0
        ipywidgets : 8.1.5
            plotly : 5.24.1
               vtk : 9.3.1
--------------------------------------------------------------------------------

### Context for the issue:

_No response_

Pull request:
Deprecate `components` in potential field surveys
#### Summary

Deprecate the `components` properties in potential field surveys. These properties weren't returning the expected output. Since receivers within the same survey can have different components, returning the components of the first receiver is misleading.

#### PR Checklist
* [ ] If this is a work in progress PR, set as a Draft PR
* [ ] Linted my code according to the [style guides](https://docs.simpeg.xyz/latest/content/getting_started/contributing/code-style.html).
* [ ] Added [tests](https://docs.simpeg.xyz/latest/content/getting_started/contributing/testing.html) to verify changes to the code.
* [ ] Added necessary documentation to any new functions/classes following the
      expect [style](https://docs.simpeg.xyz/latest/content/getting_started/contributing/documentation.html).
* [ ] Marked as ready for review (if this is was a draft PR), and converted
      to a Pull Request
* [ ] Tagged ``@simpeg/simpeg-developers`` when ready for review.

#### Reference issue
<!--Example: write "Closes #NNNN" to automatically close that issue on merge.-->

Fixes #1609

#### What does this implement/fix?
<!--Please explain your changes.-->

#### Additional information
<!--Any additional information you think is important.-->


<!--
Once all tests pass and the code has been reviewed and approved, it will be merged into main
-->


Patch:
diff --git a/simpeg/potential_fields/gravity/survey.py b/simpeg/potential_fields/gravity/survey.py
index 2808e2489..447b4b69d 100644
--- a/simpeg/potential_fields/gravity/survey.py
+++ b/simpeg/potential_fields/gravity/survey.py
@@ -2,6 +2,14 @@ from ...survey import BaseSurvey
 from ...utils.code_utils import validate_type
 from .sources import SourceField
 
+try:
+    from warnings import deprecated
+except ImportError:
+    # Use the deprecated decorator provided by typing_extensions (which
+    # supports older versions of Python) if it cannot be imported from
+    # warnings.
+    from typing_extensions import deprecated
+
 
 class Survey(BaseSurvey):
     """Base Gravity Survey
@@ -75,9 +83,24 @@ class Survey(BaseSurvey):
         return sum(receiver.nD for receiver in self.source_field.receiver_list)
 
     @property
+    @deprecated(
+        "The `components` property is deprecated, "
+        "and will be removed in SimPEG v0.25.0. "
+        "Within a gravity survey, receivers can contain different components. "
+        "Iterate over the sources and receivers in the survey to get "
+        "information about their components.",
+        category=FutureWarning,
+    )
     def components(self):
         """Number of components measured at each receiver.
 
+        .. deprecated:: 0.24.0
+
+            The `components` property is deprecated, and will be removed in
+            SimPEG v0.25.0. Within a gravity survey, receivers can contain
+            different components. Iterate over the sources and receivers in the
+            survey to get information about their components.
+
         Returns
         -------
         int
diff --git a/simpeg/potential_fields/magnetics/simulation.py b/simpeg/potential_fields/magnetics/simulation.py
index 05ddf5b99..72ada84d2 100644
--- a/simpeg/potential_fields/magnetics/simulation.py
+++ b/simpeg/potential_fields/magnetics/simulation.py
@@ -1147,6 +1147,7 @@ class Simulation3DDifferential(BaseMagneticPDESimulation):
     def survey(self, obj):
         if obj is not None:
             obj = validate_type("survey", obj, Survey, cast=False)
+            self._validate_survey(obj)
         self._survey = obj
 
     @property
@@ -1524,8 +1525,8 @@ class Simulation3DDifferential(BaseMagneticPDESimulation):
             \text{TMI} = \vec{B}_s \cdot \hat{B}_0
 
         """
-        # TODO: There can be some different tyes of data like |B| or B
-        components = self.survey.components
+        # Get components for all receivers, assuming they all have the same components
+        components = self._get_components()
 
         fields = {}
         if "bx" in components or "tmi" in components:
@@ -1560,8 +1561,8 @@ class Simulation3DDifferential(BaseMagneticPDESimulation):
 
         Especially, this function is for TMI data type
         """
-
-        components = self.survey.components
+        # Get components for all receivers, assuming they all have the same components
+        components = self._get_components()
 
         fields = {}
         if "bx" in components or "tmi" in components:
@@ -1585,6 +1586,58 @@ class Simulation3DDifferential(BaseMagneticPDESimulation):
 
         return sp.vstack([fields[comp] for comp in components])
 
+    def _get_components(self):
+        """
+        Get components of all receivers in the survey.
+
+        This function assumes that all receivers in the survey have the same
+        components in the same order.
+
+        Returns
+        -------
+        components : list of str
+            List of components shared by all receivers in the survey.
+
+        Raises
+        ------
+        ValueError
+            If the survey doesn't have any receiver, or if any receiver has
+            a different set of components than the rest.
+        """
+        # Validate survey first to ensure that the receivers have all the same
+        # components.
+        self._validate_survey(self.survey)
+        components = self.survey.source_field.receiver_list[0].components
+        return components
+
+    def _validate_survey(self, survey):
+        """
+        Validate a survey for the magnetic differential 3D simulation.
+
+        Parameters
+        ----------
+        survey : Survey
+            Survey object that will get validated.
+
+        Raises
+        ------
+        ValueError
+            If the survey doesn't have any receiver, or if any receiver has
+            a different set of components than the rest.
+        """
+        receivers = survey.source_field.receiver_list
+        if not receivers:
+            msg = "Found invalid survey without receivers."
+            raise ValueError(msg)
+        components = receivers[0].components
+        if not all(components == rx.components for rx in receivers):
+            msg = (
+                "Found invalid survey with receivers that have mixed components. "
+                f"Surveys for the {type(self).__name__} class must contain receivers "
+                "with the same components."
+            )
+            raise ValueError(msg)
+
     def projectFieldsAsVector(self, B):
         bfx = self.Qfx * B
         bfy = self.Qfy * B
diff --git a/simpeg/potential_fields/magnetics/survey.py b/simpeg/potential_fields/magnetics/survey.py
index 56a2c3296..ac66c7c42 100644
--- a/simpeg/potential_fields/magnetics/survey.py
+++ b/simpeg/potential_fields/magnetics/survey.py
@@ -3,6 +3,14 @@ from ...survey import BaseSurvey
 from ...utils.code_utils import validate_type
 from .sources import UniformBackgroundField
 
+try:
+    from warnings import deprecated
+except ImportError:
+    # Use the deprecated decorator provided by typing_extensions (which
+    # supports older versions of Python) if it cannot be imported from
+    # warnings.
+    from typing_extensions import deprecated
+
 
 class Survey(BaseSurvey):
     """Base Magnetics Survey
@@ -69,9 +77,24 @@ class Survey(BaseSurvey):
         return sum(rx.nD for rx in self.source_field.receiver_list)
 
     @property
+    @deprecated(
+        "The `components` property is deprecated, "
+        "and will be removed in SimPEG v0.25.0. "
+        "Within a magnetic survey, receivers can contain different components. "
+        "Iterate over the sources and receivers in the survey to get "
+        "information about their components.",
+        category=FutureWarning,
+    )
     def components(self):
         """Field components
 
+        .. deprecated:: 0.24.0
+
+            The `components` property is deprecated, and will be removed in
+            SimPEG v0.25.0. Within a magnetic survey, receivers can contain
+            different components. Iterate over the sources and receivers in the
+            survey to get information about their components.
+
         Returns
         -------
         list of str


Test patch:
diff --git a/tests/pf/test_components.py b/tests/pf/test_components.py
new file mode 100644
index 000000000..569a33eba
--- /dev/null
+++ b/tests/pf/test_components.py
@@ -0,0 +1,192 @@
+"""
+Test how potential field surveys and simulations access receiver components.
+"""
+
+import re
+import pytest
+import numpy as np
+
+import discretize
+from simpeg import maps
+from simpeg.potential_fields import gravity, magnetics
+
+
+@pytest.fixture
+def receiver_locations():
+    x = np.linspace(-20.0, 20.0, 4)
+    x, y = np.meshgrid(x, x)
+    z = 5.0 * np.ones_like(x)
+    return np.vstack((x.ravel(), y.ravel(), z.ravel())).T
+
+
+@pytest.fixture
+def mesh():
+    dh = 5.0
+    hx = [(dh, 10)]
+    return discretize.TensorMesh([hx, hx, hx], "CCN")
+
+
+class TestComponentsGravitySurvey:
+
+    def test_deprecated_components(self, receiver_locations):
+        """
+        Test FutureError after deprecated ``components`` property.
+        """
+        receivers = gravity.receivers.Point(receiver_locations, components="gz")
+        source_field = gravity.sources.SourceField(receiver_list=[receivers])
+        survey = gravity.survey.Survey(source_field)
+        msg = re.escape("The `components` property is deprecated")
+        with pytest.warns(FutureWarning, match=msg):
+            survey.components
+
+
+class TestComponentsMagneticSurvey:
+
+    def test_deprecated_components(self, receiver_locations):
+        """
+        Test FutureError after deprecated ``components`` property.
+        """
+        receivers = magnetics.receivers.Point(receiver_locations, components="tmi")
+        source_field = magnetics.sources.UniformBackgroundField(
+            receiver_list=[receivers], amplitude=55_000, inclination=12, declination=35
+        )
+        survey = magnetics.survey.Survey(source_field)
+        msg = re.escape("The `components` property is deprecated")
+        with pytest.warns(FutureWarning, match=msg):
+            survey.components
+
+
+class TestMagneticSimulationDifferential:
+
+    def build_survey(self, receivers: list | None):
+        """
+        Build a sample survey.
+        """
+        source_field = magnetics.sources.UniformBackgroundField(
+            receiver_list=receivers, amplitude=55_000, inclination=12, declination=35
+        )
+        survey = magnetics.survey.Survey(source_field)
+        return survey
+
+    @pytest.fixture
+    def sample_simulation(self, mesh, receiver_locations):
+        """
+        Build a sample simulation with single receiver with "tmi".
+        """
+        receivers = [magnetics.receivers.Point(receiver_locations, components="tmi")]
+        source_field = magnetics.sources.UniformBackgroundField(
+            receiver_list=receivers, amplitude=55_000, inclination=12, declination=35
+        )
+        survey = magnetics.survey.Survey(source_field)
+        simulation = magnetics.Simulation3DDifferential(
+            mesh, survey=survey, muMap=maps.IdentityMap(mesh=mesh)
+        )
+        return simulation
+
+    def test_survey_setter(self, receiver_locations, sample_simulation):
+        """
+        Test ``survey`` setter with valid receivers.
+        """
+        receivers = [magnetics.receivers.Point(receiver_locations, components="tmi")]
+        survey = self.build_survey(receivers)
+        # Try to override the survey, should pass wo errors
+        sample_simulation.survey = survey
+
+    @pytest.mark.parametrize("invalid_rx", ["no-rx", "different-components"])
+    def test_survey_setter_invalid(
+        self, receiver_locations, sample_simulation, invalid_rx
+    ):
+        """
+        Test ``survey`` setter with invalid receivers.
+        """
+        if invalid_rx == "no-rx":
+            receivers = []
+            msg = re.escape("Found invalid survey without receivers.")
+        else:
+            receivers = [
+                magnetics.receivers.Point(receiver_locations, components=c)
+                for c in ("tmi", ["bx", "by"])
+            ]
+            msg = re.escape(
+                "Found invalid survey with receivers that have mixed components."
+            )
+        # Try to override the survey
+        survey = self.build_survey(receivers)
+        with pytest.raises(ValueError, match=msg):
+            sample_simulation.survey = survey
+
+    @pytest.mark.parametrize("components", ["tmi", ["bx", "by", "bz"]])
+    def test_get_components(self, mesh, receiver_locations, components):
+        """
+        Test the ``_get_components`` method with valid receivers.
+        """
+        receivers = [
+            magnetics.receivers.Point(receiver_locations, components=components),
+            magnetics.receivers.Point(receiver_locations, components=components),
+        ]
+        survey = self.build_survey(receivers)
+        simulation = magnetics.Simulation3DDifferential(
+            mesh, survey=survey, muMap=maps.IdentityMap(mesh=mesh)
+        )
+
+        expected = components if isinstance(components, list) else [components]
+        assert expected == simulation._get_components()
+
+    @pytest.mark.parametrize("invalid_rx", ["no-rx", "different-components"])
+    def test_get_components_invalid(
+        self, sample_simulation, receiver_locations, invalid_rx
+    ):
+        """
+        Test the ``_get_components`` with invalid receivers.
+        """
+        # Override receivers in simulation's survey
+        if invalid_rx == "no-rx":
+            receivers = []
+            msg = re.escape("Found invalid survey without receivers.")
+        else:
+            receivers = [
+                magnetics.receivers.Point(receiver_locations, components=c)
+                for c in ("tmi", ["bx", "by"])
+            ]
+            msg = re.escape(
+                "Found invalid survey with receivers that have mixed components."
+            )
+        # Override private attribute `_receiver_list` to bypass the setter
+        sample_simulation.survey.source_field._receiver_list = receivers
+
+        # Try to get components
+        with pytest.raises(ValueError, match=msg):
+            sample_simulation._get_components()
+
+    @pytest.mark.parametrize("invalid_rx", ["no-rx", "different-components"])
+    @pytest.mark.parametrize("method", ["projectFields", "projectFieldsDeriv"])
+    def test_project_fields_invalid(
+        self, sample_simulation, receiver_locations, invalid_rx, method
+    ):
+        """
+        Test ``projectFields`` and ``projectFieldsDeriv`` on invalid surveys.
+        """
+        # Override receivers in simulation's survey
+        if invalid_rx == "no-rx":
+            receivers = None
+            msg = re.escape("Found invalid survey without receivers.")
+        else:
+            receivers = [
+                magnetics.receivers.Point(receiver_locations, components=c)
+                for c in ("tmi", ["bx", "by", "bz"])
+            ]
+            msg = re.escape(
+                "Found invalid survey with receivers that have mixed components."
+            )
+        # Override private attribute `_receiver_list` to bypass the setter
+        sample_simulation.survey.source_field._receiver_list = receivers
+
+        # Compute fields from a random model
+        n_cells = sample_simulation.mesh.n_cells
+        model = np.random.default_rng(seed=42).uniform(size=n_cells)
+        fields = sample_simulation.fields(model)
+
+        # Test errors
+        method = getattr(sample_simulation, method)
+        with pytest.raises(ValueError, match=msg):
+            method(fields)
diff --git a/tests/pf/test_forward_PFproblem.py b/tests/pf/test_forward_PFproblem.py
index 2c54300d7..fa2cb3fe2 100644
--- a/tests/pf/test_forward_PFproblem.py
+++ b/tests/pf/test_forward_PFproblem.py
@@ -33,11 +33,11 @@ class MagFwdProblemTests(unittest.TestCase):
         yr = np.linspace(-300, 300, 41)
         X, Y = np.meshgrid(xr, yr)
         Z = np.ones((xr.size, yr.size)) * 150
-        components = ["bx", "by", "bz"]
+        self.components = ["bx", "by", "bz"]
         self.xr = xr
         self.yr = yr
         self.rxLoc = np.c_[utils.mkvc(X), utils.mkvc(Y), utils.mkvc(Z)]
-        receivers = mag.Point(self.rxLoc, components=components)
+        receivers = mag.Point(self.rxLoc, components=self.components)
         srcField = mag.UniformBackgroundField(
             receiver_list=[receivers],
             amplitude=Btot,
@@ -70,7 +70,7 @@ class MagFwdProblemTests(unittest.TestCase):
             "secondary",
         )
 
-        n_obs, n_comp = self.rxLoc.shape[0], len(self.survey.components)
+        n_obs, n_comp = self.rxLoc.shape[0], len(self.components)
         dx, dy, dz = dpred.reshape(n_comp, n_obs)
 
         err_x = np.linalg.norm(dx - bxa) / np.linalg.norm(bxa)