Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@dccowan
Copy link
Member

@dccowan dccowan commented May 8, 2025

Summary

This PR is meant to improve the 3D NSEM primary secondary formulation. More suitable boundary conditions are implemented when solving the 1D problem that is used as the primary solution on the 3D mesh.

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.

What does this implement/fix?

The boundary conditions that are currently used to solve the 1D problem for the 3D primary-secondary simulation are sub-optimal. As a result, one needs to pad out excessively when using the 3D primary secondary formulation to obtain numerically accurate results. Here, more suitable boundary conditions are implemented to solve the 1D problem.

@jcapriot
Copy link
Member

jcapriot commented May 27, 2025

Looking through the code, the only thing this really does different to the current implementation is that it solves the primary problem on a mesh that is padded beyond the current mesh correct? Is there any reason we don't just use the analytic response for this in general?

@dccowan
Copy link
Member Author

dccowan commented May 29, 2025

Looking through the code, the only thing this really does different to the current implementation is that it solves the primary problem on a mesh that is padded beyond the current mesh correct? Is there any reason we don't just use the analytic response for this in general?

I wasn't quite sure what boundary conditions were being imposed originally because the underlying math wasn't documented. I only knew that they weren't correct. One thing this PR is working towards is better organization of the NSEM module and setting things up to add the fictitious sources approach.

@jcapriot
Copy link
Member

jcapriot commented Jun 3, 2025

I think I see more now looking at it. Currently the Primary field solution uses a Dirichlet Boundary condition on the top and bottom nodes with values taken from the analytic solution.

$$e_{sol}(z_{bottom}) = e_{ana}(z_{bottom})$$ $$e_{sol}(z_{top}) = e_{ana}(z_{top})$$

It would appear that in this proposal that you're using a Neumann boundary on $e$ at the top,

$$h_{top} = 1 \rightarrow \frac{\partial e}{\partial z}|_{top} = - i \omega \mu_0,$$

and at the bottom you're using a Robin boundary condition (or at least something close to one),

$$\frac{\partial e}{\partial z} - i k e_{bot} = 0$$

If so, then it is indeed different, and we can likely simplify up this implementation a decent amount using some of discretize's pre-built operators and be a little more rigorous with the derivation. It would be good to explicitly state these boundary conditions more clearly in the docstring though.

@jcapriot
Copy link
Member

jcapriot commented Jun 3, 2025

It would also appear there's some errors in the bottom boundary condition in the implementation as well, Some quick comparison's on my end:

image

As you can see, there is a large phase error at the bottom boundary in this proposal.

Comment on lines 335 to 377
# Extract vertical discretization
if mesh.dim == 1:
hz = mesh.h
else:
hz = mesh.h[-1]

if len(hz) != len(sigma_1d):
raise ValueError(
"Number of cells in vertical direction must match length of 'sigma_1d'. Here hz has length {} and sigma_1d has length {}".format(
len(hz), len(sigma_1d)
)
)

# Generate extended 1D mesh and model to solve 1D problem
hz_ext = np.r_[hz[0] * np.ones(n_pad), hz, hz[-1] * np.ones(n_pad)]
mesh_1d_ext = TensorMesh([hz_ext], origin=[mesh.origin[-1] - hz[0] * n_pad])

sigma_1d_ext = np.r_[
sigma_1d[0] * np.ones(n_pad), sigma_1d, sigma_1d[-1] * np.ones(n_pad)
]
sigma_1d_ext = mesh_1d_ext.average_face_to_cell.T * sigma_1d_ext
sigma_1d_ext[0] = sigma_1d[1]
sigma_1d_ext[-1] = sigma_1d[-2]

# Solve the 1D problem for electric fields on nodes
w = 2 * np.pi * freq
k = np.sqrt(-1.0j * w * mu_0 * sigma_1d_ext[0])

A = (
mesh_1d_ext.nodal_gradient.T @ mesh_1d_ext.nodal_gradient
+ 1j * w * mu_0 * sdiag(sigma_1d_ext)
)
A[0, 0] = (1.0 + 1j * k * hz[0]) / hz[0] ** 2 + 1j * w * mu_0 * sigma_1d[0]
A[0, 1] = -1 / hz[0] ** 2

q = np.zeros(mesh_1d_ext.n_faces, dtype=np.complex128)
q[-1] = -1j * w * mu_0 / hz[-1]

Ainv = Solver(A)
e_1d = Ainv * q

# Return solution along original vertical discretization
return e_1d[n_pad:-n_pad]
Copy link
Member

@jcapriot jcapriot Jun 4, 2025

Choose a reason for hiding this comment

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

Suggested change
# Extract vertical discretization
if mesh.dim == 1:
hz = mesh.h
else:
hz = mesh.h[-1]
if len(hz) != len(sigma_1d):
raise ValueError(
"Number of cells in vertical direction must match length of 'sigma_1d'. Here hz has length {} and sigma_1d has length {}".format(
len(hz), len(sigma_1d)
)
)
# Generate extended 1D mesh and model to solve 1D problem
hz_ext = np.r_[hz[0] * np.ones(n_pad), hz, hz[-1] * np.ones(n_pad)]
mesh_1d_ext = TensorMesh([hz_ext], origin=[mesh.origin[-1] - hz[0] * n_pad])
sigma_1d_ext = np.r_[
sigma_1d[0] * np.ones(n_pad), sigma_1d, sigma_1d[-1] * np.ones(n_pad)
]
sigma_1d_ext = mesh_1d_ext.average_face_to_cell.T * sigma_1d_ext
sigma_1d_ext[0] = sigma_1d[1]
sigma_1d_ext[-1] = sigma_1d[-2]
# Solve the 1D problem for electric fields on nodes
w = 2 * np.pi * freq
k = np.sqrt(-1.0j * w * mu_0 * sigma_1d_ext[0])
A = (
mesh_1d_ext.nodal_gradient.T @ mesh_1d_ext.nodal_gradient
+ 1j * w * mu_0 * sdiag(sigma_1d_ext)
)
A[0, 0] = (1.0 + 1j * k * hz[0]) / hz[0] ** 2 + 1j * w * mu_0 * sigma_1d[0]
A[0, 1] = -1 / hz[0] ** 2
q = np.zeros(mesh_1d_ext.n_faces, dtype=np.complex128)
q[-1] = -1j * w * mu_0 / hz[-1]
Ainv = Solver(A)
e_1d = Ainv * q
# Return solution along original vertical discretization
return e_1d[n_pad:-n_pad]
def primary_e_1d_solution(mesh, sigma_1d, freq, n_pad=2000):
# Extract vertical discretizations
hz = mesh.h[-1]
if len(hz) != len(sigma_1d):
raise ValueError(
"Number of cells in vertical direction must match length of 'sigma_1d'. Here hz has length {} and sigma_1d has length {}".format(
len(hz), len(sigma_1d)
)
)
# Generate extended 1D mesh and model to solve 1D problem
hz = np.pad(hz, (n_pad, n_pad), mode='edge')
mesh = discretize.TensorMesh([hz], origin=[mesh.origin[-1] - hz[0] * n_pad])
sigma_1d = np.pad(sigma_1d, (n_pad, n_pad), mode='edge')
mu = np.full(mesh.n_cells, mu_0)
# Solve the 1D problem for electric fields on nodes/faces
G = mesh.nodal_gradient
M_e_mui = mesh.get_edge_inner_product(mu, invert_model=True)
M_f_sigma = mesh.get_face_inner_product(sigma_1d)
omega = 2 * np.pi * freq
k_bot = np.sqrt(-1.0j * omega * mu[0] * sigma[0])
q = np.zeros(mesh.n_nodes, dtype=np.complex128)
q[-1] = -1j * omega
A = G.T @ M_e_mui @ G + 1j * omega * M_f_sigma
A[0, 0] += 1j * k_bot/mu[0]
Ainv = get_default_solver()(A)
e_1d = Ainv @ q
# Return solution along original vertical discretization
if n_pad != 0:
e_1d = e_1d[n_pad:-n_pad]
return e_1d

This is a bit cleaner code to understand imo, and also allows for potentially including spatially variable $\mu$ if you wanted.

@jcapriot
Copy link
Member

jcapriot commented Jun 4, 2025

Went through my own derivation to double check the boundary conditions, see my notes below, but I do get to the same result as your code, but I'm just not convinced it is a better boundary condition than just using the analytic as dirichlet conditions, here are the real and imaginary parts for a simple layered model:

image

Derivation

First the PDE you are solving, with boundary conditions clearly stated.

$$ \begin{align} \frac{\partial e}{\partial z} + i\omega b = 0\\ \frac{\partial h}{\partial z} + \sigma e = 0 \end{align} $$

s.t.

$$ \begin{align} \frac{\partial e}{\partial z} = -i \omega \mu \hspace{10pt}\textrm{for } z = z_{top}\\ \frac{\partial e}{\partial z} - i k e = 0 \hspace{10pt}\textrm{for } z = z_{bot} \end{align} $$

and in a weak form,

$$ \begin{align} < u, \partial_z e > + i \omega < u, b > = 0\\ < v, \partial_z \mu^{-1} b > + < v, \sigma e > = 0 \end{align} $$

Discretizing this with an E-B formulation with $e$ on faces (nodes in 1D) and $b$ on edges (cell centers in 1D),

$$ \begin{align} u^T M_{e} G e + i \omega u^T M_{e} b = 0\\ -v^T G^T M_{e, \mu^{-1}} b + (v \mu^{-1} b)|^{top - bot} + v^T M_{f, \sigma} e = 0 \end{align} $$

Using the two boundary conditions to solve for $b_{top}$ and $b_{bot}$ from Faraday's Law:

$$ \begin{align} i\omega b_{top} = -\partial_z e |^{top} = i\omega \mu\\ b_{top} = \mu \end{align} $$

$$ \begin{align} i \omega b_{bot} = -\partial_z e |^{bot} = -ike\\ b_{bot} = -\frac{k}{\omega} e \end{align} $$

Which gives

$$ -v^T G^T M_{e,\mu^{-1}} b + v_{top} + v_{bot}\frac{k}{\mu\omega} e + v^T M_{f, \sigma} e = 0 $$

multiply by $i\omega$

$$ v^T G^T M_{e,\mu^{-1}} (-i \omega b) + i \omega (v_{top} + v_{bot}\frac{k}{\mu\omega} e) + i\omega v^T M_{f, \sigma} e = 0 $$

Substitute in discretized Faraday's Law,

$$ v^T G^T M_{e,\mu^{-1}} G e + i \omega (v_{top} + v_{bot}\frac{k}{\mu\omega} e) + i\omega v^T M_{f, \sigma} e = 0 $$

Re-arrange

$$ v^T G^T M_{e,\mu^{-1}} G e + i(v \frac{k}{\mu} e)|^{bot} + i\omega v^T M_{f, \sigma} e = -i \omega (v_{top}) $$

So then system of equations is then:

$$ (G^T M_{e,\mu^{-1}} G +i\omega M_{f, \sigma} + i P_{c, bot} \frac{k_{bot}}{\mu_{bot}} ) e = -i \omega p_{c, top} $$

Where $P_{c, bot}$ is a matrix of zeros, with a single 1 along the diagonal at the bottom node index, and $p_{c, top}$ is a vector of 0's with a single 1 at the top node index.

@dccowan
Copy link
Member Author

dccowan commented Jun 6, 2025

I think I see more now looking at it. Currently the Primary field solution uses a Dirichlet Boundary condition on the top and bottom nodes with values taken from the analytic solution.

e s o l ( z b o t t o m ) = e a n a ( z b o t t o m ) e s o l ( z t o p ) = e a n a ( z t o p )

It would appear that in this proposal that you're using a Neumann boundary on e at the top,

h t o p = 1 → ∂ e ∂ z | t o p = − i ω μ 0 ,

and at the bottom you're using a Robin boundary condition (or at least something close to one),

∂ e ∂ z − i k e b o t = 0

If so, then it is indeed different, and we can likely simplify up this implementation a decent amount using some of discretize's pre-built operators and be a little more rigorous with the derivation. It would be good to explicitly state these boundary conditions more clearly in the docstring though.

Ya, basically. I wanted to use the prebuilt operators in discretize for boundary conditions but couldn't quite figure it out.

@dccowan
Copy link
Member Author

dccowan commented Jun 6, 2025

Went through my own derivation to double check the boundary conditions, see my notes below, but I do get to the same result as your code, but I'm just not convinced it is a better boundary condition than just using the analytic as dirichlet conditions, here are the real and imaginary parts for a simple layered model:

image

Derivation

First the PDE you are solving, with boundary conditions clearly stated.

∂ e ∂ z + i ω b = 0 ∂ h ∂ z + σ e = 0

s.t.

∂ e ∂ z = i ω μ for  z = z t o p ∂ e ∂ z − i k e = 0 for  z = z b o t

and in a weak form,

< u , ∂ z e > + i ω < u , b >= 0 < v , ∂ z μ − 1 b > + < v , σ e >= 0

Discretizing this with an E-B formulation with e on faces (nodes in 1D) and b on edges (cell centers in 1D),

u T M e G e + i ω u T M e b = 0 − v T G T M e , μ − 1 b + ( v μ − 1 b ) | t o p − b o t + v T M f , σ e = 0

Using the two boundary conditions to solve for b t o p and b b o t from Faraday's Law:

i ω b t o p = − ∂ z e | t o p = i ω μ b t o p = μ

i ω b b o t = − ∂ z e | b o t = − i k e b b o t = − k ω e

Which gives

− v T G T M e , μ − 1 b + v t o p + v b o t k μ ω e + v T M f , σ e = 0

multiply by i ω

v T G T M e , μ − 1 ( − i ω b ) + i ω ( v t o p + v b o t k μ ω e ) + i ω v T M f , σ e = 0

Substitute in discretized Faraday's Law,

v T G T M e , μ − 1 G e + i ω ( v t o p + v b o t k μ ω e ) + i ω v T M f , σ e = 0

Re-arrange

v T G T M e , μ − 1 G e + i ( v k μ e ) | b o t + i ω v T M f , σ e = − i ω ( v t o p )

So then system of equations is then:

( G T M e , μ − 1 G + i ω M f , σ + i P c , b o t k b o t μ b o t ) e = − i ω p c , t o p

Where P c , b o t is a matrix of zeros, with a single 1 along the diagonal at the bottom node index, and p c , t o p is a vector of 0's with a single 1 at the top node index.

My derivation is almost the identical. However, I'm pretty sure you have a sign missing on the boundary conditions at the top. There are two negatives that cancel out.

@dccowan
Copy link
Member Author

dccowan commented Jun 6, 2025

Devin' Derivation

The system:

$$ \frac{\partial e_x}{\partial z} + i\omega b_y = 0 $$ $$ \frac{\partial h_y}{\partial z} + \sigma e_x = 0 $$

Boundary condition at the top:

We set:

$$ h_y^{(top)} = 1 $$

Therefore from Faraday's law:

$$ \frac{\partial e_x^{(top)}}{\partial z} = - i\omega\mu_0 $$

Boundary condition at the bottom:

At the bottom, there is only a downgoing wave of the form:

$$ e_x = E^- \exp (ikz) $$

where

$$ k = \sqrt{-i\omega\mu_0\sigma} = (1 - i)\sqrt{\frac{\omega \mu_0\sigma}{2}} $$

So if $\Delta z$ is negative, the downgoing wave decays. From Faraday's law:

$$ ik E^- \exp (ikz) + i\omega b_y = 0 $$ $$ \implies ik e_x + i \omega b_y = 0 $$ $$ \implies k e_x + \omega b_y = 0 $$

And the boundary condition here is:

$$ \frac{\partial e_x}{\partial z} - i k e_x = 0 $$

Inner products:

Inner-products:

$$ \langle u , \partial_z e_x \rangle + i\omega \langle u , b \rangle = 0 $$ $$ \langle f , \partial_z h_y \rangle + \langle f, \sigma e \rangle = 0 $$

Integrate second equation by parts:

$$ \langle u , \partial_z e_x \rangle + i\omega \langle u , b_y \rangle = 0 $$ $$ -\langle \partial_z f , h_y \rangle + f h_y \bigg |_{bot}^{top} + \langle f, \sigma e_x \rangle = 0 $$

Discrete system:

Get discrete systems (multiply second equation by $i \omega$):

$$ \mathbf{G_n e} = - i\omega \mathbf{b} $$ $$ -i \omega \mathbf{G_n^T M_{\mu} b} + i \omega\mathbf{M_\sigma e} + i\omega h_y \bigg |_{bot}^{top} = 0 $$

Which gives us:

$$ \big [ \mathbf{G_n^T M_{\mu} G_n} + i \omega\mathbf{M_\sigma} \big ] \mathbf{e} + i\omega h_y \bigg |_{bot}^{top} = 0 $$

Or if $\mu = \mu_0$, we can multiply through an obtain:

$$ \big [ \mathbf{G_n^T G_n} + i \omega \mu_0 , diag (\sigma) \big ] \mathbf{e} + i\omega \mu_0 h_y \bigg |_{bot}^{top} = 0 $$

@jcapriot
Copy link
Member

jcapriot commented Jun 6, 2025

Went through my own derivation to double check the boundary conditions, see my notes below, but I do get to the same result as your code, but I'm just not convinced it is a better boundary condition than just using the analytic as dirichlet conditions, here are the real and imaginary parts for a simple layered model:

My derivation is almost the identical. However, I'm pretty sure you have a sign missing on the boundary conditions at the top. There are two negatives that cancel out.

Ah yep, missed that on the copy over.

@dccowan
Copy link
Member Author

dccowan commented Jun 6, 2025

Went through my own derivation to double check the boundary conditions, see my notes below, but I do get to the same result as your code, but I'm just not convinced it is a better boundary condition than just using the analytic as dirichlet conditions, here are the real and imaginary parts for a simple layered model:

My derivation is almost the identical. However, I'm pretty sure you have a sign missing on the boundary conditions at the top. There are two negatives that cancel out.

Ah yep, missed that on the copy over.

Can you clarify what is meant by 'analytic as Dirichlet conditions'? Do you just mean setting ex=1 on the top?

I remember there being a reason I liked setting the top boundary condition using the Neumann condition but I can't remember why at the moment.

@jcapriot
Copy link
Member

jcapriot commented Jun 7, 2025

The current formulation sets Dirichlet conditions on E at the top and bottom boundary. Their values come from the analytic solution of a layered earth, normalized to 1 at the top.

@dccowan
Copy link
Member Author

dccowan commented Sep 12, 2025

Here is what I have for both Dirichlet and Neumann conditions on the top of the mesh. So we can normal the electric field (Dirichlet) or magnetic field (Neumann) to be 1 at the top of the mesh. By default, we will set the Dirichlet condition since that is the current implementation within SimPEG. By adding some additionally padding cells at the bottom, we do a much better job at enforcing the "downgoing wave" boundary condition.

The plot below is the amplitude of things on a log-scale. As you can see, the absolute error for both implementations is extremely small.

image

Copy link
Member

@jcapriot jcapriot left a comment

Choose a reason for hiding this comment

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

You're still re-creating a lot of the inner_product functionality from discretize here for 1D making it a bit more difficult to maintain. See my previous suggested change for a simpler implementation.

@codecov
Copy link

codecov bot commented Sep 18, 2025

Codecov Report

❌ Patch coverage is 86.91099% with 25 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.39%. Comparing base (5f060ec) to head (74186b9).

Files with missing lines Patch % Lines
...ctromagnetics/natural_source/utils/source_utils.py 80.39% 10 Missing and 10 partials ⚠️
...sem/forward/test_fields_1d_vs_propagator_pytest.py 90.90% 1 Missing and 4 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1661      +/-   ##
==========================================
- Coverage   81.52%   80.39%   -1.13%     
==========================================
  Files         420      418       -2     
  Lines       55174    54778     -396     
  Branches     5254     5259       +5     
==========================================
- Hits        44978    44040     -938     
- Misses       8790     9344     +554     
+ Partials     1406     1394      -12     

☔ View full report in Codecov by Sentry.
📢 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.

@santisoler santisoler added this to the 0.25.0 milestone Oct 15, 2025
@santisoler santisoler modified the milestones: 0.25.0, 0.26.0 Oct 22, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants