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])
2525def 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])
6464def 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):
103103def 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])
221250def 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