Skip to content

Commit 60c5121

Browse files
committed
[TEST] Add new comprehensive tests for magnetic benchmarks including edge cases and symmetry validation
- Introduced new test file `test_magnetic_benchmark_II.py` covering multiple scenarios: - Symmetry checks for TMI profiles. - Edge cases for zero and negative susceptibilities. - Observation distance decay validation. - Kernel property tests for symmetry and resolution. - Enhanced and streamlined existing tests in `test_magnetic_benchmark.py`: - Adjusted formatting for clearer readability. - Updated comments and formulas for analytical benchmarks to improve documentation consistency.
1 parent 08e23b5 commit 60c5121

File tree

2 files changed

+456
-55
lines changed

2 files changed

+456
-55
lines changed

tests/test_common/test_modules/test_magnetic_benchmark.py

Lines changed: 85 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,19 @@
1515
# =============================================================================
1616

1717
@pytest.mark.parametrize("inclination,declination,expected_lx,expected_ly,expected_lz", [
18-
(0.0, 0.0, 1.0, 0.0, 0.0), # Horizontal north
19-
(90.0, 0.0, 0.0, 0.0, 1.0), # Vertical down (north pole)
20-
(-90.0, 0.0, 0.0, 0.0, -1.0), # Vertical up (south pole)
21-
(0.0, 90.0, 0.0, 1.0, 0.0), # Horizontal east
22-
(45.0, 0.0, 0.707107, 0.0, 0.707107), # 45° down to north
23-
(45.0, 45.0, 0.5, 0.5, 0.707107), # 45° down, 45° east
18+
(0.0, 0.0, 1.0, 0.0, 0.0), # Horizontal north
19+
(90.0, 0.0, 0.0, 0.0, 1.0), # Vertical down (north pole)
20+
(-90.0, 0.0, 0.0, 0.0, -1.0), # Vertical up (south pole)
21+
(0.0, 90.0, 0.0, 1.0, 0.0), # Horizontal east
22+
(45.0, 0.0, 0.707107, 0.0, 0.707107), # 45° down to north
23+
(45.0, 45.0, 0.5, 0.5, 0.707107), # 45° down, 45° east
2424
])
2525
def test_direction_cosines(inclination, declination, expected_lx, expected_ly, expected_lz):
2626
"""Test direction cosines computation for various field geometries."""
2727
l = _direction_cosines(inclination, declination)
2828

2929
# Check components match expectations
30-
np.testing.assert_allclose(l[0], expected_lx, atol=1e-5,
30+
np.testing.assert_allclose(l[0], expected_lx, atol=1e-5,
3131
err_msg=f"l_x mismatch for I={inclination}, D={declination}")
3232
np.testing.assert_allclose(l[1], expected_ly, atol=1e-5,
3333
err_msg=f"l_y mismatch for I={inclination}, D={declination}")
@@ -55,11 +55,11 @@ def test_direction_cosines_wraparound():
5555
# =============================================================================
5656

5757
@pytest.mark.parametrize("inclination,declination,intensity", [
58-
(90.0, 0.0, 50000.0), # Vertical field (pole)
59-
(0.0, 0.0, 45000.0), # Horizontal field (equator)
60-
(60.0, 10.0, 48000.0), # Mid-latitude field
61-
(45.0, 45.0, 50000.0), # Oblique field
62-
(-30.0, -15.0, 35000.0), # Southern hemisphere
58+
(90.0, 0.0, 50000.0), # Vertical field (pole)
59+
(0.0, 0.0, 45000.0), # Horizontal field (equator)
60+
(60.0, 10.0, 48000.0), # Mid-latitude field
61+
(45.0, 45.0, 50000.0), # Oblique field
62+
(-30.0, -15.0, 35000.0), # Southern hemisphere
6363
])
6464
def test_path_equivalence(inclination, declination, intensity):
6565
"""Test that pre-projected and raw V computation paths give identical results."""
@@ -82,8 +82,8 @@ def test_path_equivalence(inclination, declination, intensity):
8282

8383
# Should match to floating point precision
8484
np.testing.assert_allclose(
85-
tmi_path1,
86-
tmi_path2[0],
85+
tmi_path1,
86+
tmi_path2[0],
8787
rtol=1e-10,
8888
err_msg=f"Paths differ for I={inclination}, D={declination}, F={intensity}"
8989
)
@@ -103,19 +103,19 @@ def test_path_equivalence(inclination, declination, intensity):
103103
def test_magnetics_sphere_analytical_benchmark_induced_only(inclination, declination, intensity_nT, rtol):
104104
"""
105105
Benchmark comparing induced-only TMI against analytical solution for a uniformly
106-
susceptible sphere in a vertical inducing field.
106+
susceptible sphere with observation points along the vertical axis.
107107
108-
Geometry mirrors gravity sphere benchmark: observe along vertical line above center.
108+
For a sphere with induced magnetization M = χ·B₀/μ₀, the magnetic field is that
109+
of a magnetic dipole with moment m = (4/3)πR³·M.
109110
110-
Analytical (outside sphere, along axis):
111-
m = V * M = (4/3)π R^3 * (χ * B0 / μ0)
112-
Bz = μ0/(4π) * (2 m) / r^3 (dipole field on axis)
113-
ΔT = B · l = Bz (since l = z-hat for vertical field)
114-
=> ΔT = (2/3) * R^3 * χ * B0 / r^3 [Tesla]
115-
Convert to nT by × 1e9 if B0 in Tesla. Here intensity_nT is in nT, so use directly:
116-
ΔT[nT] = (2/3) * R^3 * χ * intensity_nT / r^3
117-
118-
We accept ~15–20% error due to voxelization discretization.
111+
The TMI anomaly for observation on the z-axis above the sphere center is:
112+
ΔT = (R³·χ·F)/(3r³) · [3·lz² - 1 + 2·(lx² + ly²)]
113+
114+
Where l = [lx, ly, lz] are the direction cosines of the IGRF field.
115+
116+
Special cases:
117+
- Vertical field (I=90°): l=[0,0,1] → ΔT = (2/3)·R³·χ·F/r³ (your current formula)
118+
- Horizontal field (I=0°): l=[±1,0,0] or [0,±1,0] → ΔT = -(1/3)·R³·χ·F/r³
119119
"""
120120

121121
# Sphere parameters
@@ -140,7 +140,7 @@ def test_magnetics_sphere_analytical_benchmark_induced_only(inclination, declina
140140
radius=np.array([200.0, 200.0, 800.0]),
141141
)
142142

143-
# Magnetic kernel (pre-projected TMI kernel per voxel recommended by plan)
143+
# Magnetic kernel
144144
igrf_params = {
145145
"inclination": inclination,
146146
"declination": declination,
@@ -152,12 +152,10 @@ def test_magnetics_sphere_analytical_benchmark_induced_only(inclination, declina
152152
geophysics_grid, igrf_params, compute_tmi=True, units_nT=True
153153
)
154154
except TypeError:
155-
# Some implementations may return the kernel directly rather than a dict; handle both
156155
mag_kern_out = calculate_magnetic_gradient_tensor(
157156
geophysics_grid, igrf_params
158157
)
159158

160-
# Support both dict output with key 'tmi_kernel' or direct array output
161159
if isinstance(mag_kern_out, dict):
162160
tmi_kernel = mag_kern_out.get("tmi_kernel", None)
163161
if tmi_kernel is None:
@@ -175,39 +173,70 @@ def test_magnetics_sphere_analytical_benchmark_induced_only(inclination, declina
175173
vc = voxel_centers[sl]
176174
inside = (np.linalg.norm(vc - center, axis=1) <= R).astype(float)
177175
chi_vox = inside * chi
178-
# Forward model: sum(chi * tmi_kernel)
179176
numerical_tmi.append(np.sum(chi_vox * tmi_kernel))
180177

181178
numerical_tmi = -np.array(numerical_tmi)
182179

183-
# Analytical TMI along axis (in nT)
180+
# Get field direction cosines for analytical solution
181+
l = _direction_cosines(inclination, declination)
182+
lx, ly, lz = l[0], l[1], l[2]
183+
184+
# Analytical TMI for observation on z-axis above sphere center
185+
# For a magnetic dipole with moment m = (4/3)πR³·(χ·F):
186+
# The field components at (0, 0, r) are:
187+
# Bx = (μ₀/4π) · (m/r³) · 3·mx·mz / |m|
188+
# By = (μ₀/4π) · (m/r³) · 3·my·mz / |m|
189+
# Bz = (μ₀/4π) · (m/r³) · (3·mz² - |m|²) / |m|
190+
# Where m = [mx, my, mz] ∝ [lx, ly, lz]
191+
#
192+
# For induced magnetization: m ∝ [lx, ly, lz]
193+
# TMI = Bx·lx + By·ly + Bz·lz
194+
#
195+
# After simplification:
196+
# ΔT = (R³·χ·F) / (3·r³) · [3·lz² - 1 + 2·(lx² + ly²)]
197+
# Since lx² + ly² + lz² = 1:
198+
# ΔT = (R³·χ·F) / (3·r³) · [3·lz² - 1 + 2·(1 - lz²)]
199+
# ΔT = (R³·χ·F) / (3·r³) · [3·lz² + 2 - 2·lz² - 1]
200+
# ΔT = (R³·χ·F) / (3·r³) · [lz² + 1] # Incorrect simplification
201+
#
202+
# Correct formula:
203+
# ΔT = (R³·χ·F) / (3·r³) · [2·lz² - lx² - ly²]
204+
# Or equivalently:
205+
# ΔT = (R³·χ·F) / (3·r³) · [2·lz² - (1 - lz²)]
206+
# ΔT = (R³·χ·F) / (3·r³) · [3·lz² - 1]
207+
184208
analytical_tmi = []
185209
for z in observation_heights:
186210
r = abs(z - center[2])
187211
if r <= R:
188-
# Inside sphere (not expected in this test set). Use outer formula at R for continuity.
189212
r = R
190-
dT = (2.0 / 3.0) * (R ** 3) * chi * intensity_nT / (r ** 3)
213+
214+
# Correct analytical formula for induced dipole on axis
215+
# ΔT = (R³·χ·F) / (3·r³) · [3·lz² - 1]
216+
dT = (R ** 3) * chi * intensity_nT / (3.0 * r ** 3) * (3 * lz**2 - 1)
191217
analytical_tmi.append(dT)
192218

193219
analytical_tmi = np.array(analytical_tmi)
194220

195221
# Report
196222
print("\n=== Magnetics Sphere Benchmark (Induced-only TMI) ===")
223+
print(f"Field: I={inclination}°, D={declination}°, F={intensity_nT} nT")
224+
print(f"Direction cosines: lx={lx:.4f}, ly={ly:.4f}, lz={lz:.4f}")
197225
print(f"Sphere: R={R}m, center={center.tolist()}, χ={chi}")
198226
print(f"Observation heights: {observation_heights}")
199227
print(f"Numerical ΔT (nT): {numerical_tmi}")
200228
print(f"Analytical ΔT (nT): {analytical_tmi}")
201229
if np.all(analytical_tmi != 0):
202-
print(
203-
f"Relative error (%): {np.abs((numerical_tmi - analytical_tmi) / analytical_tmi) * 100}"
204-
)
230+
print(f"Relative error (%): {np.abs((numerical_tmi - analytical_tmi) / analytical_tmi) * 100}")
231+
else:
232+
print(f"Absolute difference (nT): {np.abs(numerical_tmi - analytical_tmi)}")
205233

206234
# Tolerance varies by field geometry (vertical fields are most accurate)
207235
np.testing.assert_allclose(
208236
numerical_tmi,
209237
analytical_tmi,
210238
rtol=rtol,
239+
atol=1.0, # Add absolute tolerance for near-zero values
211240
err_msg=(
212241
f"Magnetic TMI calculation deviates significantly from analytical sphere solution "
213242
f"for I={inclination}°, D={declination}°"
@@ -216,7 +245,7 @@ def test_magnetics_sphere_analytical_benchmark_induced_only(inclination, declina
216245

217246

218247
@pytest.mark.parametrize("inclination,declination,intensity_nT", [
219-
(90.0, 0.0, 50000.0),
248+
(90.0, 0.0, 50000.0),
220249
])
221250
def test_magnetics_line_profile_symmetry_induced_only(inclination, declination, intensity_nT):
222251
"""
@@ -236,9 +265,9 @@ def test_magnetics_line_profile_symmetry_induced_only(inclination, declination,
236265
z_obs = 600.0
237266

238267
centers = np.column_stack([
239-
x_profile,
240-
np.full_like(x_profile, y_center),
241-
np.full_like(x_profile, z_obs),
268+
x_profile,
269+
np.full_like(x_profile, y_center),
270+
np.full_like(x_profile, z_obs),
242271
])
243272

244273
geophysics_grid = CenteredGrid(
@@ -248,9 +277,9 @@ def test_magnetics_line_profile_symmetry_induced_only(inclination, declination,
248277
)
249278

250279
igrf_params = {
251-
"inclination": inclination,
252-
"declination": declination,
253-
"intensity": intensity_nT,
280+
"inclination": inclination,
281+
"declination": declination,
282+
"intensity" : intensity_nT,
254283
}
255284

256285
try:
@@ -291,7 +320,7 @@ def test_magnetics_line_profile_symmetry_induced_only(inclination, declination,
291320
center_idx = len(tmi_profile) // 2
292321

293322
left = tmi_profile[:center_idx]
294-
right = tmi_profile[center_idx + 1 :][::-1]
323+
right = tmi_profile[center_idx + 1:][::-1]
295324

296325
print("\n=== Magnetics Line Profile Symmetry (Induced-only TMI) ===")
297326
print(f"x_profile: {x_profile}")
@@ -322,9 +351,9 @@ def test_multiple_devices(n_devices):
322351
# Create a grid of observation points
323352
x_coords = np.linspace(400, 600, n_devices)
324353
centers = np.column_stack([
325-
x_coords,
326-
np.full(n_devices, 500.0),
327-
np.full(n_devices, 600.0),
354+
x_coords,
355+
np.full(n_devices, 500.0),
356+
np.full(n_devices, 600.0),
328357
])
329358

330359
grid = CenteredGrid(
@@ -476,7 +505,8 @@ def test_kernel_decay_with_distance(distance_multiplier):
476505
err_msg=f"Decay should be approximately 1/r³ (expected {expected_ratio:.2f}, got {actual_ratio:.2f})")
477506

478507
print(f"✓ Decay follows 1/r³ law within tolerance")
479-
508+
509+
480510
# =============================================================================
481511
# Kernel Property Tests
482512
# =============================================================================
@@ -500,16 +530,16 @@ def test_kernel_symmetry():
500530
# For resolution [rx, ry, rz]: actual points are [rx+1, ry+1, rz+1]
501531
nx = 2 * (20 // 2) + 1 # 21
502532
ny = 2 * (20 // 2) + 1 # 21
503-
nz = 20 + 1 # 21
504-
533+
nz = 20 + 1 # 21
534+
505535
# Verify expected shape
506536
expected_voxels = nx * ny * nz
507537
assert tmi_kernel.shape[0] == expected_voxels, \
508538
f"Expected {expected_voxels} voxels, got {tmi_kernel.shape[0]}"
509539

510540
# Reshape kernel to 3D grid (z, y, x ordering for numpy convention)
511541
kernel_3d = tmi_kernel.reshape((nx, ny, nz))
512-
kernel_3d[:,:,0]
542+
kernel_3d[:, :, 0]
513543

514544
# For vertical field, kernel should be symmetric about vertical axis
515545
# Check horizontal slices are approximately radially symmetric
@@ -518,8 +548,8 @@ def test_kernel_symmetry():
518548

519549
# Check that corners are approximately equal (radial symmetry)
520550
corners = [
521-
slice_mid[0, 0], slice_mid[0, -1],
522-
slice_mid[-1, 0], slice_mid[-1, -1]
551+
slice_mid[0, 0], slice_mid[0, -1],
552+
slice_mid[-1, 0], slice_mid[-1, -1]
523553
]
524554

525555
# All corners should be similar for radial symmetry
@@ -536,9 +566,9 @@ def test_v_components_reusability():
536566

537567
# Different IGRF scenarios
538568
igrf_scenarios = [
539-
{"inclination": 90.0, "declination": 0.0, "intensity": 50000.0},
540-
{"inclination": 0.0, "declination": 0.0, "intensity": 45000.0},
541-
{"inclination": 60.0, "declination": 30.0, "intensity": 48000.0},
569+
{"inclination": 90.0, "declination": 0.0, "intensity": 50000.0},
570+
{"inclination": 0.0, "declination": 0.0, "intensity": 45000.0},
571+
{"inclination": 60.0, "declination": 30.0, "intensity": 48000.0},
542572
]
543573

544574
chi = np.full(V.shape[1], 0.01)

0 commit comments

Comments
 (0)