mom_sommerfeld — Galerkin MoM with direct Sommerfeld quadrature¶
Physical context¶
Every other layered backend in the family relies on a closed-form representation of the layered Green's function:
image_2layer— geometric Tagg / Sunde series.image_nlayer— dispatcher to the closed-form variants.cim— matrix-pencil approximation by complex exponentials.bem— collocation on top of the CIM kernel.
mom_sommerfeld is methodologically distinct: it evaluates the
Sommerfeld integral numerically, point by point. The recursive
\(\Gamma_1(\lambda)\) enters the integrand as is — no expansion, no
fit. The price is speed (a single \(N \times N\) reaction matrix can
take seconds rather than milliseconds), but the result is an
absolute reference: any disagreement between the closed-form
engines and mom_sommerfeld indicates a fit / series accuracy issue,
not a kernel bug.
In ADR-0002 this is the engine that anchors the cross-validation envelope for the \(n \ge 3\) regime, and provides the independent methodology that the closed-form layered family lacks.
Governing equation: layered Sommerfeld integral¶
For a top-layer source at depth \(z_s\) and a top-layer field point at \((s, z)\), the full layered Green's function is the Sommerfeld integral with multiple-reflection multiplier:
The integrand carries:
- Two direct exponentials — the source and its air mirror.
- Two reflected exponentials — the source's reflection at \(z = h_1\) and the air mirror's reflection at \(z = h_1\).
- A multiplexion factor \(1 / (1 - \Gamma_1(\lambda) e^{-2\lambda h_1})\) that captures the infinite back-and-forth bouncing between the air boundary (\(R_{\text{air}} = +1\)) and the layer interface (\(\Gamma_1\)).
For \(\Gamma_1 \to 0\) (homogeneous) the integral reduces to \(1/r + 1/r_{\text{img}}\). For \(\Gamma_1 \equiv K_1\) (2-layer) the geometric expansion of the multiplier reproduces the Tagg / Sunde series. For \(n \ge 3\) no closed form is available — the engine just integrates.
Numerical strategy¶
The integral is only conditionally convergent¶
The first term of the numerator, \(e^{-\lambda |z - z_s|}\), does not decay for two points at the same depth — and \(|z - z_s| = 0\) is the normal case for the off-diagonal entries of a buried horizontal grid. There the integral converges only through the oscillation of \(J_0(\lambda s)\), and a truncation at any finite \(\lambda_{\max}\) leaves a sign-alternating error of order
which no quadrature tolerance can reduce. Up to and including
0.14.1 the engine did exactly that (a bare
scipy.integrate.quad(0, lam_max) with limit=400), and returned
\(G = -0.012551\) where the exact value is \(+0.039990\) for
\(s = 50\,\text{m}\), \(z = z_s = 0.8\,\text{m}\),
\(h_1 = 1\,\text{m}\) — a negative Green's function in the engine
that all other backends are validated against.
Analytic extraction (0.15.0)¶
The fix is exact, not a heuristic. Lipschitz' integral
gives the closed form of every single exponential against \(J_0\). The kernel is therefore split as
where the first two terms are the \(\Gamma_1 \to 0\) limit of the integrand (the conditionally convergent part — for \(\rho_1 = \rho_2\) they are the whole answer) and the third is the leading interface reflection evaluated with the high-\(\lambda\) limit \(\Gamma_1(\infty) = K_1\) (exact for \(n = 2\), where \(\Gamma_1 \equiv K_1\)). What remains,
decays on the strictly positive length scale
— even in the corner where both points sit on the interface (\(z + z_s \to 2 h_1\)), which is why the \(K_1\) term is extracted as well. The discarded tail is then bounded by \(e^{-\lambda_{\max} d_{\text{rem}}}\), i.e. controlled — provided the panel grid actually resolves \([0, \lambda_{\max}]\). When it cannot (see Non-convergence is loud below) that bound does not hold and the engine says so.
1 mm distance floor at the interface
Distances are floored at 1 mm (_MIN_DISTANCE), the same
convention as the homogeneous image backend. The
floor is applied to the analytic
\(K_1 / \sqrt{s^2 + d_{\text{int}}^2}\) term with
\(d_{\text{int}} = 2 h_1 - z - z_s\), but not to the matching
\(K_1 e^{-\lambda d_{\text{int}}}\) subtraction inside the
numerically integrated residual, so for
\(\sqrt{s^2 + d_{\text{int}}^2} < 1\,\text{mm}\) the split is no
longer algebraically exact and the reflected part saturates at the
floor. This is not a sign error, but it is much sharper than a
1 mm geometric floor suggests:
_reflected_integral(s=0, dz=0, z+z_s=2(h_1-10^{-5})) returns
667.561 where the exact reflected series is 33334.23
(−98 %); at \(d_{\text{int}} = 2\,\text{mm}\) the same call is
exact to \(10^{-9}\). The only route there is the \(n \ge 3\)
diagonal with a segment midpoint within 0.5 mm of the interface
(\(s = 0\), \(z = z_s\)). The saturation is logged at WARNING
level; keep segment midpoints a few millimetres clear of \(h_1\).
Quadrature truncation and \(\lambda\) grid¶
\(\lambda_{\max} = \texttt{lambda\_max\_factor} / d_{\text{rem}}\) — the decay length of the remainder, not (as before 0.15.0) a characteristic length of the geometry \(\min(h_1, s + z + z_s)\). The old bound ignored \(|z - z_s|\) altogether and truncated the largest off-diagonal entries of \(\mathbf{Z}\) by up to 45 % (two segments 2 cm apart at 3 m depth: 27.6997 instead of 50.1661), an error that grew under mesh refinement. The same omission affected the \(n \ge 3\) diagonal correction, whose true decay scale is \(2 (h_1 - z_i)\): at \(z_i = 1.995\,\text{m}\) in a \(h_1 = 2\,\text{m}\) layer the reflected self-term came out 36 % low.
lambda_max_factor saturates at 60 (\(e^{-60} \approx 10^{-26}\)):
raising the knob beyond the default can no longer change the result.
Before 0.15.0 raising it made things worse — 200 → −1.4 %,
20000 → −57 % with a flipped sign — because quad's limit=400
subdivision cap could not resolve the additional Bessel half-waves.
The knob is settable through Engine.sommerfeld_lambda_max_factor
since 0.15.0; its unit differs on the delegated \(n = 2\)
cross-layer path — see the warning box below.
The remainder is integrated with fixed-order (12-point) Gauss–Legendre panels on a grid built from three families:
- a quadratically graded base grid on \([0, \lambda_{\max}]\) — dense near \(\lambda = 0\), where the fast exponentials \(e^{-\lambda (2 h_1 + z + z_s)}\) and the variation of \(\Gamma_1(\lambda)\) live;
- a geometric (log-spaced) family below the innermost base panel, reaching down to a quarter of the \(\lambda\)-scale \(\lambda_{\text{pole}} = (1 - \Gamma_1(0)) / 2 h_1\) of the multiple-reflection multiplier \(1/(1 - \Gamma_1 e^{-2\lambda h_1})\), at 8 panels per decade. For \(|K_1| \to 1\) that scale collapses towards \(\lambda = 0\) (\(2.5 \cdot 10^{-9}\,\text{m}^{-1}\) at \(\rho_2/\rho_1 = 10^8\), \(h_1 = 2\,\text{m}\)) and the quadratic grading misses it completely — see Convergence and cost;
- extra edges at half-period spacing \(\pi / s\) up to \(\lambda\, d_{\text{rem}} = 34\) (envelope \(\sim 10^{-15}\)), which resolves the \(J_0\) oscillation the way Lucas & Stone (1995) prescribe — 12 Gauss nodes per half period integrate a Bessel half-wave to machine precision.
The grid is refined (×2, ×4, ×8) until two successive values agree within \(\max(\texttt{epsabs}, \texttt{epsrel} \cdot |G|)\), so the tolerance arguments are now meaningful. All abscissae of one panel family are evaluated in a single vectorised call, which is why the engine is faster than before despite the higher accuracy (a 32- segment ring on a 2-layer soil: 121 s → 1 s).
Oscillation panel budget and the refinement ladder¶
The half-period family needs \(\lambda_{\max}\, s / \pi\) panels, so
its size grows with \(s / d_{\text{rem}}\). It is bounded by
max_osc_panels (Engine.sommerfeld_max_osc_panels, default
200 000 panels = 2.4 M abscissae per grid), which binds once
\(s / d_{\text{rem}} \gtrsim 1.9 \cdot 10^{4}\).
Up to 0.15.0 the budget was spent the wrong way round. The step was \(\pi / (s \cdot \texttt{refine})\) and the panel count was truncated at the cap, so the resolved range \(n_{\text{osc}} \cdot \text{step} = \texttt{max\_osc\_panels} \cdot \pi / (s \cdot \texttt{refine})\) shrank by the refinement factor: rung ×8 of the ladder covered one eighth of what rung ×1 covered, and since the loop returns the last value, refining made the answer worse. The budget is now spent coverage first:
- while full coverage of \([0, \min(\lambda_{\text{osc}}, \lambda_{\max})]\) fits in the budget, the refinement multiplier is reduced to the largest value that fits — coverage stays complete;
- only if not even one panel per half period fits does the covered
range become \(\texttt{max\_osc\_panels} \cdot \pi / s\), and it is
then independent of
refine.
The covered \(\lambda\)-range is therefore monotonically non-decreasing along the ladder.
Non-convergence is loud (0.15.0)¶
If the ladder is exhausted without two successive grids agreeing
within \(\max(\texttt{epsabs}, \texttt{epsrel}\cdot|G|)\), the engine
raises a
groundfield.solver.mom_sommerfeld.SommerfeldConvergenceWarning
naming the achieved versus the requested tolerance, the offending
\((s, |z - z_s|, z + z_s)\), whether the oscillation budget bound (and
how much of the required \(\lambda\)-range was covered), the fact that
the sign may be wrong, and which parameter to raise. The
\(O(N^2)\) matrix assembly aggregates all failures into a single
warning naming the worst pair and the number of affected entries.
Up to 0.15.0 this condition was only logger.debug, i.e. silent at
Python level, and the least accurate rung of the ladder was the
one returned. The combination reproduced the original F04 failure
mode in the very engine that is the package's designated reference.
Measured on LayerStack(rhos=[100, 1000, 50], h=[2, h_2]) with
\(z = z_s\), checked against the far-field asymptote
\(G \to (2/s)\,\rho_3/\rho_1\):
| \(h_2\) [m] | \(z\) [m] | \(s\) [m] | 0.14.x | 0.15.0 | exact | warned? |
|---|---|---|---|---|---|---|
| 0.01 | 2.0 | 2000 | −9.4749e-05 (−119 %, wrong sign) | +5.0139e-04 (+0.28 %) | 5.000e-04 | yes |
| 0.001 | 2.0 | 200 | −9.4761e-04 (−119 %, wrong sign) | +5.0138e-03 (+0.28 %) | 5.000e-03 | yes |
| 0.01 | 2.0 | 500 | 1.92531e-03 (−3.7 %) | 1.999995e-03 (−2.6e-6) | 2.000e-03 | no |
| 0.01 | 1.99 | 1000 | 9.58002e-04 (−4.2 %) | 9.999994e-04 (−6.4e-7) | 1.000e-03 | no |
The two rows that still warn are genuinely budget-limited: raising
Engine.sommerfeld_max_osc_panels to 1_200_000 makes the first row
converge silently to 4.9999992e-04 (−1.5e-7) in 19 s instead of 2 s.
Across the 1260 in-envelope parameter sets of the accuracy table
below the ladder converged in 2–3 rungs every time, so escalating the
diagnostic from DEBUG to a warning does not fire in normal use.
import warnings
import groundfield as gf
from groundfield.solver.mom_sommerfeld import SommerfeldConvergenceWarning
engine = gf.Engine(
backend="mom_sommerfeld",
segment_length=0.5,
sommerfeld_max_osc_panels=2_000_000, # escape hatch
sommerfeld_epsrel=1e-9, # tighter convergence test
)
with warnings.catch_warnings():
warnings.simplefilter("error", SommerfeldConvergenceWarning)
result = engine.solve(world) # now raises instead of returning junk
Accuracy knobs are reachable from the public API (0.15.0)¶
Engine.solve used to call solve_mom_sommerfeld(world, self) with
no keywords, so lambda_max_factor, epsabs and epsrel could not
be set by a user at all — a caller who hit the corner above had
nothing to tighten. They are now forwarded from four Engine fields,
following the image_max_terms / image_series_tol precedent:
Engine field |
backend keyword | default |
|---|---|---|
sommerfeld_lambda_max_factor |
lambda_max_factor |
200.0 |
sommerfeld_epsabs |
epsabs |
\(10^{-9}\) |
sommerfeld_epsrel |
epsrel |
\(10^{-7}\) |
sommerfeld_max_osc_panels |
max_osc_panels |
200 000 |
The defaults equal the previous signature defaults, so existing
callers get bit-identical results. The fields are not exposed
through gf.create_engine(...); construct gf.Engine(...) directly.
All four are echoed into FieldResult.metadata.
lambda_max_factor has two different units
On the top-layer path (all segments above \(h_1\)) the factor is measured in units of \(1 / d_{\text{rem}}\) — the decay length of the remainder — and saturates at 60, so raising it is harmless and eventually a no-op.
On the delegated \(n = 2\) cross-layer path
(sommerfeld_kernel_value forwards to
coupling.layered_green.two_layer_real_space_kernel when
\(z > h_1\) or \(z_s > h_1\)) the very same keyword is measured in
units of \(1 / \ell_{\text{char}}\) with \(\ell_{\text{char}}\) a
geometric characteristic length, is not saturated, and
raising it degrades the answer: the Hankel grid there must
resolve \(J_0(\lambda s)\) out to \(\lambda_{\max}\), so a large
factor exhausts the node budget and triggers
layered_green.SommerfeldResolutionWarning. On that path the
documented remedy is to lower the factor.
The two conventions are deliberately left unmerged — the delegated kernel is shared with the inductive-coupling code and calibrated against its own reference — but the difference is a real trap and is flagged at both sites in the source.
Reaction matrix assembly¶
mom_sommerfeld builds the \(N \times N\) reaction matrix in three
modes:
- Homogeneous (\(n = 1\)). Falls back on the closed-form homogeneous self-kernel — quadrature is unnecessary and would introduce numerical noise.
- Two-layer (\(n = 2\)). Uses the Tagg / Sunde self-kernel for the diagonal (the closed-form multi-image self-action) and the direct Sommerfeld quadrature for the off-diagonals. The two are consistent — each off-diagonal entry is the same physics evaluated by quadrature, and the diagonal is the closed-form geometric series for the same physics.
- Multilayer (\(n \ge 3\)). Uses the homogeneous line self-potential plus a layered self-correction obtained from the reflected remainder \(G_{\text{refl}}\) evaluated at \(s = 0\), \(|z - z_s| = 0\): the direct \(1/r\) singularity sits entirely in the analytically extracted part, which the line self-potential already accounts for, so the remainder is regular point-on-source. Its decay scale there is \(2 (h_1 - z_i)\).
Galerkin solve¶
The actual linear-system solve reuses the _galerkin_solve helper
from mom — the cluster augmenting rows are identical;
only the kernel changes.
Validity envelope¶
| Property | Range / value |
|---|---|
| Soil model | HomogeneousSoil, TwoLayerSoil, MultiLayerSoil |
| Frequency | quasi-static, \(f < 1\,\text{kHz}\) |
| Electrode placement, \(n \le 2\) | any; the cross-layer case (\(z > h_1\) or \(z_s > h_1\)) is supported and delegated to coupling.layered_green.two_layer_real_space_kernel (note the different lambda_max_factor units) |
| Electrode placement, \(n \ge 3\) | every segment strictly in the upper layer — a ValueError is raised for \(z_{\max} \ge h_1\); keep midpoints a few mm clear of \(h_1\) (1 mm distance floor) |
| Layer contrast | validated \(\rho_2/\rho_1 \in [10^{-2}, 10^{2}]\); accurate to \(\sim 10^{-11}\) up to \(10^{8}\) since 0.15.0 |
| Quadrature tolerances | \(\text{epsabs} = 10^{-9}\), \(\text{epsrel} = 10^{-7}\) (Engine.sommerfeld_epsabs / _epsrel) |
| Panel rule | 12-point Gauss–Legendre, 96 graded panels + 8 log panels/decade at the multiplier pole + one panel per \(J_0\) half period |
| \(\lambda_{\max}\) factor | 200, saturating at 60 / \(d_{\text{rem}}\) (Engine.sommerfeld_lambda_max_factor) |
| Oscillation panel budget | 200 000 (Engine.sommerfeld_max_osc_panels); binds for \(s / d_{\text{rem}} \gtrsim 1.9\cdot10^{4}\) |
| Mesh size \(N\) | practical limit \(\sim 300\) segments at acceptable runtime — but see the cost table below, which can move that limit down by three orders of magnitude |
The runtime cost of the engine grows as \(O(N^2)\) remainder
quadratures. In the ordinary regime each costs one vectorised
evaluation of a few thousand abscissae (≈ 1–3 ms), so a 32-segment
ring on a 2-layer soil takes ≈ 1 s (0.14.1: 121 s), \(N = 200\) ≈ 40 s
and \(N = 1000\) of the order of a quarter hour. This is acceptable for
cross-validation but not for production sweeps — use image_2layer /
cim / bem there.
The per-pair cost is not bounded by those few thousand abscissae. It scales with \(s / d_{\text{rem}}\), the number of \(J_0\) half periods that have to be resolved, and \(d_{\text{rem}}\) collapses to \(2\min(h_1, h_2)\) as soon as an intermediate layer is thin. Measured (summed over the refinement rungs actually used, one kernel evaluation each):
| Geometry | abscissae | wall time |
|---|---|---|
TwoLayerSoil(100, 500, h_1=2), \(s = 5\), \(z = z_s = 0.8\) |
3 924 | 1.3 ms |
| same, \(s = 50\) | 8 316 | 2.2 ms |
[100, 1000, 50], \(h = [2, 1]\), \(s = 50\), \(z = z_s = 1.9\) |
12 276 | 2.6 ms |
[100, 1000, 50], \(h = [2, 0.01]\), \(s = 50\), \(z = z_s = 2\) |
977 532 | 488 ms |
| same, \(s = 500\) | 4 803 516 | 2.13 s |
| same, \(s = 2000\) (budget-capped, warns) | 9 617 340 | 3.25 s |
Trigger: a centimetre-scale intermediate layer (\(n \ge 3\)) with
both points near the interface and a horizontal separation of order
\(10^2\)–\(10^3\,\text{m}\). At 0.5–3 s per pair an \(O(N^2)\) assembly
for a 60-segment mesh is 30 min – 3 h, and raising
sommerfeld_max_osc_panels to escape the accuracy warning scales that
up linearly. In such a soil do not use this engine for a solve — use
it pointwise, on the pairs you actually need.
Convergence and cost¶
- Quadrature accuracy. Measured against the Tagg / Sunde image series for \(n = 2\) over \(\rho_2/\rho_1 \in [0.2, 10]\), \(h_1 \in [1, 5]\,\text{m}\) and seven \((z, z_s)\) combinations including \(z = z_s = h_1\) — 1260 parameter sets in total:
| radial range | worst relative error | 99th pct | median |
|---|---|---|---|
| \(s \in [0.05, 50]\,\text{m}\) (stated envelope) | 3.4e-15 | 2.0e-15 | 0 (bit-exact) |
| \(s \in [100, 200]\,\text{m}\) | 8.7e-15 | 3.9e-15 | 2.2e-16 |
The previously documented "\(10^{-14}\) level" was measured on the analytic split alone and was about 10× optimistic there (worst 1.1e-13 inside the envelope, 1.4e-13 at \(s = 200\), median 1.4e-16); the pole-resolving log panel family added in 0.15.0 brings it back under \(10^{-14}\) with margin. Do not read the table as a guarantee: it is a measurement on a grid, and the median being exactly 0 means the reference series and the quadrature agree bit for bit on most of it. - Extreme layer contrast. Outside the soil range the accuracy no longer degrades the way it did up to 0.15.0. Measured at \(s = 0.5\), \(z = z_s = 0.8\), \(h_1 = 2\) against the resummed image series:
| \(\rho_2/\rho_1\) | before (split only) | 0.15.0 |
|---|---|---|
| \(10^{2}\) | −2.2e-16 | −2.2e-16 |
| \(10^{4}\) | −1.5e-13 | −1.5e-13 |
| \(10^{6}\) | −3.4e-4 | −7.7e-13 |
| \(10^{8}\) | −5.2e-2 | −2.8e-11 |
The \(10^{6}\) / \(10^{8}\) errors came from the
\(\lambda\)-scale \((1 - \Gamma_1(0)) / 2 h_1\) of the
multiple-reflection multiplier
\(1/(1 - \Gamma_1 e^{-2\lambda h_1})\), which the quadratically
graded base grid cannot reach (\(2.5\cdot10^{-9}\,\text{m}^{-1}\) at
\(10^{8}\) versus an innermost base panel of
\(1.6\cdot10^{-3}\,\text{m}^{-1}\)). The geometric panel family now
covers it. Note that these were returned after the internal
convergence test had already failed and logged at DEBUG — they
would now warn.
- Per-pair cost. ≈ 4000–13000 abscissae per (field, source) pair
in the ordinary regime (1–3 ms), evaluated vectorised. The count
scales with \(s / d_{\text{rem}}\) (the number of \(J_0\) half
periods that have to be resolved) and reaches \(10^{6}\)–\(10^{7}\)
abscissae / 0.5–3 s per single pair for a centimetre-thin
intermediate layer — see the cost table in
Validity envelope.
- Reduction. At \(\Gamma_1 \to 0\) — including a 2-layer stack
declared with \(\rho_1 = \rho_2\), where \(K_1 = 0\) — the
reflected remainder is identically zero and the engine returns
\(1/r + 1/r_{\text{img}}\) bit-exact. A 25 m ring at
\(z = 0.8\,\text{m}\) in TwoLayerSoil(100, 100, h_1=1) then
reproduces image and mom to the last bit (1.5750513784 Ω;
0.14.1 gave 1.4952 Ω, −5.1 %). With a real contrast
(TwoLayerSoil(100, 500, h_1=2)) the same ring agrees with mom
and image_2layer to 4e-8 (0.14.1: −5.7e-4).
Cross-validation notes¶
| Counterpart | Expected agreement | What is checked |
|---|---|---|
image (\(n = 1\)) |
bit-exact | quadrature short-circuits to closed form |
image (\(n = 2\), \(\rho_1 = \rho_2\)) |
bit-exact | \(\Gamma_1 \equiv 0\): the remainder vanishes identically |
mom (\(n \le 2\)) |
\(\lesssim 10^{-7}\) | same Galerkin scheme, kernel by quadrature instead of the closed-form series — this is the sharpest kernel-only comparison |
image_2layer (\(n = 2\)) |
\(\le 5\,\%\) | quadrature reproduces the geometric series; the residual is the different discretisation scheme, not the kernel |
cim (any \(n\)) |
\(\le 5\,\%\) | matrix-pencil fit accuracy |
bem (any \(n\)) |
\(\le 5\,\%\) | collocation on the same physics |
The engine's role is the absolute reference. Whenever the
closed-form layered engines disagree, the disagreement is measured
against mom_sommerfeld. The cross-engine validation harness report agreement tables relative to this engine.
References¶
- Sommerfeld, A. (1909). Über die Ausbreitung der Wellen in der drahtlosen Telegraphie. Annalen der Physik 28. The original paper introducing the integral.
- Watson, G. N. (1944). A Treatise on the Theory of Bessel Functions, 2nd ed., §13.2. Lipschitz' integral \(\int_0^\infty e^{-\lambda d} J_0(\lambda s)\,d\lambda = (s^2 + d^2)^{-1/2}\) — the identity behind the analytic extraction.
- Lucas, S. K. & Stone, H. A. (1995). Evaluating infinite integrals involving Bessel functions of arbitrary order. J. Comput. Appl. Math. 64. Panel quadrature between the zeros of \(J_0\), the strategy used for the oscillatory factor.
- Zou, J., Du, X. & Zhou, C. (2015). Fast calculation of the Green function of a point current source in a horizontal layered soil with a new complex path. IEEE Trans. Magn. 51(3). A deformed complex contour is an alternative way of taming the Bessel oscillation; it is not implemented here — the analytic extraction above removes the conditionally convergent part instead.
- Dwight, H. B. (1936). Calculation of resistances to ground. Reference DC resistances used in the cross-validation tests.
Example¶
import groundfield as gf
soil = gf.TwoLayerSoil(rho_1=100.0, rho_2=500.0, h_1=2.0)
world = gf.create_world(soil=soil)
gf.create_electrode(world, "rod", name="g1",
position=(0.0, 0.0, 0.0), length=1.5)
gf.create_source(world, attached_to="g1", magnitude=1.0)
# Reference engine — slow but methodologically independent.
engine = gf.create_engine(backend="mom_sommerfeld",
segment_length=0.1,
frequencies=[50.0])
result = world.solve(engine)
print(result.cluster_impedance("g1")[0])
API reference¶
mom_sommerfeld ¶
Method-of-Moments backend with direct Sommerfeld quadrature (mom_sommerfeld).
Mathematical / physical model
The other layered backends (image_nlayer, cim, bem) rely
on a closed-form representation of the layered Green's function
(real image series or complex images). This backend instead evaluates
the Sommerfeld integral
$$
\varphi(s, z; z_s) \;=\; \frac{\rho_1\, I}{4\pi}
!\int_0^{\infty}! \bigl[ e^{-\lambda |z - z_s|}
+ \Gamma_1(\lambda)\, e^{-\lambda (z + z_s)}\bigr]
J_0(\lambda s)\, d\lambda
$$
numerically, point by point, with adaptive Gauss–Kronrod
quadrature (scipy.integrate.quad). The recursive
\(\Gamma_1(\lambda)\) from
:func:groundfield.solver._layered.reflection_gamma is used as is —
no expansion, no fit. The result is therefore independent of the
expansion choices in image_nlayer / cim and serves as the
absolute reference inside the cross-engine validation.
Quadrature strategy
The integrand is not absolutely integrable: the direct term \(e^{-\lambda |z - z_s|} J_0(\lambda s)\) does not decay at all for two segments at the same depth (\(|z - z_s| = 0\)), and the integral converges only through the oscillation of \(J_0\). A finite real-axis truncation \([0, \lambda_{\max}]\) therefore leaves a sign-alternating error of order \(\sqrt{2 / (\pi \lambda_{\max} s)}\) that no quadrature tolerance can remove (fixed in 0.15.0; before that the backend could return a negative Green's function).
The kernel is therefore split into an analytic part and a genuinely decaying remainder, using Lipschitz' integral $$ \int_0^{\infty} e^{-\lambda d}\, J_0(\lambda s)\, d\lambda = \frac{1}{\sqrt{s^2 + d^2}}, \qquad d \ge 0 . $$ Three terms are integrated in closed form — the direct term (\(d = |z - z_s|\)), its air mirror (\(d = z + z_s\)) and the leading interface reflection with the \(\lambda \to \infty\) limit \(\Gamma_1(\infty) = K_1\) of the reflection coefficient (\(d = 2 h_1 - z - z_s\)). The remainder carries at least the exponential \(e^{-\lambda \min(2 h_1 - |z - z_s|,\; 2 h_1 - z - z_s + \Delta)}\), i.e. it decays on a strictly positive length scale even when both points sit on the layer interface, so a truncated quadrature is now controlled: the discarded tail is bounded by \(e^{-\lambda_{\max} d_{\text{rem}}}\).
The remainder is evaluated with fixed-order Gauss–Legendre panels on
a grid that (a) is graded towards \(\lambda = 0\) to resolve the
exponentials and \(\Gamma_1(\lambda)\), (b) carries a geometric
(log-spaced) family down to the \(\lambda\)-scale
\((1 - \Gamma_1(0)) / 2 h_1\) of the multiple-reflection multiplier,
which collapses towards \(\lambda = 0\) for \(|K_1| \to 1\), and
(c) is subdivided at half-period spacing \(\pi / s\) to resolve the
\(J_0\) oscillation. The panel count is refined until two successive
grids agree within max(epsabs, epsrel · |G|), which makes the
tolerance arguments meaningful (the previous adaptive
scipy.integrate.quad call could not honour them at all, because
its limit=400 subdivision cap was reached long before the
requested accuracy).
Failing that test is not silent: a
:class:SommerfeldConvergenceWarning is raised naming the achieved
versus the requested tolerance, the fact that the sign may be wrong
and the parameter to raise. Up to 0.15.0 the same condition was only
logged at DEBUG level, and — because the oscillation panel budget
made the resolved \(\lambda\)-range shrink with refinement — the
least accurate of the four grid values was returned. In the
corner \(s / d_{\text{rem}} \gtrsim 2 \cdot 10^4\) (a
centimetre-thin intermediate layer with a kilometre-scale horizontal
separation) that reproduced the original F04 failure mode: the kernel
returned \(-9.47 \cdot 10^{-5}\) where the far-field asymptote
\(G \to (2/s)\,\rho_n/\rho_1\) requires \(+5 \cdot 10^{-4}\),
with no Python-level warning.
The MoM resolution itself (Galerkin scheme with one constraint per
cluster) is the same as :mod:groundfield.solver.mom; only the
underlying Z-matrix kernel differs.
Validity
- Quasi-static, \(f < 1\,\mathrm{kHz}\).
- All segments in the upper layer, except for the \(n = 2\)
cross-layer case, which is delegated to
:mod:
groundfield.coupling.layered_green. - Slower than the closed-form backends but methodologically independent. Use it as the reference in cross-engine tests on layered worlds with hard contrasts: the pointwise kernel matches the analytic Tagg/Sunde series to \(\lesssim 10^{-13}\) relative (median \(\sim 10^{-16}\)) for \(\rho_2 / \rho_1 \in [10^{-2}, 10^{2}]\), so any deviation of a closed-form backend is that backend's series / fit error.
- Distances are floored at 1 mm (
_MIN_DISTANCE). That floor is applied to the analytic \(K_1 / \sqrt{s^2 + d_{\text{int}}^2}\) interface-image term but not to the matching \(K_1 e^{-\lambda d_{\text{int}}}\) subtraction inside the numerically integrated residual, so for \(d_{\text{int}} = 2 h_1 - z - z_s < 1\,\mathrm{mm}\) the split is no longer algebraically exact and the reflected part is saturated by the floor:_reflected_integral(s=0, dz=0, z+z_s=2(h_1 - 10^{-5}))returns 667.561 where the exact reflected series is 33334.23 (−98 %); at \(d_{\text{int}} = 2\,\mathrm{mm}\) the same call is exact. This is the documented distance-floor convention (identical to the homogeneousimagebackend), not a sign error, but it is far sharper here than a 1 mm geometric floor suggests. Reachable through the \(n \ge 3\) diagonal when a segment midpoint sits within 0.5 mm of the layer interface — keep segment midpoints at least a few millimetres clear of \(h_1\).
References
- Sommerfeld, A. (1909). Über die Ausbreitung der Wellen in der drahtlosen Telegraphie. Annalen der Physik 28.
- Watson, G. N. (1944). A Treatise on the Theory of Bessel Functions, 2nd ed., §13.2 — Lipschitz' integral \(\int_0^\infty e^{-\lambda d} J_0(\lambda s)\,d\lambda = (s^2 + d^2)^{-1/2}\), the identity used for the analytic extraction.
- Lucas, S. K., & Stone, H. A. (1995). Evaluating infinite integrals involving Bessel functions of arbitrary order. J. Comput. Appl. Math. 64 — panel quadrature between the zeros of \(J_0\).
- Zou, J., Du, X., & Zhou, C. (2015). Fast calculation of the Green function of a point current source in a horizontal layered soil with a new complex path. IEEE Trans. Magn. 51(3). A complex contour is an alternative to the analytic extraction used here; it is not implemented.
SommerfeldConvergenceWarning ¶
Bases: UserWarning
The reflected-remainder quadrature did not reach epsrel.
Emitted (in addition to the logger record) whenever the grid
refinement ladder of :func:_reflected_integral exhausts all of
:data:_REFINEMENTS without two successive grids agreeing within
max(epsabs, epsrel · |G|). The returned value is then not
converged, and because the integrand is oscillatory
(\(J_0(\lambda s)\)) a non-converged panel grid does not merely
lose digits — it can return a Green's function with the wrong
sign (the pre-0.15.0 failure mode of this backend, review-pass-9
finding F04). Up to 0.15.0 the same situation was only logged at
DEBUG level and the least accurate of the four grid values
was returned silently.
Two mechanisms reach this branch:
- Oscillation budget.
max_osc_panels(default 200 000, i.e.Engine.sommerfeld_max_osc_panels) bounds the half-period panel family \(\pi / s\) that resolves \(J_0(\lambda s)\) on \([0, 34 / d_{\text{rem}}]\). The budget binds when \(s / d_{\text{rem}} \gtrsim 1.9 \cdot 10^4\) — a centimetre-thin intermediate layer (\(d_{\text{rem}} = 2\min(h_1,h_2)\)) combined with a kilometre-scale horizontal separation. Remedy: raisemax_osc_panels(runtime and memory grow linearly with it). - Layer contrast. For \(|K_1| \to 1\) the multiple-reflection
multiplier \(1/(1 - \Gamma_1 e^{-2\lambda h_1})\) develops a
\(\lambda\)-scale \((1 - \Gamma_1(0)) / 2 h_1\) that the graded
base grid has to resolve. Since 0.15.0 a geometric panel family
covers that scale explicitly, which removed the historic
\(-12.9\,\%\) error at \(\rho_2/\rho_1 = 10^8\); if the
warning still appears here the contrast is outside the validated
envelope \(\rho_2/\rho_1 \in [10^{-2}, 10^{2}]\) and no knob
fixes it — use
image_2layer(exact geometric series) instead.
Silence with
warnings.simplefilter("ignore", SommerfeldConvergenceWarning)
only if the affected entries are known to be negligible.
solve_mom_sommerfeld ¶
solve_mom_sommerfeld(
world: "World",
engine: "Engine",
*,
lambda_max_factor: float = 200.0,
epsabs: float = 1e-09,
epsrel: float = 1e-07,
max_osc_panels: int = _MAX_OSC_PANELS
) -> FieldResult
Galerkin MoM with direct Sommerfeld quadrature for layered soil.
This is a methodologically independent backend used as the
reference inside :func:groundfield.compare_engines for layered
worlds with hard contrasts. Every off-diagonal reaction-matrix
entry is one evaluation of :func:sommerfeld_kernel_value, i.e.
the closed-form direct pair plus a numerically integrated
reflected remainder; no image series and no fit enter the kernel.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
world
|
'World'
|
World to evaluate. |
required |
engine
|
'Engine'
|
Engine configuration. |
required |
lambda_max_factor
|
float
|
Truncation of the remainder quadrature in units of the
remainder decay length \(1 / d_{\text{rem}}\); saturates at
60. See :func: |
200.0
|
epsabs
|
float
|
Convergence thresholds of the remainder quadrature. |
1e-09
|
epsrel
|
float
|
Convergence thresholds of the remainder quadrature. |
1e-09
|
max_osc_panels
|
int
|
Budget for the \(J_0\) half-period panel family. |
_MAX_OSC_PANELS
|
Returns:
| Type | Description |
|---|---|
FieldResult
|
|
Warns:
| Type | Description |
|---|---|
SommerfeldConvergenceWarning
|
Once per solve, if any reaction-matrix entry's remainder quadrature failed to converge. |
Notes
The four accuracy keywords are wired to the
:class:~groundfield.solver.engine.Engine fields
sommerfeld_lambda_max_factor, sommerfeld_epsabs,
sommerfeld_epsrel and sommerfeld_max_osc_panels. The
signature defaults are kept identical to those field defaults, so a
direct call without keywords behaves exactly like
:meth:~groundfield.solver.engine.Engine.solve. Up to 0.14.1
Engine.solve called this function with no keywords at all, so
none of the knobs was reachable through the public API.
Source code in src/groundfield/solver/mom_sommerfeld.py
1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 | |
sommerfeld_kernel_value ¶
sommerfeld_kernel_value(
stack: LayerStack,
s: float,
z: float,
z_s: float,
*,
lambda_max_factor: float = 200.0,
epsabs: float = 1e-09,
epsrel: float = 1e-07,
max_osc_panels: int = _MAX_OSC_PANELS,
issues: list[_QuadratureIssue] | None = None
) -> float
Evaluate the layered-soil Sommerfeld kernel at one point.
Returns the full top-layer kernel
$$
G(s, z, z_s) \;=\; \int_0^{\infty}
\frac{e^{-\lambda |z - z_s|}
+ \Gamma_1 e^{-\lambda (2 h_1 - |z - z_s|)}
+ e^{-\lambda (z + z_s)}
+ \Gamma_1 e^{-\lambda (2 h_1 - z - z_s)}}
{1 - \Gamma_1(\lambda)\, e^{-2 \lambda h_1}}
J_0(\lambda s)\, d\lambda ,
$$
i.e. both image families plus the full multiple-reflection
multiplier between the free surface (\(R_{\text{air}} = +1\))
and the layer interface. The potential of a point source \(I\)
is then ρ_1 · I · G / (4 π).
The integral is not evaluated by a bare real-axis quadrature:
the two \(\Gamma_1 \to 0\) terms (which converge only through
the oscillation of \(J_0\) and are the reason the pre-0.15.0
implementation could return a negative value) are taken from
Lipschitz' integral
\(\int_0^\infty e^{-\lambda d} J_0(\lambda s) d\lambda
= (s^2 + d^2)^{-1/2}\), so that
$$
G = \frac{1}{\sqrt{s^2 + (z - z_s)^2}}
+ \frac{1}{\sqrt{s^2 + (z + z_s)^2}}
+ G_{\text{refl}},
$$
and only the exponentially decaying reflected remainder
\(G_{\text{refl}}\) is integrated numerically — see
:func:_reflected_integral.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
stack
|
LayerStack
|
Layer stack. |
required |
s
|
float
|
Cylindrical radius \(s = \sqrt{(x - x_s)^2 + (y - y_s)^2}\) in metres. |
required |
z
|
float
|
Field-point depth and source depth (positive into the soil). |
required |
z_s
|
float
|
Field-point depth and source depth (positive into the soil). |
required |
lambda_max_factor
|
float
|
Truncation of the remainder quadrature, in units of \(1 / d_{\text{rem}}\), where \(d_{\text{rem}}\) is the decay length of the remainder (not, as before 0.15.0, a characteristic length of the geometry). The discarded tail is bounded by \(e^{-\texttt{lambda\_max\_factor}}\); the factor saturates at 60 (\(\sim 10^{-26}\)), so raising it beyond the default cannot change the result. .. warning::
The same keyword has a different meaning on the
delegated \(n = 2\) cross-layer path. When
|
200.0
|
epsabs
|
float
|
Absolute / relative thresholds of the grid-refinement
convergence test of the remainder quadrature. |
1e-09
|
epsrel
|
float
|
Absolute / relative thresholds of the grid-refinement
convergence test of the remainder quadrature. |
1e-09
|
max_osc_panels
|
int
|
Budget for the half-period panel family that resolves the
\(J_0(\lambda s)\) oscillation, default 200 000 panels
(× 12 Gauss nodes). Only binds for
\(s / d_{\text{rem}} \gtrsim 1.9 \cdot 10^{4}\), i.e. a
centimetre-thin intermediate layer combined with a
kilometre-scale horizontal separation; there the coverage is
incomplete, a :class: |
_MAX_OSC_PANELS
|
issues
|
list[_QuadratureIssue] | None
|
Optional collector for non-convergence diagnostics; see
:func: |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
G |
float
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
For \(n \ge 3\) with \(z > h_1\) or \(z_s > h_1\): the expression above is a top-layer form, so the kernel refuses instead of silently returning a diverging number (before 0.15.0 it returned \(\approx -2 \cdot 10^{127}\) there). |
Warns:
| Type | Description |
|---|---|
SommerfeldConvergenceWarning
|
If the remainder quadrature does not reach the requested tolerance. The returned value is then not converged and its sign may be wrong. |
Notes
Limits and special cases:
stack.n_layers == 1(homogeneous, \(\Gamma_1 \equiv 0\)): short-circuited to \(1/r + 1/r_{\text{img}}\), bit-exact.- \(\rho_1 = \rho_2\) in a declared 2-layer stack (\(K_1 = 0\), hence \(\Gamma_1 \equiv 0\)): the reflected remainder is identically zero, so the same closed form is returned bit-exact. This is the strongest available accuracy test of the backend.
stack.n_layers == 2with \(z > h_1\) or \(z_s > h_1\): delegated to :func:groundfield.coupling.layered_green.two_layer_real_space_kernel, which solves the cross-layer spectral matching.
Distances are floored at 1 mm (_MIN_DISTANCE), consistently
with the homogeneous image backend, so the coincident-point
limit stays finite.
Source code in src/groundfield/solver/mom_sommerfeld.py
772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 | |
Related material¶
- ADR-0002 — engine selection heuristic; this engine is the reference for the layered family.