Skip to content

Commit b49c6fa

Browse files
authored
[ENH] Add magnetic forward modeling for Total Magnetic Intensity (TMI) calculation (#39)
# Add magnetics (TMI) forward modeling to geophysics workflows Introduce functionality for induced-only TMI anomaly computation using precomputed voxel-based TMI kernels and geologic unit susceptibilities. Updates include: - `compute_tmi`: Core TMI calculation logic based on magnetics kernel and voxel susceptibilities in `fw_magnetic.py`. - Extended `GeophysicsInput` to include magnetics-specific fields such as `mag_kernel`, `susceptibilities`, and `igrf_params`. - Modified `model_api.py` to integrate magnetics into geophysics workflows alongside gravity computations. - Updated `solutions.py` to include magnetics in forward modeling outputs. Ensures magnetics functionality remains optional without impacting gravity workflows. ## Additional changes - Updated `model_api.py` to make gravity computations conditional, enabling workflows without gravity inputs. - Modified `GeophysicsInput` to allow `tz` and `densities` as optional fields for magnetics-only workflows. - Added `tmi` alias property in `solutions.py` for Total Magnetic Intensity outputs. - Improved formatting in geophysics README for better readability. - Added analytical and symmetry benchmark tests for magnetics implementation validation. - Included placeholder magnetics gradient computation module with core physics for initial testing.
2 parents 8fafc7c + 60c5121 commit b49c6fa

File tree

9 files changed

+1382
-15
lines changed

9 files changed

+1382
-15
lines changed

gempy_engine/API/model/model_api.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from ...core.data.interp_output import InterpOutput
88
from ...core.data.geophysics_input import GeophysicsInput
99
from ...modules.geophysics.fw_gravity import compute_gravity
10+
from ...modules.geophysics.fw_magnetic import compute_tmi
1011
from ...core.data.dual_contouring_mesh import DualContouringMesh
1112
from ..dual_contouring.multi_scalar_dual_contouring import dual_contouring_multi_scalar
1213
from ..interp_single.interp_features import interpolate_n_octree_levels
@@ -46,12 +47,31 @@ def compute_model(interpolation_input: InterpolationInput, options: Interpolatio
4647

4748
if geophysics_input is not None:
4849
first_level_last_field: InterpOutput = output[0].outputs_centers[-1]
49-
gravity = compute_gravity(
50-
geophysics_input=geophysics_input,
51-
root_ouput=first_level_last_field
52-
)
50+
51+
# Gravity (optional)
52+
if getattr(geophysics_input, 'tz', None) is not None and getattr(geophysics_input, 'densities', None) is not None:
53+
gravity = compute_gravity(
54+
geophysics_input=geophysics_input,
55+
root_ouput=first_level_last_field
56+
)
57+
else:
58+
gravity = None
59+
60+
# Magnetics (optional)
61+
try:
62+
if getattr(geophysics_input, 'mag_kernel', None) is not None and getattr(geophysics_input, 'susceptibilities', None) is not None:
63+
magnetics = compute_tmi(
64+
geophysics_input=geophysics_input,
65+
root_ouput=first_level_last_field
66+
)
67+
else:
68+
magnetics = None
69+
except Exception:
70+
# Keep gravity working even if magnetics paths are incomplete
71+
magnetics = None
5372
else:
5473
gravity = None
74+
magnetics = None
5575

5676
# endregion
5777

@@ -71,6 +91,7 @@ def compute_model(interpolation_input: InterpolationInput, options: Interpolatio
7191
octrees_output=output,
7292
dc_meshes=meshes,
7393
fw_gravity=gravity,
94+
fw_magnetics=magnetics,
7495
block_solution_type=options.block_solutions_type
7596
)
7697

gempy_engine/core/data/centered_grid.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,49 @@ def create_irregular_grid_kernel(grid_resolution, scaling_factor, base_spacing=0
9191
flattened_right_voxel_edges = np.vstack(tuple(map(np.ravel, right_voxel_edges))).T.astype("float64")
9292

9393
return flattened_grid_centers, flattened_left_voxel_edges, flattened_right_voxel_edges
94+
95+
def get_number_of_voxels_per_device(self) -> int:
96+
"""
97+
Calculate the number of voxels in the kernel grid for a single device.
98+
99+
Returns:
100+
int: Number of voxels per observation device
101+
102+
Notes:
103+
- X and Y axes use symmetric grids: (resolution + 1) points each
104+
- Z axis uses asymmetric grid (downward only): (resolution + 1) points
105+
- Total voxels = (rx + 1) × (ry + 1) × (rz + 1)
106+
107+
Example:
108+
>>> grid = CenteredGrid(centers=[[500, 500, 600]],
109+
... resolution=[10, 10, 10],
110+
... radius=[100, 100, 100])
111+
>>> grid.get_number_of_voxels_per_device()
112+
1331 # = 11 × 11 × 11
113+
"""
114+
resolution = np.atleast_1d(self.resolution)
115+
116+
# Calculate points per axis following the create_irregular_grid_kernel logic
117+
n_x = int(resolution[0] // 2) * 2 + 1 # Symmetric: 2 * (res//2) + 1
118+
n_y = int(resolution[1] // 2) * 2 + 1 # Symmetric: 2 * (res//2) + 1
119+
n_z = int(resolution[2] // 1) + 1 # Asymmetric: res + 1
120+
121+
return n_x * n_y * n_z
122+
123+
def get_total_number_of_voxels(self) -> int:
124+
"""
125+
Calculate the total number of voxels across all observation devices.
126+
127+
Returns:
128+
int: Total number of voxels (n_devices × voxels_per_device)
129+
130+
Example:
131+
>>> grid = CenteredGrid(centers=[[500, 500, 600], [600, 500, 600]],
132+
... resolution=[10, 10, 10],
133+
... radius=[100, 100, 100])
134+
>>> grid.get_total_number_of_voxels()
135+
2662 # = 2 devices × 1331 voxels
136+
"""
137+
n_devices = self.centers.shape[0]
138+
voxels_per_device = self.get_number_of_voxels_per_device()
139+
return n_devices * voxels_per_device
Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from dataclasses import dataclass
2-
from typing import Annotated
2+
from typing import Annotated, Optional
33

44
import numpy as np
55

@@ -8,5 +8,14 @@
88

99
@dataclass
1010
class GeophysicsInput:
11-
tz: Annotated[np.ndarray, numpy_array_short_validator]
12-
densities: Annotated[np.ndarray, numpy_array_short_validator]
11+
# Gravity inputs (optional to allow magnetics-only workflows)
12+
tz: Optional[Annotated[np.ndarray, numpy_array_short_validator]] = None
13+
densities: Optional[Annotated[np.ndarray, numpy_array_short_validator]] = None
14+
15+
# Magnetics inputs (optional for Phase 1)
16+
# Pre-projected TMI kernel per voxel (per device geometry), shape: (n_voxels_per_device,)
17+
mag_kernel: Optional[np.ndarray] = None
18+
# Susceptibilities per geologic unit (dimensionless SI), shape: (n_units,)
19+
susceptibilities: Optional[np.ndarray] = None
20+
# IGRF parameters metadata used to build kernel (inclination, declination, intensity (nT))
21+
igrf_params: Optional[dict] = None

gempy_engine/core/data/solutions.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@ class Solutions:
2525
block_solution_type: RawArraysSolution.BlockSolutionType
2626

2727
def __init__(self, octrees_output: List[OctreeLevel], dc_meshes: List[DualContouringMesh] = None, fw_gravity: np.ndarray = None,
28+
fw_magnetics: np.ndarray = None,
2829
block_solution_type: RawArraysSolution.BlockSolutionType = RawArraysSolution.BlockSolutionType.OCTREE):
2930
self.octrees_output = octrees_output
3031
self.dc_meshes = dc_meshes
3132
self.gravity = fw_gravity
33+
self.magnetics = fw_magnetics
3234
self.block_solution_type = block_solution_type
3335

3436
self._set_scalar_field_at_surface_points_and_elements_order(octrees_output)
@@ -124,3 +126,9 @@ def _set_scalar_field_at_surface_points_and_elements_order(self, octrees_output:
124126

125127
# Order self_scalar_field_at_surface_points
126128
self._ordered_elements.append(np.argsort(scalar_field_at_surface_points_data)[::-1])
129+
130+
131+
@property
132+
def tmi(self) -> np.ndarray:
133+
"""Alias for magnetics Total Magnetic Intensity (nT)."""
134+
return self.magnetics

gempy_engine/modules/geophysics/README.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -257,14 +257,14 @@ Magnetic field modeling is more complex than gravity because it involves **vecto
257257

258258
### Key Differences from Gravity
259259

260-
| Aspect | Gravity | Magnetics |
261-
|--------|---------|-----------|
262-
| **Field type** | Scalar (gz only) | Vector (3 components) |
263-
| **Physical property** | Density (ρ) | Susceptibility (χ) + Remanence (Mr) |
264-
| **Source** | Mass distribution | Magnetic dipoles |
265-
| **Ambient field** | None (constant g) | Earth's field (varies by location/time) |
266-
| **Measurement** | Vertical acceleration | Total field intensity |
267-
| **Kernel complexity** | Single component (tz) | Full tensor (9 components) |
260+
| Aspect | Gravity | Magnetics |
261+
|-----------------------|-----------------------|-----------------------------------------|
262+
| **Field type** | Scalar (gz only) | Vector (3 components) |
263+
| **Physical property** | Density (ρ) | Susceptibility (χ) + Remanence (Mr) |
264+
| **Source** | Mass distribution | Magnetic dipoles |
265+
| **Ambient field** | None (constant g) | Earth's field (varies by location/time) |
266+
| **Measurement** | Vertical acceleration | Total field intensity |
267+
| **Kernel complexity** | Single component (tz) | Full tensor (9 components) |
268268

269269
### Mathematical Framework
270270

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import numpy as np
2+
3+
from .magnetic_gradient import _direction_cosines
4+
from ...core.data.geophysics_input import GeophysicsInput
5+
from ...core.data.interp_output import InterpOutput
6+
from ...core.backend_tensor import BackendTensor
7+
8+
9+
def map_susceptibilities_to_ids_basic(ids_geophysics_grid, susceptibilities):
10+
"""
11+
Map per-unit susceptibilities to voxel centers using 1-based geological IDs.
12+
Matches gravity's basic mapper behavior.
13+
"""
14+
return susceptibilities[ids_geophysics_grid - 1]
15+
16+
17+
def compute_tmi(geophysics_input: GeophysicsInput, root_output: InterpOutput) -> BackendTensor.t:
18+
"""
19+
Compute induced-only Total Magnetic Intensity (TMI) anomalies (nT) by combining
20+
precomputed per-voxel TMI kernel with voxel susceptibilities.
21+
22+
Expectations for Phase 1 (MVP):
23+
- geophysics_input.mag_kernel is a scalar kernel per voxel (for one device geometry)
24+
that already includes projection along the IGRF field direction and unit handling
25+
(nT per unit susceptibility).
26+
- geophysics_input.susceptibilities is an array of susceptibilities per geologic unit (SI).
27+
- root_output.ids_geophysics_grid are 1-based IDs mapping each voxel to a geologic unit.
28+
29+
Returns:
30+
BackendTensor.t array of shape (n_devices,) with TMI anomaly in nT.
31+
32+
Notes:
33+
This function works with pre-projected TMI kernels from
34+
calculate_magnetic_gradient_tensor(..., compute_tmi=True).
35+
For more flexibility (e.g., remanent magnetization in Phase 2),
36+
consider using the raw V components and compute_magnetic_forward().
37+
"""
38+
if geophysics_input.mag_kernel is None:
39+
raise ValueError("GeophysicsInput.mag_kernel is required for magnetic forward modeling.")
40+
if geophysics_input.susceptibilities is None:
41+
raise ValueError("GeophysicsInput.susceptibilities is required for magnetic forward modeling.")
42+
43+
# Kernel for one device geometry
44+
mag_kernel = BackendTensor.t.array(geophysics_input.mag_kernel)
45+
46+
# Map susceptibilities to voxel centers according to IDs (1-based indexing)
47+
chi = map_susceptibilities_to_ids_basic(
48+
ids_geophysics_grid=root_output.ids_geophysics_grid,
49+
susceptibilities=BackendTensor.t.array(geophysics_input.susceptibilities)
50+
)
51+
52+
# Determine how many devices are present by comparing lengths
53+
n_devices = chi.shape[0] // mag_kernel.shape[0]
54+
55+
# Reshape for batch multiply-sum across devices
56+
mag_kernel = mag_kernel.reshape(1, -1)
57+
chi = chi.reshape(n_devices, -1)
58+
59+
# Weighted sum per device
60+
tmi = BackendTensor.t.sum(chi * mag_kernel, axis=1)
61+
return tmi
62+
63+
64+
def compute_magnetic_forward(
65+
V: np.ndarray,
66+
susceptibilities: np.ndarray,
67+
igrf_params: dict,
68+
n_devices: int = 1,
69+
units_nT: bool = True
70+
) -> np.ndarray:
71+
"""
72+
Compute Total Magnetic Intensity (TMI) anomalies from precomputed tensor components.
73+
74+
This follows the legacy implementation workflow: combine geometry-dependent V components
75+
with susceptibility and IGRF field parameters to compute TMI.
76+
77+
Args:
78+
V: np.ndarray of shape (6, n_voxels_per_device) from calculate_magnetic_gradient_components()
79+
susceptibilities: np.ndarray of shape (n_total_voxels,) - susceptibility per voxel for all devices
80+
igrf_params: dict with keys {"inclination", "declination", "intensity"} (intensity in nT)
81+
n_devices: Number of observation devices (default 1)
82+
units_nT: If True, output in nT; if False, output in Tesla
83+
84+
Returns:
85+
dT: np.ndarray of shape (n_devices,) - TMI anomaly at each observation point
86+
87+
Notes:
88+
Implements the formula from legacy code:
89+
- Compute induced magnetization: J = chi * B_ext
90+
- Compute directional components: Jx, Jy, Jz
91+
- Apply gradient tensor: Tx, Ty, Tz using V components
92+
- Project onto field direction: dT = Tx*dir_x + Ty*dir_y + Tz*dir_z
93+
"""
94+
# Extract IGRF parameters
95+
incl = float(igrf_params.get("inclination", 0.0))
96+
decl = float(igrf_params.get("declination", 0.0))
97+
B_ext = float(igrf_params.get("intensity", 50000.0)) # in nT
98+
99+
# Convert to Tesla for internal computation
100+
B_ext_tesla = B_ext * 1e-9
101+
102+
# Get field direction cosines
103+
dir_x, dir_y, dir_z = _direction_cosines(incl, decl)
104+
105+
# Compute induced magnetization [Tesla]
106+
# J = chi * B_ext (susceptibility is dimensionless)
107+
J = susceptibilities * B_ext_tesla
108+
109+
# Compute magnetization components along field direction
110+
Jx = dir_x * J
111+
Jy = dir_y * J
112+
Jz = dir_z * J
113+
114+
# Tile V for multiple devices (repeat the kernel for each device)
115+
V_tiled = np.tile(V, (1, n_devices))
116+
117+
# Compute directional magnetic effect on each voxel using V components
118+
# This is equation (3.19) from the theory
119+
# Bx = Jx*V1 + Jy*V2 + Jz*V3
120+
# By = Jx*V2 + Jy*V4 + Jz*V5 (V2 = V[1] because tensor is symmetric)
121+
# Bz = Jx*V3 + Jy*V5 + Jz*V6
122+
Tx = (Jx * V_tiled[0, :] + Jy * V_tiled[1, :] + Jz * V_tiled[2, :]) / (4 * np.pi)
123+
Ty = (Jx * V_tiled[1, :] + Jy * V_tiled[3, :] + Jz * V_tiled[4, :]) / (4 * np.pi)
124+
Tz = (Jx * V_tiled[2, :] + Jy * V_tiled[4, :] + Jz * V_tiled[5, :]) / (4 * np.pi)
125+
126+
# Sum over voxels for each device
127+
n_voxels_per_device = V.shape[1]
128+
Tx = np.sum(Tx.reshape((n_devices, n_voxels_per_device)), axis=1)
129+
Ty = np.sum(Ty.reshape((n_devices, n_voxels_per_device)), axis=1)
130+
Tz = np.sum(Tz.reshape((n_devices, n_voxels_per_device)), axis=1)
131+
132+
# Project onto field direction to get TMI
133+
# "Total field magnetometers can measure only that part of the anomalous field
134+
# which is in the direction of the Earth's main field" (SimPEG documentation)
135+
dT = Tx * dir_x + Ty * dir_y + Tz * dir_z
136+
137+
if units_nT:
138+
# Convert to nT
139+
dT = dT * 1e9
140+
141+
return dT

0 commit comments

Comments
 (0)