Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
531 changes: 531 additions & 0 deletions MICRO_ANISOTROPIC_FIELD_DEFORMATION.md

Large diffs are not rendered by default.

157 changes: 157 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# Technical Implementation Report: Multiscale Anisotropic Field Deformation for Automated Data Compliance in Implicit Geological Modeling

## Executive Summary

Traditional implicit geological modeling frameworks face a fundamental trade-off between structural consistency and high-density data compliance. Incorporating thousands of borehole contact points directly into a global Universal Co-Kriging dual matrix introduces a cubic computational complexity bottleneck ($O(N^3)$) and risks severe structural distortions such as artificial dimples and broken fault mechanics.

This report outlines a next-generation, hybrid **Multiscale Anisotropic Field Deformation** framework. By treating the geological model as an expert-driven structural hypothesis (the Macro model) and delegating high-density borehole snapping to a localized, GPU-accelerated geostatistical compliance layer (the Micro step), this architecture achieves exact data precision while preserving structural logic. The execution leverages **PyKeOps** for symbolic matrix operations and a **Conjugate Gradient (CG)** solver on the GPU, culminating in real-time execution ready for extraction via Dual Contouring and visualization in **Unity 6**.

---

## 1. Architectural Philosophy: The Hypothesis-First Paradigm

Rather than treating geological modeling as an automated black-box data-fitting problem, this framework decouples the modeling process into two distinct spatial frequencies:

1. **The Macro-Scale Framework (Low Frequency):** The geologist uses a clean, sparse subset of structural data (regional dips, major fault geometries) to establish the primary architectural trend using GemPy. This acts as the regional geological hypothesis, ensuring tectonic and structural rules are strictly enforced.
2. **The Micro-Scale Compliance Layer (High Frequency):** An automated, local optimization pass adjusts the generated continuous scalar field to ensure the target isosurfaces intersect perfectly with thousands of borehole contacts without affecting the regional framework outside a localized "damage radius."

---

## 2. Mathematical Formulation & Algorithmic Steps

### Step 1: Macro Field Evaluation and Gradient Extraction

The baseline GemPy model is evaluated to produce a continuous global structural trend field, $V_{macro}(\mathbf{x})$. At each of the $N$ borehole contact points $\mathbf{x}_i = (x_i, y_i, z_i)$, two operations are performed:

1. **Trend Lookup:** The baseline scalar value is sampled: $V_{pred, i} = V_{macro}(\mathbf{x}_i)$.
2. **Gradient Sampling:** The normalized mathematical gradient vector is extracted:

$$\mathbf{g}_i = \nabla V_{macro}(\mathbf{x}_i)$$



### Step 2: Local Horizon Anchoring and Residual Calculation

Because GemPy operates on relative drift constraints (where the absolute scalar value of an interface emerges from gradient orientations rather than static inputs), residuals cannot be evaluated against an arbitrary global constant.

Contacts are grouped by their respective geological horizons. For each horizon group, a localized anchor point $\mathbf{x}_{anchor}$ is selected. The target scalar value for that specific horizon is locked to the macro value at that anchor: $C_{target} = V_{macro}(\mathbf{x}_{anchor})$. Local scalar residuals are then computed for all points within the horizon group:


$$\Delta V_i = C_{target} - V_{macro}(\mathbf{x}_i)$$

### Step 3: Constructing the Geologically Aligned Anisotropy Tensors

To prevent spherical "bullseye" artifacts and cross-layer data contamination, distances must be evaluated in a warped, local coordinate system. For each borehole point $i$, a localized anisotropic transformation matrix $\mathbf{A}_i$ is constructed using a reverse Translation-Rotation-Scale (TRS) process:

$$\mathbf{A}_i = \mathbf{S} \cdot \mathbf{R}_i^T$$

* **Rotation ($\mathbf{R}_i^T$):** Aligns the world coordinate axes to the local geology using the sampled macro-gradient $\mathbf{g}_i$ as the local "Up" vector, while the strike and dip vectors establish the local "Right" and "Forward" planes.
* **Scale ($\mathbf{S}$):** Imposes a steep distance penalty perpendicular to the stratigraphy:

$$\mathbf{S} = \begin{bmatrix} \frac{1}{r_{lateral}} & 0 & 0 \\ 0 & \frac{1}{r_{vertical}} & 0 \\ 0 & 0 & \frac{1}{r_{lateral}} \end{bmatrix}$$



Where $r_{vertical} \ll r_{lateral}$. The vertical range is strictly constrained to be smaller than the minimum stratigraphic thickness between adjacent horizons, mathematically isolating independent layers.

---

## 3. High-Performance GPU Implementation via PyKeOps

### Symmetric Distance Computation

To guarantee that the global covariance matrix remains strictly positive semi-definite (preventing invalid mathematical spaces or solver divergence), a symmetric distance metric is applied between any two interacting data points $i$ and $j$:

$$\text{Dist}^2(i, j) = (\mathbf{x}_i - \mathbf{x}_j)^T \left( \frac{\mathbf{A}_i^T\mathbf{A}_i + \mathbf{A}_j^T\mathbf{A}_j}{2} \right) (\mathbf{x}_i - \mathbf{x}_j)$$

### Symbolic Kernel and Optimization Loop

The $N \times N$ covariance matrix $\mathbf{K}$ is initialized symbolically inside **PyKeOps** as a `LazyTensor`. This allows the GPU to compute covariance coefficients on the fly in registers, reducing memory consumption from $O(N^2)$ to $O(N)$ storage and entirely bypassing VRAM bottlenecks.

```python
import torch
from pykeops.torch import LazyTensor

X = torch.tensor(X_boreholes, dtype=torch.float32).cuda()
A = torch.tensor(A_matrices, dtype=torch.float32).cuda()

# Map coordinates to localized transformation spaces
AX = torch.einsum('nij,nj->ni', A, X)

x_i = LazyTensor(AX[:, None, :]) # (N, 1, 3)
x_j = LazyTensor(AX[None, :, :]) # (1, N, 3)

dist_squared = ((x_i - x_j) ** 2).sum(-1)
K = (- (dist_squared.sqrt())).exp() # Exponential Covariance Kernel

```

Because the macro model provides a highly accurate starting baseline, the residual vector $\mathbf{y} = [\Delta V_1, \dots, \Delta V_N]^T$ sits close to the final solution space. A PyKeOps-backed **Conjugate Gradient (CG)** solver solves the linear system $\mathbf{K}\mathbf{w} = \mathbf{y}$ for the weights vector $\mathbf{w}$ within a fraction of a second.

---

## 4. Optimized Mesh Extraction via Dual-Criteria Octree Subdivision

To minimize the computational overhead of the micro-evaluation pass, the final field deformation is restricted exclusively to the finest level of an adaptive octree structure, directly targeting the boundary cells where the Dual Contouring mesh is extracted.

However, relying solely on the macro-geology field to guide octree subdivision introduces a critical geometric vulnerability: if a dense borehole contact point deviates significantly from the macro trend, it may fall outside the fine cells generated by the macro engine, landing in a massive, un-subdivided coarse block. Consequently, the micro-correction field at that location would be bypassed, and the extracted mesh would fail to capture the data point.

To eliminate this data-omission risk, the framework utilizes a **Dual-Criteria Octree Subdivision** protocol. A spatial volume cell is forced to subdivide to its highest resolution tier if **either** of the following conditions is met:

1. **The Macro Isosurface Criterion:** The cell intersects a target structural threshold of the baseline GemPy field ($V_{macro}(\mathbf{x}) = C_{target}$).
2. **The High-Density Data Criterion:** The cell boundaries enclose one or more raw borehole contact coordinates ($\mathbf{x}_i$).

### Passive Metric Grid Evaluation

When it is time to extract the mesh via Dual Contouring, the framework loops through the active cells at the finest resolution level. For each corner coordinate $\mathbf{x}_{corner}$ of a fine boundary cell, it looks up the baseline value and adds the fast, passive PyKeOps anisotropic distance lookup:

$$V_{final}(\mathbf{x}_{corner}) = V_{macro}(\mathbf{x}_{corner}) + \sum_{i=1}^{N} w_i \cdot e^{-\|\mathbf{A}_i (\mathbf{x}_{corner} - \mathbf{x}_i)\|}$$

This eliminates the need to calculate new gradients at millions of grid nodes, keeping the final evaluation pass highly parallelized and computationally cheap.

---

## 5. End-to-End Execution Pipeline

By implementing this dual-gated subdivision rule, the downstream mesh extraction operates as a streamlined, highly parallelized graphics workflow:

```
[GemPy Macro Model] + [Borehole Coordinates]
[Dual-Criteria Octree Generation]
(Fine cells at macro horizons & well sites)
[Fine Cell Corner Evaluation]
(Passive Anisotropic PyKeOps Vector Lookup)
[Dual Contouring Extraction]
(Forced Isosurface Crossing at Well Caps)
[Unity 6 Mesh Buffers]

```

1. **Sparse Global Architecture:** The octree remains broad and lightweight across the vast majority of the asset volume, preventing unnecessary scalar evaluation loops in homogeneous rock masses.
2. **Targeted Precision Hooks:** The grid is guaranteed to maintain ultra-high-resolution cell matrices immediately surrounding every well track, providing the necessary mathematical "hooks" for data snapping.
3. **Forced Crossings:** When the Dual Contouring engine processes the corners of these fine well-bounding cells, it samples the combined $V_{final}(\mathbf{x}_{corner})$ field. The micro-weights ($\mathbf{w}$) seamlessly shift the scalar values across the boundary threshold within that cell, forcing the extracted vertex to lock onto the physical borehole coordinate with millimeter precision before piping the clean topology directly to the **Unity 6** render buffers.

---

## 6. Architectural Comparison Matrix

| Property | Standard Global Engine | Pure Post-Process Mesh Snapping | Proposed Multiscale PyKeOps Framework |
| --- | --- | --- | --- |
| **Computational Complexity** | Cubic $O(N^3)$ — chokes on dense datasets. | Linear $O(N)$ — executed entirely on the client. | **Ultra-Fast $O(N)$** — GPU-accelerated symbolic linear iterations. |
| **Volumetric Field Consistency** | High. | Broken — visual mesh diverges from the underlying scalar volume. | **Perfect** — corrections are applied directly inside the 3D scalar volume. |
| **Structural Integrity (Faults/Dips)** | Maintained globally, but lacks local data compliance. | Poor — risks crossing surfaces, artifact generation, and fault smearing. | **Maintained** — locked to the macro-gradient; strong vertical anisotropy prevents layer cross-talk. |
| **Visual Artifacts** | None. | High risk of sharp conical dimples ("tents over poles"). | **None** — corrections stretch naturally into smooth geological ovals. |

---

## Conclusion

The **Multiscale Anisotropic Field Deformation** framework successfully bridges the gap between expert geological intuition and strict data compliance. By utilizing the macro-gradient of GemPy to dictate localized, anisotropic data transformations and leveraging PyKeOps for memory-efficient GPU parallelization, this implementation delivers an auditable, structurally sound, and lightning-fast modeling pipeline that satisfies both mathematical rigor and real-time interactive rendering demands.
6 changes: 5 additions & 1 deletion gempy_engine/core/data/options/evaluation_options.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import enum

from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Annotated

from typing_extensions import deprecated

from .micro_anisotropic_options import MicroAnisotropicOptions


class MeshExtractionMaskingOptions(enum.Enum):
NOTHING = enum.auto() # * This is only for testing
Expand All @@ -27,6 +29,8 @@ class EvaluationOptions:

evaluation_chunk_size: int = 500_000

micro_anisotropic: MicroAnisotropicOptions = field(default_factory=MicroAnisotropicOptions)


compute_scalar: bool = True
compute_scalar_gradient: bool = False
Expand Down
21 changes: 21 additions & 0 deletions gempy_engine/core/data/options/micro_anisotropic_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from typing import Literal, Optional

import numpy as np
from pydantic import BaseModel, ConfigDict

MicroKernelType = Literal["exponential", "matern_3_2", "matern_5_2"]


class MicroAnisotropicOptions(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)

enabled: bool = False
points: Optional[np.ndarray] = None # (N, 3) micro constraint points
residuals: Optional[np.ndarray] = None # (N,) target residual values
anisotropy_matrices: Optional[np.ndarray] = None # (N, 3, 3) per-point anisotropy transforms
weights: Optional[np.ndarray] = None # (N,) solved micro weights
kernel_range: float = 1.0 # range for the micro kernel
kernel_type: MicroKernelType = "matern_5_2" # kernel function for micro solve + eval
nugget: float = 0.0 # diagonal nugget for the micro solve
preserve_macro_points: bool = True # include macro SP as zero-residual constraints
strength: float = 1.0 # global strength multiplier (1.0 = full correction)
16 changes: 16 additions & 0 deletions gempy_engine/modules/evaluator/generic_evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import gc
from typing import Optional

import gempy_engine.config
from gempy_engine.core.backend_tensor import BackendTensor
from gempy_engine.core.data import InterpolationOptions
from gempy_engine.core.data.exported_fields import ExportedFields
Expand Down Expand Up @@ -66,6 +67,21 @@ def generic_evaluator(
if n_chunks > 5:
print(f"Chunking done: {n_chunks} chunks")

micro = options.evaluation_options.micro_anisotropic
if micro.enabled and micro.weights is not None and micro.points is not None and micro.anisotropy_matrices is not None:
from gempy_engine.modules.evaluator.micro_anisotropic_evaluator import evaluate_micro_correction
if BackendTensor.engine_backend != gempy_engine.config.AvailableBackends.numpy:
scalar_field = BackendTensor.t.to_numpy(scalar_field)
correction = evaluate_micro_correction(
xyz_to_interpolate=solver_input.xyz_to_interpolate,
micro_points=micro.points,
micro_weights=micro.weights,
anisotropy_matrices=micro.anisotropy_matrices,
kernel_range=micro.kernel_range,
kernel_type=micro.kernel_type,
)
scalar_field = scalar_field + correction

return ExportedFields(scalar_field, gx_field, gy_field, gz_field)


Expand Down
Loading