Skip to content

Add rotation invariant transfer functions - #1779

Open
dccowan wants to merge 15 commits into
mainfrom
dcowan/NSEM_orientation_invariant
Open

dccowan wants to merge 15 commits into
mainfrom
dcowan/NSEM_orientation_invariant

Conversation

@dccowan

@dccowan dccowan commented Aug 28, 2026 •

Copy link
Copy Markdown
Member

Summary

This PR is intended to add the complete suite of rotation invariant transfer transfer functions to our receiver classes. These are primarily used for airborne NSEM systems but that can be used for general survey configurations.

PR Checklist

  • If this is a work in progress PR, set as a Draft PR
  • Linted my code according to the style guides.
  • Added tests to verify changes to the code.
  • Added necessary documentation to any new functions/classes following the
    expect style.
  • 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.

Supplementary math

Consider an AirMT system that measures three-component airborne magnetic fields. For a magnetic base station, we can generate the fundamental set of transfer functions $\mathbf{T}$, where $\mathbf{T}$ is a 2x3 matrix. Likewise, for an electric base station, we can generate the fundamental admittance tensor $\mathbf{Y}$. A multitude of orientation invariant transfer functions can be derived from the fundamental set. These are helpful when receiver orientation cannot be precisely resolved.

Root Gram Determinant: $\widehat{\mathbf{T}} = \sqrt{\mathbf{TT^\dagger}}$ where $\dagger$ represents the Hermitian. Similarly, the root Gram determinant can be generated for an electric base station $\widehat{\mathbf{Y}} = \sqrt{\mathbf{YY^\dagger}}$

Cross Product Amplitude: Orientation invariant transfer functions $|T|$ and $|Y|$ can be generated by taking the amplitude of the cross-product of the two rows of $\mathbf{T}$ and $\mathbf{Y}$, respectively. See Mackie and Soyer (2026).

Horizontal Determinant: Taking the determinant of the horizontal transfer functions provides something invariant to z-axis rotation $det(T_H) = T_{xx} T_{yy} - T_{xy} T_{yx}$ and $det(Y_H) = Y_{xx} Y_{yy} - Y_{xy} Y_{yx}$.

Apparent Conductivity: For an electric base station, all of these approaches can be used to generate apparent conductivity data.

  • $\omega \mu_0 \widehat{Y}$
  • $\omega \mu_0 |Y|$
  • $\omega \mu_0 |det(Y_H)|$

This PR adds all the aforementioned transfer functions.

What is being added

A _BaseOrientationInvariant will host the underlying functionality for taking the root Gram determinant, cross product amplitude and horizontal determinant. There will be a property that sets whether an electric or magnetic base station is used.

From this, I created child classes RootGramDeterminant, CrossProductAmplitude and HorizontalDeterminant that users can interact with.

There is also an ApparentConductivity class. Here, the component property is used to set the approach used to generate the apparent conductivity datum

dccowan and others added 4 commits August 28, 2026 15:36
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
@codecov

codecov Bot commented Aug 28, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.31579% with 52 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.74%. Comparing base (bb9067d) to head (2de3c3d).

Files with missing lines Patch % Lines
...impeg/electromagnetics/natural_source/receivers.py 88.59% 22 Missing and 13 partials ⚠️
...em/forward/test_Simulation3D_vs_Analytic_pytest.py 79.31% 5 Missing and 7 partials ⚠️
...nsem/inversion/test_NSEM_3D_jvecjtvecadj_pytest.py 66.66% 4 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1779      +/-   ##
==========================================
- Coverage   81.75%   81.74%   -0.02%     
==========================================
  Files         422      422              
  Lines       55756    56056     +300     
  Branches     5294     5345      +51     
==========================================
+ Hits        45585    45824     +239     
- Misses       8758     8797      +39     
- Partials     1413     1435      +22     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@dccowan
dccowan requested review from a team and santisoler September 24, 2026 17:26

@santisoler santisoler left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for putting this together, @dccowan!

I just pushed some changes to the branch, mainly improving the docstring of the new receivers, and removing some uneeded noqa comments.

I left a few comments below. Let me know what do you think and if I could help with anything. I'm leaving some more general comments here too.

Breaking change in ApparentConductivity?

I was wondering if you checked that the ApparentConductivity in this PR (with the default arguments) generates the same data as in main.

Since now we are introducing an option to change how the data is computed, I'm wondering if the cross_product_amplitude method returns the same values as before.

I would strongly suggest to avoid changing those values since that would introduce a breaking change: users' code using ApparentConductivity would generate different values after updating.

Repeated mathematical operations

I noticed that we are repeating very similar mathematical operations, like computing the determinant of some matrices. I think we could create some private functions to handle those and avoid potential typos every time we repeat them. We can also add unit tests for those functions to check that individually they work as intended.

Structure

As we talked in last meeting, I'm not a big fan of the current structure of the classes. The _BaseOrientationInvariant is handling too many things, while the other receiver classes just work as dispatchers. This makes the _BaseOrientationInvariant too complex, and moves the implementation of each one of the receivers to a parent class.

I understand the spirit of the idea: since these methods are going to be used by each one of the new receiver classes (RootGramDeterminant, CrossProductAmplitude, and HorizontalDeterminant) and also by the ApparentConductivity class, it makes sense not to repeat things.

But what if we have private functions to compute each one of these quantities, and those functions can get reused by each one of those classes.

For example, we could have a function like:

def _get_root_gram_determinant(receiver, src, mesh, f):

    if mesh.dim < 3:
        raise NotImplementedError(
            "'RootGramDeterminant' transfer function only for 3D simulation."
        )

    h = f[src, "h"]
    hx = receiver.getP(mesh, "Fx", 0) @ h
    hy = receiver.getP(mesh, "Fy", 0) @ h
    hz = receiver.getP(mesh, "Fz", 0) @ h

    if receiver.base_type == "magnetic":
        bx = receiver.getP(mesh, "Fx", 1) @ h
        by = receiver.getP(mesh, "Fy", 1) @ h

    else:
        e = f[src, "e"]
        bx = receiver.getP(mesh, "Ex", 1) @ e
        by = receiver.getP(mesh, "Ey", 1) @ e

    # abs(det(H H*))
    top = (
        (np.abs(hx[:, 0] ** 2) + np.abs(hy[:, 0] ** 2) + np.abs(hz[:, 0] ** 2))
        * (np.abs(hx[:, 1] ** 2) + np.abs(hy[:, 1] ** 2) + np.abs(hz[:, 1] ** 2))
    ) - np.abs(
        hx[:, 0] * hx[:, 1].conjugate()
        + hy[:, 0] * hy[:, 1].conjugate()
        + hz[:, 0] * hz[:, 1].conjugate()
    ) ** 2

    # abs(det(B B*)) = abs(det(B))**2
    bot = np.abs(bx[:, 0] * by[:, 1] - bx[:, 1] * by[:, 0]) ** 2

    return np.sqrt(top / bot)

That gets used by RootGramDeterminant:

class RootGramDeterminant(...):

    def eval(self, src, mesh, f):
        return _get_root_gram_determinant(self, src, mesh, f)

And also by ApparentConductivity:

class ApparentConductivity(...):

    def eval(self, src, mesh, f):  # noqa: A003 D102
        if self._component == "root_gram_determinant":
            root_gram_determinant = self._get_root_gram_determinant(self, src, mesh, f)
            return _alpha(src) ** -1 * root_gram_determinant
        ...

We could also keep those private functions simpler: we can remove those if statements that perform action if the receiver is an ApparentConductivity, and let it handle those extra steps.

Nonetheless, these changes would be applied to the internal structure of how these classes are implemented and wouldn't affect the public interface. So we can leave them here as ideas to implement later if we are happy with the public API of these new receivers, and the changes to the ApparentConductivity one.

Comment thread simpeg/electromagnetics/natural_source/receivers.py Outdated
Comment thread simpeg/electromagnetics/natural_source/receivers.py
Comment thread simpeg/electromagnetics/natural_source/receivers.py Outdated
Comment thread simpeg/electromagnetics/natural_source/receivers.py Outdated
Comment thread simpeg/electromagnetics/natural_source/receivers.py Outdated
Comment thread simpeg/electromagnetics/natural_source/receivers.py Outdated
Comment thread simpeg/electromagnetics/natural_source/receivers.py Outdated
Comment thread simpeg/electromagnetics/natural_source/receivers.py Outdated
Comment thread simpeg/electromagnetics/natural_source/receivers.py Outdated
Comment thread simpeg/electromagnetics/natural_source/receivers.py Outdated
@lheagy

lheagy commented Sep 30, 2026

Copy link
Copy Markdown
Member

Thanks so much @dccowan and @santisoler for your work on this! Just adding a quick comment on the apparent conductivity computation - I would be fine with us introducing a breaking change here. The previous version computed |H|/|E|, which is a quantity that cannot be computed from timeseries data in MT. So it was an incorrect value. The number of users who have used this so far is quite small because we haven't had any examples online yet, so we can put a big warning in the release notes, and if we wish, put a warning in receiver call, but I would consider the previous implementation to have been a bug in our understanding of the problem

@dccowan

dccowan commented Sep 30, 2026

Copy link
Copy Markdown
Member Author

@santisoler regarding breaking change to ApparentConductivity: The way that the apparent conductivity datum is being generated in main is currently incorrect. While mathematically it works, it is not possible to generate the datum in this way in practice. The discrepancy between the apparent conductivity datum in main and the new ones is actually very small so I doubt anyone would notice. But since the way the apparent conductivity datum computed on the main branch is incorrect, I want to remove it as soon as possible.

@dccowan

dccowan commented Sep 30, 2026

Copy link
Copy Markdown
Member Author

Just to confirm then general reorganization of functionality:

  • Hidden methods like _eval_root_gram_determinant that were defined in the _BaseOrientationInvariant class will instead be defined as hidden functions
  • The base_type property will be moved to the BaseNaturalSourceRx class, which effectively removes the need to have a _BaseOrientationInvariant class all together.

@santisoler

Copy link
Copy Markdown
Member

Thanks for clarifying the breaking change. Sounds good then! I would add an admonition to the ApparentConductiivty class like:

.. admonition:: Breaking change

    Since SimPEG v0.XX.0 the :class:`~simpeg.electromagnetics.natural_source.receivers.ApparentConductivity` receiver computes the apparent conductivity as [...]

We could use SimPEG v0.26.0 since we are planning to merge this before the next release.

I would also explicit such breaking change and the bug it fixed in the description of the PR, so it'll be included in the commit message, and we can include it in the changelog once we make the release.


  • Hidden methods like _eval_root_gram_determinant that were defined in the _BaseOrientationInvariant class will instead be defined as hidden functions

If you are down with refactoring the code in this PR, please go ahead! Yes, my suggestion was that you could get rid of the _BaseOrientationInvariant class and have the different datum computations on private functions, that then each one of the public receiver classes can use accordingly. My other suggestion was that the ApparentConductivity makes use of those functions and implements the required extra steps to get from them to apparent conductivity, instead of having if statements like this one in the private functions:

if isinstance(self, ApparentConductivity):
scale /= _alpha(src)

I'm not 100% sure if this is possible since I haven't dug deep in the math, but if it's possible to apply those changes afterwards, I think it would make the code cleaner.

  • The base_type property will be moved to the BaseNaturalSourceRx class, which effectively removes the need to have a _BaseOrientationInvariant class all together.

I wouldn't do that, since not necessarily every natural source receiver has a base station. For example the Impedance receiver has electric and magnetic receivers in the same location, without distinction of "base station".

I wouldn't mind having the base_type property repeated in those four receiver classes. It's just a minor property, and I don't expect for it to suffer changes in the future.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants