From 763c6c60441d3dd103e9f22231afbe63079ab898 Mon Sep 17 00:00:00 2001
From: Paloma Martinez <104762252+paloma-martinez@users.noreply.github.com>
Date: Thu, 5 Feb 2026 10:00:32 +0100
Subject: [PATCH 1/5] Adding the code as is
---
.../post_processing/FaultStabilityAnalysis.py | 4413 +++++++++++++++++
1 file changed, 4413 insertions(+)
create mode 100755 geos-processing/src/geos/processing/post_processing/FaultStabilityAnalysis.py
diff --git a/geos-processing/src/geos/processing/post_processing/FaultStabilityAnalysis.py b/geos-processing/src/geos/processing/post_processing/FaultStabilityAnalysis.py
new file mode 100755
index 00000000..a8e2476e
--- /dev/null
+++ b/geos-processing/src/geos/processing/post_processing/FaultStabilityAnalysis.py
@@ -0,0 +1,4413 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright 2023-2026 TotalEnergies.
+# SPDX-FileContributor: Nicolas Pillardou, Paloma Martinez
+from pathlib import Path
+import numpy as np
+import pyvista as pv
+import matplotlib.pyplot as plt
+import pyfiglet
+from scipy.spatial import cKDTree
+from scipy.interpolate import splprep, splev, LinearNDInterpolator, Rbf
+import os
+import math
+
+
+
+# ============================================================================
+# CONFIGURATION
+# ============================================================================
+
+class Config:
+ """Configuration parameters for fault analysis"""
+
+ # Mechanical parameters
+ FRICTION_ANGLE = 12 # [degrees]
+ COHESION = 0 # [bar]
+
+ # Normal orientation
+ ROTATE_NORMALS = False # Rotate normals and tangents from 180°
+
+ # Sensitivity analysis
+ RUN_SENSITIVITY = True # Enable sensitivity analysis
+ SENSITIVITY_FRICTION_ANGLES = [12,15,18,20,22,25] # degrees
+ SENSITIVITY_COHESIONS = [0,1,2,5,10] # bar
+
+ # Visualization
+ Z_SCALE = 1.0
+ SHOW_NORMAL_PLOTS = True # Show the mesh grid and normals at fault planes
+ SHOW_CONTRIBUTION_VIZ = True # Show volume contribution visualization (first timestep only)
+ SHOW_DEPTH_PROFILES = True # Active les profils verticaux
+ N_DEPTH_PROFILES = 1 # Nombre de lignes verticales
+
+ MIN_DEPTH_PROFILES = None
+ MAX_DEPTH_PROFILES = None
+ SHOW_PLOTS = True # Set to False to skip interactive plots
+ SAVE_PLOTS = True # Set to False to skip saving plots
+ SAVE_CONTRIBUTION_CELLS = True # Save vtu contributive cells
+ WEIGHTING_SCHEME = "arithmetic"
+
+ COMPUTE_PRINCIPAL_STRESS = False
+ SHOW_PROFILE_EXTRACTOR = True
+
+ PROFILE_START_POINTS = [
+ (2282.61, 1040, 0)] # Profile Fault 1
+
+ PROFILE_SEARCH_RADIUS = None
+
+ # Time series - List of time indices to process (None = all)
+ TIME_INDEX = [0,-1]
+
+ # File paths
+ PATH = ""
+ GRID_FILE = "mesh_faulted_reservoir_60_mod.vtu"
+ PVD_FILE = "faultModel.pvd"
+
+ # Variable names
+ STRESS_NAME = "averageStress"
+ BIOT_NAME = "rockPorosity_biotCoefficient"
+
+ # Faults attributes
+ FAULT_ATTRIBUTE = "Fault"
+ FAULT_VALUES = [1]
+
+ # Output
+ OUTPUT_DIR = "Processed_Fault_Analysis"
+ SENSITIVITY_OUTPUT_DIR = "Processed_Fault_Analysis/Sensitivity_Analysis"
+
+
+# ============================================================================
+# STRESS TENSOR OPERATIONS
+# ============================================================================
+class StressTensor:
+ """Utility class for stress tensor operations"""
+
+ @staticmethod
+ def build_from_array(arr):
+ """Convert stress array to 3x3 tensor format"""
+ n = arr.shape[0]
+ tensors = np.zeros((n, 3, 3))
+
+ if arr.shape[1] == 6: # Voigt notation
+ tensors[:, 0, 0] = arr[:, 0] # Sxx
+ tensors[:, 1, 1] = arr[:, 1] # Syy
+ tensors[:, 2, 2] = arr[:, 2] # Szz
+ tensors[:, 1, 2] = tensors[:, 2, 1] = arr[:, 3] # Syz
+ tensors[:, 0, 2] = tensors[:, 2, 0] = arr[:, 4] # Sxz
+ tensors[:, 0, 1] = tensors[:, 1, 0] = arr[:, 5] # Sxy
+ elif arr.shape[1] == 9:
+ tensors = arr.reshape((-1, 3, 3))
+ else:
+ raise ValueError(f"Unsupported stress shape: {arr.shape}")
+
+ return tensors
+
+ @staticmethod
+ def rotate_to_fault_frame(stress_tensor, normal, tangent1, tangent2):
+ """Rotate stress tensor to fault local coordinate system"""
+ # Verify orthonormality
+ assert np.abs(np.linalg.norm(tangent1) - 1.0) < 1e-10
+ assert np.abs(np.linalg.norm(tangent2) - 1.0) < 1e-10
+ assert np.abs(np.dot(normal, tangent1)) < 1e-10
+ assert np.abs(np.dot(normal, tangent2)) < 1e-10
+
+ # Rotation matrix: columns = local directions (n, t1, t2)
+ R = np.column_stack((normal, tangent1, tangent2))
+
+ # Rotate tensor
+ stress_local = R.T @ stress_tensor @ R
+
+ # Traction on fault plane (normal = [1,0,0] in local frame) # TODO is it aways that way ?
+ traction_local = stress_local @ np.array([1.0, 0.0, 0.0])
+
+ return {
+ 'stress_local': stress_local,
+ 'normal_stress': traction_local[0],
+ 'shear_stress': np.sqrt(traction_local[1]**2 + traction_local[2]**2),
+ 'shear_strike': traction_local[1],
+ 'shear_dip': traction_local[2]
+ }
+
+# ============================================================================
+# FAULT GEOMETRY
+# ============================================================================
+class FaultGeometry:
+
+ """Handles fault surface extraction and normal computation with optimizations"""
+
+ # -------------------------------------------------------------------
+ def __init__(self, config, mesh, fault_values, fault_attribute, volume_mesh):
+ """
+ Initialize fault geometry with pre-computed topology.
+
+ Args:
+ config (Config):
+ mesh (): pv.read(path / config.GRID_FILE) -> "mesh_faulted_reservoir_60_mod.vtu"
+ fault_values (list[int]): Config.FAULT_VALUES
+ fault_attribute (str): Config.FAULT_ATTRIBUTES
+ volume_mesh (): processor._merge_blocks(dataset)
+ """
+ self.mesh = mesh
+ self.fault_values = fault_values
+ self.fault_attribute = fault_attribute
+ self.volume_mesh = volume_mesh
+
+ # These will be computed once
+ self.fault_surface = None
+ self.surfaces = None
+ self.adjacency_mapping = None
+ self.contributing_cells = None
+ self.contributing_cells_plus = None
+ self.contributing_cells_minus = None
+
+ # NEW: Pre-computed geometric properties
+ self.volume_cell_volumes = None # Volume of each cell
+ self.volume_centers = None # Center coordinates
+ self.distance_to_fault = None # Distance from each volume cell to nearest fault
+ self.fault_tree = None # KDTree for fault surface
+
+ # Config
+ self.config = config
+
+ # -------------------------------------------------------------------
+ def initialize(self, scale_factor=50.0, process_faults_separately=True):
+ """
+ One-time initialization: compute normals, adjacency topology, and geometric properties
+ """
+
+ # Extract and compute normals
+ self.fault_surface, self.surfaces = self._extract_and_compute_normals(
+ show_plot=self.config.SHOW_NORMAL_PLOTS,
+ scale_factor=scale_factor,
+ z_scale=self.config.Z_SCALE)
+
+ # Pre-compute adjacency mapping
+ print("\n🔍 Pre-computing volume-fault adjacency topology")
+ print(" Method: Face-sharing (adaptive epsilon)")
+
+ self.adjacency_mapping = self._build_adjacency_mapping_face_sharing(
+ process_faults_separately=process_faults_separately)
+
+ # Mark and optionally save contributing cells
+ self._mark_contributing_cells()
+
+ # NEW: Pre-compute geometric properties
+ self._precompute_geometric_properties()
+
+ n_mapped = len(self.adjacency_mapping)
+ n_with_both = sum(1 for m in self.adjacency_mapping.values()
+ if len(m['plus']) > 0 and len(m['minus']) > 0)
+
+ print("\n✅ Adjacency topology computed:")
+ print(f" - {n_mapped}/{self.fault_surface.n_cells} fault cells mapped")
+ print(f" - {n_with_both} cells have neighbors on both sides")
+
+ # Visualize contributions if requested
+ if self.config.SHOW_CONTRIBUTION_VIZ:
+ self._visualize_contributions()
+
+ return self.fault_surface, self.adjacency_mapping
+
+ # -------------------------------------------------------------------
+ def _mark_contributing_cells(self):
+ """
+ Mark volume cells that contribute to fault stress projection
+ """
+ print("\n📦 Marking contributing volume cells...")
+
+ n_volume = self.volume_mesh.n_cells
+
+ # Collect contributing cells by side
+ all_plus = set()
+ all_minus = set()
+
+ for fault_idx, neighbors in self.adjacency_mapping.items():
+ all_plus.update(neighbors['plus'])
+ all_minus.update(neighbors['minus'])
+
+ # Create classification array
+ contribution_side = np.zeros(n_volume, dtype=int)
+
+ for idx in all_plus:
+ if 0 <= idx < n_volume:
+ contribution_side[idx] += 1
+
+ for idx in all_minus:
+ if 0 <= idx < n_volume:
+ contribution_side[idx] += 2
+
+ # Add classification to volume mesh
+ self.volume_mesh.cell_data["contribution_side"] = contribution_side
+ contrib_mask = contribution_side > 0
+ self.volume_mesh.cell_data["contribution_to_faults"] = contrib_mask.astype(int)
+
+ # Extract subsets
+ mask_all = contrib_mask
+ mask_plus = (contribution_side == 1) | (contribution_side == 3)
+ mask_minus = (contribution_side == 2) | (contribution_side == 3)
+
+ self.contributing_cells = self.volume_mesh.extract_cells(mask_all)
+ self.contributing_cells_plus = self.volume_mesh.extract_cells(mask_plus)
+ self.contributing_cells_minus = self.volume_mesh.extract_cells(mask_minus)
+
+ # Statistics
+ n_contrib = np.sum(mask_all)
+ n_plus = np.sum(contribution_side == 1)
+ n_minus = np.sum(contribution_side == 2)
+ n_both = np.sum(contribution_side == 3)
+ pct_contrib = n_contrib / n_volume * 100
+
+ print(f" ✅ Total contributing: {n_contrib}/{n_volume} ({pct_contrib:.1f}%)")
+ print(f" Plus side only: {n_plus} cells")
+ print(f" Minus side only: {n_minus} cells")
+ print(f" Both sides: {n_both} cells")
+
+ # Save to files if requested
+ if self.config.SAVE_CONTRIBUTION_CELLS:
+ self._save_contributing_cells()
+
+ # -------------------------------------------------------------------
+ def _save_contributing_cells(self):
+ """
+ Save contributing volume cells to VTU files
+ Saves three files: all, plus side, minus side
+ """
+ from pathlib import Path
+
+ # Create output directory if it doesn't exist
+ output_dir = Path(self.config.OUTPUT_DIR) if hasattr(self.config, 'OUTPUT_DIR') else Path('.')
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ # Save all contributing cells
+ filename_all = output_dir / "contributing_cells_all.vtu"
+ self.contributing_cells.save(str(filename_all))
+ print(f"\n 💾 All contributing cells saved: {filename_all}")
+ print(f" ({self.contributing_cells.n_cells} cells, {self.contributing_cells.n_points} points)")
+
+ # Save plus side
+ filename_plus = output_dir / "contributing_cells_plus.vtu"
+ # self.contributing_cells_plus.save(str(filename_plus))
+ # print(f" 💾 Plus side cells saved: {filename_plus}")
+ print(f" ({self.contributing_cells_plus.n_cells} cells, {self.contributing_cells_plus.n_points} points)")
+
+ # Save minus side
+ filename_minus = output_dir / "contributing_cells_minus.vtu"
+ # self.contributing_cells_minus.save(str(filename_minus))
+ # print(f" 💾 Minus side cells saved: {filename_minus}")
+ print(f" ({self.contributing_cells_minus.n_cells} cells, {self.contributing_cells_minus.n_points} points)")
+
+ # -------------------------------------------------------------------
+ def get_contributing_cells(self, side='all'):
+ """
+ Get the extracted contributing cells
+
+ Parameters:
+ side: 'all', 'plus', or 'minus'
+
+ Returns:
+ pyvista.UnstructuredGrid: Contributing volume cells
+ """
+ if self.contributing_cells is None:
+ raise ValueError("Contributing cells not yet computed. Call initialize() first.")
+
+ if side == 'all':
+ return self.contributing_cells
+ elif side == 'plus':
+ return self.contributing_cells_plus
+ elif side == 'minus':
+ return self.contributing_cells_minus
+ else:
+ raise ValueError(f"Invalid side '{side}'. Must be 'all', 'plus', or 'minus'.")
+
+ # -------------------------------------------------------------------
+ def get_geometric_properties(self):
+ """
+ Get pre-computed geometric properties
+
+ Returns
+ -------
+ dict with keys:
+ - 'volumes': ndarray of cell volumes
+ - 'centers': ndarray of cell centers (n_cells, 3)
+ - 'distances': ndarray of distances to nearest fault cell
+ - 'fault_tree': KDTree for fault surface
+ """
+ if self.volume_cell_volumes is None:
+ raise ValueError("Geometric properties not computed. Call initialize() first.")
+
+ return {
+ 'volumes': self.volume_cell_volumes,
+ 'centers': self.volume_centers,
+ 'distances': self.distance_to_fault,
+ 'fault_tree': self.fault_tree
+ }
+
+ # -------------------------------------------------------------------
+ def _precompute_geometric_properties(self):
+ """
+ Pre-compute geometric properties of volume mesh for efficient stress projection
+
+ Computes:
+ - Cell volumes (for volume-weighted averaging)
+ - Cell centers (for distance calculations)
+ - Distance from each volume cell to nearest fault cell
+ - KDTree for fault surface
+ """
+ print("\n📐 Pre-computing geometric properties...")
+
+ n_volume = self.volume_mesh.n_cells
+
+ # 1. Compute volume centers
+ print(" Computing cell centers...")
+ self.volume_centers = self.volume_mesh.cell_centers().points
+
+ # 2. Compute cell volumes
+ print(" Computing cell volumes...")
+ volume_with_sizes = self.volume_mesh.compute_cell_sizes(
+ length=False, area=False, volume=True
+ )
+ self.volume_cell_volumes = volume_with_sizes.cell_data['Volume']
+
+ print(f" Volume range: [{np.min(self.volume_cell_volumes):.1e}, "
+ f"{np.max(self.volume_cell_volumes):.1e}] m³")
+
+ # 3. Build KDTree for fault surface (for fast distance queries)
+ print(" Building KDTree for fault surface...")
+ from scipy.spatial import cKDTree
+
+ fault_centers = self.fault_surface.cell_centers().points
+ self.fault_tree = cKDTree(fault_centers)
+
+ # 4. Compute distance from each volume cell to nearest fault cell
+ print(" Computing distances to fault...")
+ self.distance_to_fault = np.zeros(n_volume)
+
+ # Vectorized query for all points at once (much faster)
+ distances, _ = self.fault_tree.query(self.volume_centers)
+ self.distance_to_fault = distances
+
+ print(f" Distance range: [{np.min(self.distance_to_fault):.1f}, "
+ f"{np.max(self.distance_to_fault):.1f}] m")
+
+ # 5. Add these properties to volume mesh for reference
+ self.volume_mesh.cell_data['cell_volume'] = self.volume_cell_volumes
+ self.volume_mesh.cell_data['distance_to_fault'] = self.distance_to_fault
+
+ print(" ✅ Geometric properties computed and cached")
+
+ # -------------------------------------------------------------------
+ def _build_adjacency_mapping_face_sharing(self, process_faults_separately=True):
+ """
+ Build adjacency for cells sharing faces with fault
+ Uses adaptive epsilon optimization
+ """
+
+ fault_ids = np.unique(self.fault_surface.cell_data[self.fault_attribute])
+ n_faults = len(fault_ids)
+ print(f" 📋 Processing {n_faults} separate faults: {fault_ids}")
+
+ all_mappings = {}
+
+ for fault_id in fault_ids:
+ mask = self.fault_surface.cell_data[self.fault_attribute] == fault_id
+ indices = np.where(mask)[0]
+ single_fault = self.fault_surface.extract_cells(indices)
+
+ print(f" 🔧 Mapping Fault {fault_id}...")
+
+ # Build face-sharing mapping with adaptive epsilon
+ local_mapping = self._find_face_sharing_cells(single_fault)
+
+ # Remap local indices to global fault indices
+ for local_idx, neighbors in local_mapping.items():
+ global_idx = indices[local_idx]
+ all_mappings[global_idx] = neighbors
+
+ return all_mappings
+
+ # -------------------------------------------------------------------
+ def _find_face_sharing_cells(self, fault_surface):
+ """
+ Find volume cells that share a FACE with fault cells
+
+ Uses FindCell with adaptive epsilon to maximize cells with both neighbors
+ """
+ vol_mesh = self.volume_mesh
+ vol_centers = vol_mesh.cell_centers().points
+ fault_normals = fault_surface.cell_data["Normals"]
+ fault_centers = fault_surface.cell_centers().points
+
+ # Determine base epsilon based on mesh size
+ vol_bounds = vol_mesh.bounds
+ typical_size = np.mean([
+ vol_bounds[1] - vol_bounds[0],
+ vol_bounds[3] - vol_bounds[2],
+ vol_bounds[5] - vol_bounds[4]
+ ]) / 100.0
+
+ # Build VTK cell locator (once)
+ from vtkmodules.vtkCommonDataModel import vtkCellLocator
+
+ locator = vtkCellLocator()
+ locator.SetDataSet(vol_mesh)
+ locator.BuildLocator()
+
+ # Try multiple epsilon values and keep the best
+ epsilon_candidates = [
+ typical_size * 0.005,
+ typical_size * 0.01,
+ typical_size * 0.05,
+ typical_size * 0.1,
+ typical_size * 0.2,
+ typical_size * 0.5,
+ typical_size * 1.0
+ ]
+
+ print(f" Testing {len(epsilon_candidates)} epsilon values...")
+
+ best_epsilon = None
+ best_mapping = None
+ best_score = -1
+ best_stats = None
+
+ for epsilon in epsilon_candidates:
+ # Test this epsilon
+ mapping, stats = self._test_epsilon(
+ fault_surface, locator, epsilon,
+ fault_centers, fault_normals, vol_centers
+ )
+
+ # Score = percentage with both sides + penalty for no neighbors
+ score = stats['pct_both'] - 2.0 * stats['pct_none']
+
+ print(f" ε={epsilon:.3f}m → Both: {stats['pct_both']:.1f}%, "
+ f"One: {stats['pct_one']:.1f}%, None: {stats['pct_none']:.1f}%, "
+ f"Avg: {stats['avg_neighbors']:.2f} (score: {score:.1f})")
+
+ if score > best_score:
+ best_score = score
+ best_epsilon = epsilon
+ best_mapping = mapping
+ best_stats = stats
+
+ print(f"\n ✅ Best epsilon: {best_epsilon:.6f}m")
+ print(f" ✅ Face-sharing mapping completed:")
+ print(f" Both sides: {best_stats['n_both']} ({best_stats['pct_both']:.1f}%)")
+ print(f" One side: {best_stats['n_one']} ({best_stats['pct_one']:.1f}%)")
+ print(f" No neighbors: {best_stats['n_none']} ({best_stats['pct_none']:.1f}%)")
+ print(f" Average neighbors per fault cell: {best_stats['avg_neighbors']:.2f}")
+
+ return best_mapping
+
+ # -------------------------------------------------------------------
+ def _test_epsilon(self, fault_surface, locator, epsilon,
+ fault_centers, fault_normals, vol_centers):
+ """
+ Test a specific epsilon value and return mapping + statistics
+ """
+ mapping = {}
+ n_found_both = 0
+ n_found_one = 0
+ n_found_none = 0
+ total_neighbors = 0
+
+ for fid in range(fault_surface.n_cells):
+ fcenter = fault_centers[fid]
+ fnormal = fault_normals[fid]
+
+ plus_cells = []
+ minus_cells = []
+
+ # Search on PLUS side
+ point_plus = fcenter + epsilon * fnormal
+ cell_id_plus = locator.FindCell(point_plus)
+ if cell_id_plus >= 0:
+ plus_cells.append(cell_id_plus)
+
+ # Search on MINUS side
+ point_minus = fcenter - epsilon * fnormal
+ cell_id_minus = locator.FindCell(point_minus)
+ if cell_id_minus >= 0:
+ minus_cells.append(cell_id_minus)
+
+ mapping[fid] = {"plus": plus_cells, "minus": minus_cells}
+
+ # Statistics
+ n_neighbors = len(plus_cells) + len(minus_cells)
+ total_neighbors += n_neighbors
+
+ if len(plus_cells) > 0 and len(minus_cells) > 0:
+ n_found_both += 1
+ elif len(plus_cells) > 0 or len(minus_cells) > 0:
+ n_found_one += 1
+ else:
+ n_found_none += 1
+
+ n_cells = fault_surface.n_cells
+ avg_neighbors = total_neighbors / n_cells if n_cells > 0 else 0
+
+ stats = {
+ 'n_both': n_found_both,
+ 'n_one': n_found_one,
+ 'n_none': n_found_none,
+ 'pct_both': n_found_both / n_cells * 100,
+ 'pct_one': n_found_one / n_cells * 100,
+ 'pct_none': n_found_none / n_cells * 100,
+ 'avg_neighbors': avg_neighbors
+ }
+
+ return mapping, stats
+
+ # -------------------------------------------------------------------
+ def _visualize_contributions(self):
+ """
+ Unified visualization of volume contributions to fault surfaces
+ 4-panel view combining full context, side classification, clip, and slice
+ """
+ import pyvista as pv
+
+ print("\n📊 Creating contribution visualization...")
+
+ # Create plotter with 4 subplots
+ plotter = pv.Plotter(shape=(2, 2), window_size=[1800, 1400])
+
+ # ========== PLOT 1: Full context (top-left) ==========
+ plotter.subplot(0, 0)
+ plotter.add_text("Full Context - Volume & Fault", font_size=14, position='upper_edge')
+
+ # All volume (transparent)
+ plotter.add_mesh(self.mesh, color='lightgray', opacity=0.05,
+ show_edges=False, label='Volume')
+
+ # Fault surface (red)
+ plotter.add_mesh(self.fault_surface, color='red', opacity=1,
+ show_edges=True, label='Fault Surface')
+
+ plotter.add_legend(loc="upper left")
+ plotter.add_axes()
+ plotter.set_scale(zscale=self.config.Z_SCALE)
+
+ # ========== PLOT 2: Contributing cells by side (top-right) ==========
+ plotter.subplot(0, 1)
+ plotter.add_text("Contributing Cells",
+ font_size=14, position='upper_edge')
+
+ if 'contribution_side' in self.volume_mesh.cell_data:
+ # Plus side (blue)
+ if self.contributing_cells_plus.n_cells > 0:
+ plotter.add_mesh(self.contributing_cells_plus, color='dodgerblue',
+ opacity=1.0, show_edges=True,
+ label=f'Plus side ({self.contributing_cells_plus.n_cells} cells)')
+
+ # Minus side (orange)
+ if self.contributing_cells_minus.n_cells > 0:
+ plotter.add_mesh(self.contributing_cells_minus, color='darkorange',
+ opacity=1.0, show_edges=True,
+ label=f'Minus side ({self.contributing_cells_minus.n_cells} cells)')
+
+ # Fault surface for reference
+ plotter.add_mesh(self.fault_surface, color='red', opacity=1.0,
+ show_edges=True, label='Fault')
+
+ plotter.add_legend(loc='upper right')
+ plotter.add_axes()
+ plotter.set_scale(zscale=self.config.Z_SCALE)
+
+ # ========== PLOT 3: Clipped view (bottom-left) ==========
+ plotter.subplot(1, 0)
+ plotter.add_text("Clipped View - Contributing Cells",
+ font_size=14, position='upper_edge')
+
+ # Determine clip position (middle of fault)
+ bounds = self.fault_surface.bounds
+ clip_normal = [0, 0, -1] # Clip along Z axis
+ clip_origin = [0,0, (bounds[4] + bounds[5]) / 2]
+
+ # Clip and show contributing cells
+ if self.contributing_cells.n_cells > 0:
+ plotter.add_mesh_clip_plane(
+ self.contributing_cells,
+ normal=clip_normal,
+ origin=clip_origin,
+ color='blue',
+ opacity=1,
+ show_edges=True,
+ label='Contributing (clipped)'
+ )
+
+ # Fault surface
+ plotter.add_mesh(self.fault_surface, color='red', opacity=1.0,
+ show_edges=True, label='Fault')
+
+ plotter.add_legend(loc='upper left')
+ plotter.add_axes()
+ plotter.set_scale(zscale=self.config.Z_SCALE)
+
+ # ========== PLOT 4: Slice view (bottom-right) ==========
+ plotter.subplot(1, 1)
+
+ # Determine slice position (middle of fault in Z)
+ slice_position = (bounds[4] + bounds[5]) / 2
+ plotter.add_text(f"Slice View at Z={slice_position:.1f}m",
+ font_size=14, position='upper_edge')
+
+ # Create slice of volume
+ slice_vol = self.volume_mesh.slice(normal='z', origin=[0, 0, slice_position])
+ slice_fault = self.fault_surface.slice(normal='z', origin=[0, 0, slice_position])
+
+ # Show contributing vs non-contributing in slice
+ if 'contribution_side' in slice_vol.cell_data:
+ # Non-contributing cells (gray)
+ non_contrib_mask = slice_vol.cell_data['contribution_side'] == 0
+ if np.sum(non_contrib_mask) > 0:
+ non_contrib = slice_vol.extract_cells(non_contrib_mask)
+ plotter.add_mesh(non_contrib, color='lightgray', opacity=0.15,
+ show_edges=True, line_width=1, label='Non-contributing')
+
+ # Plus side (blue)
+ plus_mask = (slice_vol.cell_data['contribution_side'] == 1) | \
+ (slice_vol.cell_data['contribution_side'] == 3)
+ if np.sum(plus_mask) > 0:
+ plus_cells = slice_vol.extract_cells(plus_mask)
+ plotter.add_mesh(plus_cells, color='dodgerblue', opacity=0.7,
+ show_edges=True, line_width=2, label='Plus side')
+
+ # Minus side (orange)
+ minus_mask = (slice_vol.cell_data['contribution_side'] == 2) | \
+ (slice_vol.cell_data['contribution_side'] == 3)
+ if np.sum(minus_mask) > 0:
+ minus_cells = slice_vol.extract_cells(minus_mask)
+ plotter.add_mesh(minus_cells, color='darkorange', opacity=0.7,
+ show_edges=True, line_width=2, label='Minus side')
+
+ # Fault slice (thick red line)
+ if slice_fault.n_cells > 0:
+ plotter.add_mesh(slice_fault, color='red', line_width=6,
+ label='Fault', render_lines_as_tubes=True)
+
+ plotter.add_legend(loc='upper right')
+ plotter.add_axes()
+ plotter.set_scale(zscale=self.config.Z_SCALE)
+ plotter.view_xy()
+
+ # Link all views for synchronized rotation
+ plotter.link_views()
+
+ # Show or save
+ if self.config.SHOW_PLOTS:
+ plotter.show()
+ else:
+ # Save screenshot
+ from pathlib import Path
+ output_dir = Path(self.config.OUTPUT_DIR) if hasattr(self.config, 'OUTPUT_DIR') else Path('.')
+ output_dir.mkdir(parents=True, exist_ok=True)
+ screenshot_path = output_dir / "contribution_visualization.png"
+ plotter.screenshot(str(screenshot_path))
+ print(f" 💾 Visualization saved: {screenshot_path}")
+ plotter.close()
+
+ # -------------------------------------------------------------------
+ # NORMALS
+ # -------------------------------------------------------------------
+ def _extract_and_compute_normals(self, show_plot=False, scale_factor=50.0, z_scale=1.0):
+ """Extract fault surfaces and compute oriented normals/tangents"""
+ surfaces = []
+
+ for fault_id in self.fault_values:
+ # Extract fault cells
+ fault_mask = self.mesh.cell_data[self.fault_attribute] == fault_id
+ fault_cells = self.mesh.extract_cells(fault_mask)
+
+ if fault_cells.n_cells == 0:
+ print(f"⚠️ No cells for fault {fault_id}")
+ continue
+
+ # Extract surface
+ surf = fault_cells.extract_surface()
+ if surf.n_cells == 0:
+ continue
+
+ # Compute normals
+ surf.compute_normals(cell_normals=True, point_normals=True, inplace=True)
+
+ # Orient normals consistently within the fault
+ surf = self._orient_normals(surf)
+
+ surfaces.append(surf)
+
+ merged = pv.MultiBlock(surfaces).combine()
+ print(f"✅ Normals computed for {merged.n_cells} fault cells")
+
+ if show_plot:
+ self._plot_geometry(merged, scale_factor, z_scale)
+
+ return merged, surfaces
+
+ # -------------------------------------------------------------------
+ def _orient_normals(self, surf):
+ """Ensure normals point in consistent direction within the fault"""
+ normals = surf.cell_data['Normals']
+ mean_normal = np.mean(normals, axis=0)
+ mean_normal /= np.linalg.norm(mean_normal)
+
+ n_cells = len(normals)
+ tangents1 = np.zeros((n_cells, 3))
+ tangents2 = np.zeros((n_cells, 3))
+
+ for i, normal in enumerate(normals):
+
+ # Flip if pointing opposite to mean
+ if np.dot(normal, mean_normal) < 0:
+ normals[i] = -normal
+
+ if self.config.ROTATE_NORMALS:
+ normals[i] = -normal
+
+ # Compute orthogonal tangents
+ normal = normals[i]
+ if abs(normal[0]) > 1e-6 or abs(normal[1]) > 1e-6:
+ t1 = np.array([-normal[1], normal[0], 0])
+ else:
+ t1 = np.array([0, -normal[2], normal[1]])
+
+ t1 /= np.linalg.norm(t1)
+ t2 = np.cross(normal, t1)
+ t2 /= np.linalg.norm(t2)
+
+ tangents1[i] = t1
+ tangents2[i] = t2
+
+ surf.cell_data['Normals'] = normals
+ surf.cell_data['tangent1'] = tangents1
+ surf.cell_data['tangent2'] = tangents2
+
+ dip_angles, strike_angles = self.compute_dip_strike_from_cell_base(normals, tangents1, tangents2)
+
+ surf.cell_data['dip_angle'] = dip_angles
+ surf.cell_data['strike_angle'] = strike_angles
+
+ return surf
+
+ # -------------------------------------------------------------------
+ def compute_dip_strike_from_cell_base(self, normals, tangent1, tangent2):
+ """
+ Calcule les angles dip et strike à partir des vecteurs normaux et tangents des cellules.
+ Hypothèses :
+ - Système de coordonnées : X=Est, Y=Nord, Z=Haut.
+ - Vecteurs donnés par cellule (shape: (n_cells, 3)).
+ - Les vecteurs d'entrée sont supposés orthonormés (n = t1 x t2).
+
+ Retourne :
+ dip_deg, strike_deg (two arrays of shape (n_cells,))
+ """
+ # 1. Identifier le vecteur strike (le plus horizontal)
+ t1_horiz = tangent1 - (tangent1[:, 2][:, np.newaxis] * np.array([0, 0, 1]))
+ t2_horiz = tangent2 - (tangent2[:, 2][:, np.newaxis] * np.array([0, 0, 1]))
+ norm_t1_h = np.linalg.norm(t1_horiz, axis=1)
+ norm_t2_h = np.linalg.norm(t2_horiz, axis=1)
+
+ use_t1 = norm_t1_h > norm_t2_h
+ strike_vector = np.zeros_like(tangent1)
+ strike_vector[use_t1] = t1_horiz[use_t1]
+ strike_vector[~use_t1] = t2_horiz[~use_t1]
+
+ # Normaliser
+ strike_norm = np.linalg.norm(strike_vector, axis=1)
+ # Éviter la division par zéro (si la faille est parfaitement verticale, le strike est bien défini par l'autre vecteur)
+ strike_norm[strike_norm == 0] = 1.0
+ strike_vector = strike_vector / strike_norm[:, np.newaxis]
+
+ # 2. Calculer le strike (azimut depuis le Nord, sens horaire)
+ strike_rad = np.arctan2(strike_vector[:, 0], strike_vector[:, 1]) # atan2(E, N)
+ strike_deg = np.degrees(strike_rad)
+ strike_deg = np.where(strike_deg < 0, strike_deg + 360, strike_deg)
+
+ # 3. Calculer le dip
+ norm_horiz = np.linalg.norm(normals[:, :2], axis=1)
+ dip_rad = np.arcsin(np.clip(norm_horiz, 0, 1)) # clip pour éviter les erreurs d'arrondi
+ dip_deg = np.degrees(dip_rad)
+
+ return dip_deg, strike_deg
+
+ # -------------------------------------------------------------------
+ def _plot_geometry(self, surface, scale_factor, z_scale):
+ """Visualize fault geometry with normals"""
+ plotter = pv.Plotter()
+ plotter.add_mesh(self.mesh, color='lightgray', opacity=0.1, label='Volume')
+ plotter.add_mesh(surface, color='darkgray', opacity=0.7, show_edges=True, label='Fault')
+
+ centers = surface.cell_centers()
+ for name, color in [('Normals', 'red'), ('tangent1', 'green'), ('tangent2', 'blue')]:
+ arrows = centers.glyph(orient=name, scale=z_scale, factor=scale_factor)
+ plotter.add_mesh(arrows, color=color, label=name)
+
+ plotter.add_legend()
+ plotter.add_axes()
+ plotter.set_scale(zscale=z_scale)
+ plotter.show()
+
+ # -------------------------------------------------------------------
+ def diagnose_normals(self, scale_factor=50.0, z_scale=1.0):
+ """
+ Diagnostic visualization to check normal quality
+ Shows orthogonality and orientation issues
+ """
+ surface = self.fault_surface
+
+ print("\n🔍 DIAGNOSTIC DES NORMALES")
+ print("=" * 60)
+
+ normals = surface.cell_data['Normals']
+ tangent1 = surface.cell_data['tangent1']
+ tangent2 = surface.cell_data['tangent2']
+
+ n_cells = len(normals)
+
+ # Check orthogonality
+ dot_n_t1 = np.array([np.dot(normals[i], tangent1[i]) for i in range(n_cells)])
+ dot_n_t2 = np.array([np.dot(normals[i], tangent2[i]) for i in range(n_cells)])
+ dot_t1_t2 = np.array([np.dot(tangent1[i], tangent2[i]) for i in range(n_cells)])
+
+ print(f"Orthogonalité (doit être proche de 0):")
+ print(f" Normal · Tangent1 : max={np.max(np.abs(dot_n_t1)):.2e}, mean={np.mean(np.abs(dot_n_t1)):.2e}")
+ print(f" Normal · Tangent2 : max={np.max(np.abs(dot_n_t2)):.2e}, mean={np.mean(np.abs(dot_n_t2)):.2e}")
+ print(f" Tangent1 · Tangent2: max={np.max(np.abs(dot_t1_t2)):.2e}, mean={np.mean(np.abs(dot_t1_t2)):.2e}")
+
+ # Check unit vectors
+ norm_n = np.linalg.norm(normals, axis=1)
+ norm_t1 = np.linalg.norm(tangent1, axis=1)
+ norm_t2 = np.linalg.norm(tangent2, axis=1)
+
+ print(f"\nNormes (doit être proche de 1):")
+ print(f" Normals : min={np.min(norm_n):.6f}, max={np.max(norm_n):.6f}")
+ print(f" Tangent1 : min={np.min(norm_t1):.6f}, max={np.max(norm_t1):.6f}")
+ print(f" Tangent2 : min={np.min(norm_t2):.6f}, max={np.max(norm_t2):.6f}")
+
+ # Check orientation consistency
+ mean_normal = np.mean(normals, axis=0)
+ mean_normal = mean_normal / np.linalg.norm(mean_normal)
+
+ dots_with_mean = np.array([np.dot(normals[i], mean_normal) for i in range(n_cells)])
+ n_reversed = np.sum(dots_with_mean < 0)
+
+ print(f"\nCohérence d'orientation:")
+ print(f" Normale moyenne: [{mean_normal[0]:.3f}, {mean_normal[1]:.3f}, {mean_normal[2]:.3f}]")
+ print(f" Normales inversées: {n_reversed}/{n_cells} ({n_reversed/n_cells*100:.1f}%)")
+
+ if n_reversed > n_cells * 0.1:
+ print(f" ⚠️ Plus de 10% des normales pointent dans la direction opposée!")
+ else:
+ print(f" ✅ Orientation cohérente")
+
+ print("=" * 60)
+
+ # Visualization
+ plotter = pv.Plotter(shape=(1, 2))
+
+ # Plot 1: Surface with normals
+ plotter.subplot(0, 0)
+ plotter.add_mesh(surface, color='lightgray', show_edges=True, opacity=0.8)
+
+ centers = surface.cell_centers()
+ arrows_n = centers.glyph(orient='Normals', scale=False, factor=scale_factor)
+ plotter.add_mesh(arrows_n, color='red', label='Normals')
+
+ plotter.add_legend()
+ plotter.add_axes()
+ plotter.add_text("Normales (Rouge)", position='upper_edge')
+ plotter.set_scale(zscale=z_scale)
+
+ # Plot 2: All vectors
+ plotter.subplot(0, 1)
+ plotter.add_mesh(surface, color='lightgray', show_edges=True, opacity=0.5)
+
+ arrows_n = centers.glyph(orient='Normals', scale=False, factor=scale_factor)
+ arrows_t1 = centers.glyph(orient='tangent1', scale=False, factor=scale_factor)
+ arrows_t2 = centers.glyph(orient='tangent2', scale=False, factor=scale_factor)
+
+ plotter.add_mesh(arrows_n, color='red', label='Normal')
+ plotter.add_mesh(arrows_t1, color='green', label='Tangent1')
+ plotter.add_mesh(arrows_t2, color='blue', label='Tangent2')
+
+ plotter.add_legend()
+ plotter.add_axes()
+ plotter.add_text("Système complet (R,G,B)", position='upper_edge')
+ plotter.set_scale(zscale=z_scale)
+
+ plotter.link_views()
+ plotter.show()
+
+ return surface
+
+
+ """
+ Diagnostic visualization to check normal quality
+ Shows orthogonality and orientation issues
+ """
+ print("\n🔍 DIAGNOSTIC DES NORMALES")
+ print("=" * 60)
+
+ normals = surface.cell_data['Normals']
+ tangent1 = surface.cell_data['tangent1']
+ tangent2 = surface.cell_data['tangent2']
+
+ n_cells = len(normals)
+
+ # Check orthogonality
+ dot_n_t1 = np.array([np.dot(normals[i], tangent1[i]) for i in range(n_cells)])
+ dot_n_t2 = np.array([np.dot(normals[i], tangent2[i]) for i in range(n_cells)])
+ dot_t1_t2 = np.array([np.dot(tangent1[i], tangent2[i]) for i in range(n_cells)])
+
+ print(f"Orthogonalité (doit être proche de 0):")
+ print(f" Normal · Tangent1 : max={np.max(np.abs(dot_n_t1)):.2e}, mean={np.mean(np.abs(dot_n_t1)):.2e}")
+ print(f" Normal · Tangent2 : max={np.max(np.abs(dot_n_t2)):.2e}, mean={np.mean(np.abs(dot_n_t2)):.2e}")
+ print(f" Tangent1 · Tangent2: max={np.max(np.abs(dot_t1_t2)):.2e}, mean={np.mean(np.abs(dot_t1_t2)):.2e}")
+
+ # Check unit vectors
+ norm_n = np.array([np.linalg.norm(normals[i]) for i in range(n_cells)])
+ norm_t1 = np.array([np.linalg.norm(tangent1[i]) for i in range(n_cells)])
+ norm_t2 = np.array([np.linalg.norm(tangent2[i]) for i in range(n_cells)])
+
+ print(f"\nNormes (doit être proche de 1):")
+ print(f" Normals : min={np.min(norm_n):.6f}, max={np.max(norm_n):.6f}")
+ print(f" Tangent1 : min={np.min(norm_t1):.6f}, max={np.max(norm_t1):.6f}")
+ print(f" Tangent2 : min={np.min(norm_t2):.6f}, max={np.max(norm_t2):.6f}")
+
+ # Check orientation consistency
+ mean_normal = np.mean(normals, axis=0)
+ mean_normal = mean_normal / np.linalg.norm(mean_normal)
+
+ dots_with_mean = np.array([np.dot(normals[i], mean_normal) for i in range(n_cells)])
+ n_reversed = np.sum(dots_with_mean < 0)
+
+ print(f"\nCohérence d'orientation:")
+ print(f" Normale moyenne: [{mean_normal[0]:.3f}, {mean_normal[1]:.3f}, {mean_normal[2]:.3f}]")
+ print(f" Normales inversées: {n_reversed}/{n_cells} ({n_reversed/n_cells*100:.1f}%)")
+
+ # Visual check
+ if n_reversed > n_cells * 0.1:
+ print(f" ⚠️ Plus de 10% des normales pointent dans la direction opposée!")
+ else:
+ print(f" ✅ Orientation cohérente")
+
+ # Check for problematic cells
+ bad_ortho = (np.abs(dot_n_t1) > 1e-3) | (np.abs(dot_n_t2) > 1e-3) | (np.abs(dot_t1_t2) > 1e-3)
+ n_bad = np.sum(bad_ortho)
+
+ if n_bad > 0:
+ print(f"\n⚠️ {n_bad} cellules avec orthogonalité douteuse (|dot| > 1e-3)")
+ surface.cell_data['orthogonality_error'] = np.maximum.reduce([
+ np.abs(dot_n_t1), np.abs(dot_n_t2), np.abs(dot_t1_t2)
+ ])
+ else:
+ print(f"\n✅ Toutes les cellules ont une bonne orthogonalité")
+
+ print("=" * 60)
+
+ # Visualization
+ plotter = pv.Plotter(shape=(1, 2))
+
+ # Plot 1: Surface with normals
+ plotter.subplot(0, 0)
+ plotter.add_mesh(surface, color='lightgray', show_edges=True, opacity=0.8)
+
+ centers = surface.cell_centers()
+ arrows_n = centers.glyph(orient='Normals', scale=False, factor=scale_factor)
+ plotter.add_mesh(arrows_n, color='red', label='Normals')
+
+ plotter.add_legend()
+ plotter.add_axes()
+ plotter.add_text("Normales (Rouge)", position='upper_edge')
+ plotter.set_scale(zscale=z_scale)
+
+ # Plot 2: All vectors (normal + tangents)
+ plotter.subplot(0, 1)
+ plotter.add_mesh(surface, color='lightgray', show_edges=True, opacity=0.5)
+
+ arrows_n = centers.glyph(orient='Normals', scale=False, factor=scale_factor)
+ arrows_t1 = centers.glyph(orient='tangent1', scale=False, factor=scale_factor)
+ arrows_t2 = centers.glyph(orient='tangent2', scale=False, factor=scale_factor)
+
+ plotter.add_mesh(arrows_n, color='red', label='Normal')
+ plotter.add_mesh(arrows_t1, color='green', label='Tangent1')
+ plotter.add_mesh(arrows_t2, color='blue', label='Tangent2')
+
+ plotter.add_legend()
+ plotter.add_axes()
+ plotter.add_text("Système complet (R,G,B)", position='upper_edge')
+ plotter.set_scale(zscale=z_scale)
+
+ plotter.link_views()
+ plotter.show()
+
+ return surface
+
+
+# ============================================================================
+# STRESS PROJECTION
+# ============================================================================
+class StressProjector:
+ """Projects volume stress onto fault surfaces and tracks principal stresses in VTU"""
+
+ # -------------------------------------------------------------------
+ def __init__(self, config, adjacency_mapping, geometric_properties):
+ """
+ Initialize with pre-computed adjacency mapping and geometric properties
+
+ Parameters
+ ----------
+ config : Configuration object
+ adjacency_mapping : dict
+ Pre-computed dict mapping fault cells to volume cells
+ geometric_properties : dict
+ Pre-computed geometric properties from FaultGeometry:
+ - 'volumes': cell volumes
+ - 'centers': cell centers
+ - 'distances': distances to fault
+ - 'fault_tree': KDTree for fault
+ """
+ self.config = config
+ self.adjacency_mapping = adjacency_mapping
+
+ # Store pre-computed geometric properties
+ self.volume_cell_volumes = geometric_properties['volumes']
+ self.volume_centers = geometric_properties['centers']
+ self.distance_to_fault = geometric_properties['distances']
+ self.fault_tree = geometric_properties['fault_tree']
+
+ # Storage for time series metadata
+ self.timestep_info = []
+
+ # Track which cells to monitor (optional)
+ self.monitored_cells = None
+
+ # Output directory for VTU files
+ self.vtu_output_dir = None
+
+ # -------------------------------------------------------------------
+ def set_monitored_cells(self, cell_indices):
+ """
+ Set specific cells to monitor (optional)
+
+ Parameters:
+ cell_indices: list of volume cell indices to track
+ If None, all contributing cells are tracked
+ """
+ self.monitored_cells = set(cell_indices) if cell_indices is not None else None
+
+ # -------------------------------------------------------------------
+ def project_stress_to_fault(self, volume_data, volume_initial, fault_surface,
+ time=None, timestep=None, weighting_scheme="arithmetic"):
+ """
+ Project stress and save principal stresses to VTU
+
+ Now uses pre-computed geometric properties for efficiency
+ """
+ stress_name = self.config.STRESS_NAME
+ biot_name = self.config.BIOT_NAME
+
+ if stress_name not in volume_data.array_names:
+ raise ValueError(f"No stress data '{stress_name}' in dataset")
+
+ # =====================================================================
+ # 1. EXTRACT STRESS DATA
+ # =====================================================================
+ pressure = volume_data["pressure"] / 1e5
+ p_fault = volume_initial["pressure"] / 1e5
+ p_init = volume_initial["pressure"] / 1e5
+ biot = volume_data[biot_name]
+
+ stress_eff = StressTensor.build_from_array(volume_data[stress_name] / 1e5)
+ stress_eff_init = StressTensor.build_from_array(volume_initial[stress_name] / 1e5)
+
+ # Convert effective stress to total stress
+ I = np.eye(3)[None, :, :]
+ stress_total = stress_eff - biot[:, None, None] * pressure[:, None, None] * I
+ stress_total_init = stress_eff_init - biot[:, None, None] * p_init[:, None, None] * I
+
+ # =====================================================================
+ # 2. USE PRE-COMPUTED ADJACENCY
+ # =====================================================================
+ mapping = self.adjacency_mapping
+
+ # =====================================================================
+ # 3. PREPARE FAULT GEOMETRY
+ # =====================================================================
+ normals = fault_surface.cell_data["Normals"]
+ tangent1 = fault_surface.cell_data["tangent1"]
+ tangent2 = fault_surface.cell_data["tangent2"]
+
+ fault_centers = fault_surface.cell_centers().points
+ fault_surface.cell_data['elementCenter'] = fault_centers
+
+ n_fault = fault_surface.n_cells
+ n_volume = volume_data.n_cells
+
+ # =====================================================================
+ # 4. COMPUTE PRINCIPAL STRESSES FOR CONTRIBUTING CELLS
+ # =====================================================================
+ if self.config.COMPUTE_PRINCIPAL_STRESS and timestep is not None:
+
+ # Collect all unique contributing cells
+ all_contributing_cells = set()
+ for fault_idx, neighbors in mapping.items():
+ all_contributing_cells.update(neighbors['plus'])
+ all_contributing_cells.update(neighbors['minus'])
+
+ # Filter by monitored cells if specified
+ if self.monitored_cells is not None:
+ cells_to_track = all_contributing_cells.intersection(self.monitored_cells)
+ else:
+ cells_to_track = all_contributing_cells
+
+ print(f" 📊 Computing principal stresses for {len(cells_to_track)} contributing cells...")
+
+ # Create mesh with only contributing cells
+ contributing_mesh = self._create_volumic_contrib_mesh(
+ volume_data, fault_surface, cells_to_track, mapping
+ )
+
+ # Save to VTU
+ if self.vtu_output_dir is None:
+ self.vtu_output_dir = Path(self.config.OUTPUT_DIR) / "principal_stresses"
+
+ self._save_principal_stress_vtu(contributing_mesh, time, timestep)
+
+ else:
+ contributing_mesh = None
+
+ # =====================================================================
+ # 6. PROJECT STRESS FOR EACH FAULT CELL
+ # =====================================================================
+ sigma_n_arr = np.zeros(n_fault)
+ tau_arr = np.zeros(n_fault)
+ tau_dip_arr = np.zeros(n_fault)
+ tau_strike_arr = np.zeros(n_fault)
+ delta_sigma_n_arr = np.zeros(n_fault)
+ delta_tau_arr = np.zeros(n_fault)
+ n_contributors = np.zeros(n_fault, dtype=int)
+
+ print(f" 🔄 Projecting stress to {n_fault} fault cells...")
+ print(f" Weighting scheme: {weighting_scheme}")
+
+ for fault_idx in range(n_fault):
+ if fault_idx not in mapping:
+ continue
+
+ vol_plus = mapping[fault_idx]['plus']
+ vol_minus = mapping[fault_idx]['minus']
+ all_vol = vol_plus + vol_minus
+
+ if len(all_vol) == 0:
+ continue
+
+ # ===================================================================
+ # CALCULATE WEIGHTS (using pre-computed properties)
+ # ===================================================================
+
+ if weighting_scheme == 'arithmetic':
+ weights = np.ones(len(all_vol)) / len(all_vol)
+
+ elif weighting_scheme == 'harmonic':
+ weights = np.ones(len(all_vol)) / len(all_vol)
+
+ elif weighting_scheme == 'distance':
+ # Use pre-computed distances
+ dists = np.array([self.distance_to_fault[v] for v in all_vol])
+ dists = np.maximum(dists, 1e-6)
+ inv_dists = 1.0 / dists
+ weights = inv_dists / np.sum(inv_dists)
+
+ elif weighting_scheme == 'volume':
+ # Use pre-computed volumes
+ vols = np.array([self.volume_cell_volumes[v] for v in all_vol])
+ weights = vols / np.sum(vols)
+
+ elif weighting_scheme == 'distance_volume':
+ # Use pre-computed volumes and distances
+ vols = np.array([self.volume_cell_volumes[v] for v in all_vol])
+ dists = np.array([self.distance_to_fault[v] for v in all_vol])
+ dists = np.maximum(dists, 1e-6)
+
+ weights = vols / dists
+ weights = weights / np.sum(weights)
+
+ elif weighting_scheme == 'inverse_square_distance':
+ # Use pre-computed distances
+ dists = np.array([self.distance_to_fault[v] for v in all_vol])
+ dists = np.maximum(dists, 1e-6)
+ inv_sq_dists = 1.0 / (dists ** 2)
+ weights = inv_sq_dists / np.sum(inv_sq_dists)
+
+ else:
+ raise ValueError(f"Unknown weighting scheme: {weighting_scheme}")
+
+ # ===================================================================
+ # ACCUMULATE WEIGHTED CONTRIBUTIONS
+ # ===================================================================
+
+ sigma_n = 0.0
+ tau = 0.0
+ tau_dip = 0.0
+ tau_strike = 0.0
+ delta_sigma_n = 0.0
+ delta_tau = 0.0
+
+ for vol_idx, w in zip(all_vol, weights):
+
+ # Total stress (with pressure)
+ sigma_final = stress_total[vol_idx] + p_fault[vol_idx] * np.eye(3)
+ sigma_init = stress_total_init[vol_idx] + p_init[vol_idx] * np.eye(3)
+
+ # Rotate to fault frame
+ res_f = StressTensor.rotate_to_fault_frame(
+ sigma_final, normals[fault_idx], tangent1[fault_idx], tangent2[fault_idx]
+ )
+
+ res_i = StressTensor.rotate_to_fault_frame(
+ sigma_init, normals[fault_idx], tangent1[fault_idx], tangent2[fault_idx]
+ )
+
+ # Accumulate weighted contributions
+ sigma_n += w * res_f['normal_stress']
+ tau += w * res_f['shear_stress']
+ tau_dip += w * res_f['shear_dip']
+ tau_strike += w * res_f['shear_strike']
+ delta_sigma_n += w * (res_f['normal_stress'] - res_i['normal_stress'])
+ delta_tau += w * (res_f['shear_stress'] - res_i['shear_stress'])
+
+ sigma_n_arr[fault_idx] = sigma_n
+ tau_arr[fault_idx] = tau
+ tau_dip_arr[fault_idx] = tau_dip
+ tau_strike_arr[fault_idx] = tau_strike
+ delta_sigma_n_arr[fault_idx] = delta_sigma_n
+ delta_tau_arr[fault_idx] = delta_tau
+ n_contributors[fault_idx] = len(all_vol)
+
+ # =====================================================================
+ # 7. STORE RESULTS ON FAULT SURFACE
+ # =====================================================================
+ fault_surface.cell_data["sigma_n_eff"] = sigma_n_arr
+ fault_surface.cell_data["tau_eff"] = tau_dip_arr
+ fault_surface.cell_data["tau_strike"] = tau_strike_arr
+ fault_surface.cell_data["tau_dip"] = tau_dip_arr
+ fault_surface.cell_data["delta_sigma_n_eff"] = delta_sigma_n_arr
+ fault_surface.cell_data["delta_tau_eff"] = delta_tau_arr
+
+ # =====================================================================
+ # 8. STATISTICS
+ # =====================================================================
+ valid = n_contributors > 0
+ n_valid = np.sum(valid)
+
+ print(f" ✅ Stress projected: {n_valid}/{n_fault} fault cells ({n_valid/n_fault*100:.1f}%)")
+
+ if np.sum(valid) > 0:
+ print(f" Contributors per fault cell: min={np.min(n_contributors[valid])}, "
+ f"max={np.max(n_contributors[valid])}, "
+ f"mean={np.mean(n_contributors[valid]):.1f}")
+
+ return fault_surface, volume_data, contributing_mesh
+
+ # -------------------------------------------------------------------
+ @staticmethod
+ def compute_principal_stresses(stress_tensor):
+ """
+ Compute principal stresses and directions
+
+ Convention: Compression is NEGATIVE
+ - σ1 = most compressive (most negative)
+ - σ3 = least compressive (least negative, or most tensile)
+
+ Returns:
+ dict with eigenvalues, eigenvectors, mean_stress, deviatoric_stress
+ """
+ eigenvalues, eigenvectors = np.linalg.eigh(stress_tensor)
+
+ # Sort from MOST NEGATIVE to LEAST NEGATIVE (most compressive to least)
+ # Example: -600 < -450 < -200, so -600 is σ1 (most compressive)
+ idx = np.argsort(eigenvalues) # Ascending order (most negative first)
+ eigenvalues_sorted = eigenvalues[idx]
+ eigenvectors_sorted = eigenvectors[:, idx]
+
+ return {
+ 'sigma1': eigenvalues_sorted[0], # Most compressive (most negative)
+ 'sigma2': eigenvalues_sorted[1], # Intermediate
+ 'sigma3': eigenvalues_sorted[2], # Least compressive (least negative)
+ 'mean_stress': np.mean(eigenvalues_sorted),
+ 'deviatoric_stress': eigenvalues_sorted[0] - eigenvalues_sorted[2], # σ1 - σ3 (negative - more negative = positive or less negative)
+ 'direction1': eigenvectors_sorted[:, 0], # Direction of σ1
+ 'direction2': eigenvectors_sorted[:, 1], # Direction of σ2
+ 'direction3': eigenvectors_sorted[:, 2] # Direction of σ3
+ }
+
+ # -------------------------------------------------------------------
+ def _create_volumic_contrib_mesh(self, volume_data, fault_surface, cells_to_track, mapping):
+ """
+ Create a mesh containing only contributing cells with principal stress data
+ and compute analytical normal/shear stresses based on fault dip angle
+
+ Parameters
+ ----------
+ volume_data : pyvista.UnstructuredGrid
+ Volume mesh with stress data (rock_stress or averageStress)
+ fault_surface : pyvista.PolyData
+ Fault surface with dip_angle and strike_angle per cell
+ cells_to_track : set
+ Set of volume cell indices to include
+ mapping : dict
+ Adjacency mapping {fault_idx: {'plus': [...], 'minus': [...]}}
+ """
+
+ # ===================================================================
+ # EXTRACT STRESS DATA FROM VOLUME
+ # ===================================================================
+ stress_name = self.config.STRESS_NAME
+ biot_name = self.config.BIOT_NAME
+
+ if stress_name not in volume_data.array_names:
+ raise ValueError(f"No stress data '{stress_name}' in volume dataset")
+
+ print(f" 📊 Extracting stress from field: '{stress_name}'")
+
+ # Extract effective stress and pressure
+ pressure = volume_data["pressure"] / 1e5 # Convert to bar
+ biot = volume_data[biot_name]
+
+ stress_eff = StressTensor.build_from_array(volume_data[stress_name] / 1e5)
+
+ # Convert effective stress to total stress
+ I = np.eye(3)[None, :, :]
+ stress_total = stress_eff - biot[:, None, None] * pressure[:, None, None] * I
+
+ # ===================================================================
+ # EXTRACT SUBSET OF CELLS
+ # ===================================================================
+ cell_indices = sorted(list(cells_to_track))
+ cell_mask = np.zeros(volume_data.n_cells, dtype=bool)
+ cell_mask[cell_indices] = True
+
+ subset_mesh = volume_data.extract_cells(cell_mask)
+
+ # ===================================================================
+ # REBUILD MAPPING: subset_idx -> original_idx
+ # ===================================================================
+ original_centers = volume_data.cell_centers().points[cell_indices]
+ subset_centers = subset_mesh.cell_centers().points
+
+ from scipy.spatial import cKDTree
+ tree = cKDTree(original_centers)
+
+ subset_to_original = np.zeros(subset_mesh.n_cells, dtype=int)
+ for subset_idx in range(subset_mesh.n_cells):
+ dist, idx = tree.query(subset_centers[subset_idx])
+ if dist > 1e-6:
+ print(f" WARNING: Cell {subset_idx} not matched (dist={dist})")
+ subset_to_original[subset_idx] = cell_indices[idx]
+
+ # ===================================================================
+ # MAP VOLUME CELLS TO FAULT DIP/STRIKE ANGLES
+ # ===================================================================
+ print(f" 📐 Mapping volume cells to fault dip/strike angles...")
+
+ # Check if fault surface has required data
+ if 'dip_angle' not in fault_surface.cell_data:
+ print(f" ⚠️ WARNING: 'dip_angle' not found in fault_surface")
+ print(f" Available fields: {list(fault_surface.cell_data.keys())}")
+ return None
+
+ if 'strike_angle' not in fault_surface.cell_data:
+ print(f" ⚠️ WARNING: 'strike_angle' not found in fault_surface")
+
+ # Create mapping: volume_cell_id -> [dip_angles, strike_angles]
+ volume_to_dip = {}
+ volume_to_strike = {}
+
+ for fault_idx, neighbors in mapping.items():
+ # Get dip and strike angle from fault cell
+ fault_dip = fault_surface.cell_data['dip_angle'][fault_idx]
+
+ # Strike is optional
+ if 'strike_angle' in fault_surface.cell_data:
+ fault_strike = fault_surface.cell_data['strike_angle'][fault_idx]
+ else:
+ fault_strike = np.nan
+
+ # Assign to all contributing volume cells (plus and minus)
+ for vol_idx in neighbors['plus'] + neighbors['minus']:
+ if vol_idx not in volume_to_dip:
+ volume_to_dip[vol_idx] = []
+ volume_to_strike[vol_idx] = []
+ volume_to_dip[vol_idx].append(fault_dip)
+ volume_to_strike[vol_idx].append(fault_strike)
+
+ # Average if a volume cell contributes to multiple fault cells
+ volume_to_dip_avg = {vol_idx: np.mean(dips)
+ for vol_idx, dips in volume_to_dip.items()}
+ volume_to_strike_avg = {vol_idx: np.mean(strikes)
+ for vol_idx, strikes in volume_to_strike.items()}
+
+ print(f" ✅ Mapped {len(volume_to_dip_avg)} volume cells to fault angles")
+
+ # Statistics
+ all_dips = [np.mean(dips) for dips in volume_to_dip.values()]
+ if len(all_dips) > 0:
+ print(f" Dip angle range: [{np.min(all_dips):.1f}, {np.max(all_dips):.1f}]°")
+
+ # ===================================================================
+ # COMPUTE PRINCIPAL STRESSES AND ANALYTICAL FAULT STRESSES
+ # ===================================================================
+ n_cells = subset_mesh.n_cells
+
+ sigma1_arr = np.zeros(n_cells)
+ sigma2_arr = np.zeros(n_cells)
+ sigma3_arr = np.zeros(n_cells)
+ mean_stress_arr = np.zeros(n_cells)
+ deviatoric_stress_arr = np.zeros(n_cells)
+ pressure_arr = np.zeros(n_cells)
+
+ direction1_arr = np.zeros((n_cells, 3))
+ direction2_arr = np.zeros((n_cells, 3))
+ direction3_arr = np.zeros((n_cells, 3))
+
+ # NEW: Analytical fault stresses
+ sigma_n_analytical_arr = np.zeros(n_cells)
+ tau_analytical_arr = np.zeros(n_cells)
+ dip_angle_arr = np.zeros(n_cells)
+ strike_angle_arr = np.zeros(n_cells)
+ delta_arr = np.zeros(n_cells)
+
+ side_arr = np.zeros(n_cells, dtype=int)
+ n_fault_cells_arr = np.zeros(n_cells, dtype=int)
+
+ print(f" 🔢 Computing principal stresses and analytical projections...")
+
+ for subset_idx in range(n_cells):
+ orig_idx = subset_to_original[subset_idx]
+
+ # ===============================================================
+ # COMPUTE PRINCIPAL STRESSES
+ # ===============================================================
+ # Total stress = effective stress + pore pressure
+ sigma_total_cell = stress_total[orig_idx] + pressure[orig_idx] * np.eye(3)
+ principal = self.compute_principal_stresses(sigma_total_cell)
+
+ sigma1_arr[subset_idx] = principal['sigma1']
+ sigma2_arr[subset_idx] = principal['sigma2']
+ sigma3_arr[subset_idx] = principal['sigma3']
+ mean_stress_arr[subset_idx] = principal['mean_stress']
+ deviatoric_stress_arr[subset_idx] = principal['deviatoric_stress']
+ pressure_arr[subset_idx] = pressure[orig_idx]
+
+ direction1_arr[subset_idx] = principal['direction1']
+ direction2_arr[subset_idx] = principal['direction2']
+ direction3_arr[subset_idx] = principal['direction3']
+
+ # ===============================================================
+ # COMPUTE ANALYTICAL FAULT STRESSES (Anderson formulas)
+ # ===============================================================
+ if orig_idx in volume_to_dip_avg:
+ dip_deg = volume_to_dip_avg[orig_idx]
+ dip_angle_arr[subset_idx] = dip_deg
+
+ strike_deg = volume_to_strike_avg.get(orig_idx, np.nan)
+ strike_angle_arr[subset_idx] = strike_deg
+
+ # δ = 90° - dip (angle from horizontal)
+ delta_deg = 90.0 - dip_deg
+ delta_rad = np.radians(delta_deg)
+ delta_arr[subset_idx] = delta_deg
+
+ # Extract principal stresses (compression negative)
+ sigma1 = principal['sigma1'] # Most compressive (most negative)
+ sigma3 = principal['sigma3'] # Least compressive (least negative)
+
+ # Anderson formulas (1951)
+ # σ_n = (σ1 + σ3)/2 - (σ1 - σ3)/2 * cos(2δ)
+ # τ = |(σ1 - σ3)/2 * sin(2δ)|
+
+ sigma_mean = (sigma1 + sigma3) / 2.0
+ sigma_diff = (sigma1 - sigma3) / 2.0
+
+ sigma_n_analytical = sigma_mean - sigma_diff * np.cos(2 * delta_rad)
+ tau_analytical = sigma_diff * np.sin(2 * delta_rad)
+
+ sigma_n_analytical_arr[subset_idx] = sigma_n_analytical
+ tau_analytical_arr[subset_idx] = np.abs(tau_analytical)
+ else:
+ # No fault association - set to NaN
+ dip_angle_arr[subset_idx] = np.nan
+ strike_angle_arr[subset_idx] = np.nan
+ delta_arr[subset_idx] = np.nan
+ sigma_n_analytical_arr[subset_idx] = np.nan
+ tau_analytical_arr[subset_idx] = np.nan
+
+ # ===============================================================
+ # DETERMINE SIDE (plus/minus/both)
+ # ===============================================================
+ is_plus = False
+ is_minus = False
+ fault_cell_count = 0
+
+ for fault_idx, neighbors in mapping.items():
+ if orig_idx in neighbors['plus']:
+ is_plus = True
+ fault_cell_count += 1
+ if orig_idx in neighbors['minus']:
+ is_minus = True
+ fault_cell_count += 1
+
+ if is_plus and is_minus:
+ side_arr[subset_idx] = 3 # both
+ elif is_plus:
+ side_arr[subset_idx] = 1 # plus
+ elif is_minus:
+ side_arr[subset_idx] = 2 # minus
+ else:
+ side_arr[subset_idx] = 0 # none (should not happen)
+
+ n_fault_cells_arr[subset_idx] = fault_cell_count
+
+ # ===================================================================
+ # ADD DATA TO MESH
+ # ===================================================================
+ subset_mesh.cell_data['sigma1'] = sigma1_arr
+ subset_mesh.cell_data['sigma2'] = sigma2_arr
+ subset_mesh.cell_data['sigma3'] = sigma3_arr
+ subset_mesh.cell_data['mean_stress'] = mean_stress_arr
+ subset_mesh.cell_data['deviatoric_stress'] = deviatoric_stress_arr
+ subset_mesh.cell_data['pressure_bar'] = pressure_arr
+
+ subset_mesh.cell_data['sigma1_direction'] = direction1_arr
+ subset_mesh.cell_data['sigma2_direction'] = direction2_arr
+ subset_mesh.cell_data['sigma3_direction'] = direction3_arr
+
+ # Analytical fault stresses
+ subset_mesh.cell_data['sigma_n_analytical'] = sigma_n_analytical_arr
+ subset_mesh.cell_data['tau_analytical'] = tau_analytical_arr
+ subset_mesh.cell_data['dip_angle'] = dip_angle_arr
+ subset_mesh.cell_data['strike_angle'] = strike_angle_arr
+ subset_mesh.cell_data['delta_angle'] = delta_arr
+
+ # ===================================================================
+ # COMPUTE SCU ANALYTICALLY (Mohr-Coulomb)
+ # ===================================================================
+ if hasattr(self.config, 'FRICTION_ANGLE') and hasattr(self.config, 'COHESION'):
+ mu = np.tan(np.radians(self.config.FRICTION_ANGLE))
+ cohesion = self.config.COHESION
+
+ # τ_crit = C - σ_n * μ
+ # Note: σ_n is negative (compression), so -σ_n * μ is positive
+ tau_crit_arr = cohesion - sigma_n_analytical_arr * mu
+
+ # SCU = τ / τ_crit
+ SCU_analytical_arr = np.divide(
+ tau_analytical_arr,
+ tau_crit_arr,
+ out=np.zeros_like(tau_analytical_arr),
+ where=tau_crit_arr != 0
+ )
+
+ subset_mesh.cell_data['tau_crit_analytical'] = tau_crit_arr
+ subset_mesh.cell_data['SCU_analytical'] = SCU_analytical_arr
+
+ # CFS (Coulomb Failure Stress)
+ CFS_analytical_arr = tau_analytical_arr - mu * (-sigma_n_analytical_arr)
+ subset_mesh.cell_data['CFS_analytical'] = CFS_analytical_arr
+
+ subset_mesh.cell_data['side'] = side_arr
+ subset_mesh.cell_data['n_fault_cells'] = n_fault_cells_arr
+ subset_mesh.cell_data['original_cell_id'] = subset_to_original
+
+ # ===================================================================
+ # STATISTICS
+ # ===================================================================
+ valid_analytical = ~np.isnan(sigma_n_analytical_arr)
+ n_valid = np.sum(valid_analytical)
+
+ if n_valid > 0:
+ print(f" 📊 Analytical fault stresses computed for {n_valid}/{n_cells} cells")
+ print(f" σ_n range: [{np.nanmin(sigma_n_analytical_arr):.1f}, {np.nanmax(sigma_n_analytical_arr):.1f}] bar")
+ print(f" τ range: [{np.nanmin(tau_analytical_arr):.1f}, {np.nanmax(tau_analytical_arr):.1f}] bar")
+ print(f" Dip angle range: [{np.nanmin(dip_angle_arr):.1f}, {np.nanmax(dip_angle_arr):.1f}]°")
+
+ if hasattr(self.config, 'FRICTION_ANGLE') and hasattr(self.config, 'COHESION'):
+ print(f" SCU range: [{np.nanmin(SCU_analytical_arr[valid_analytical]):.2f}, {np.nanmax(SCU_analytical_arr[valid_analytical]):.2f}]")
+ n_critical = np.sum((SCU_analytical_arr >= 0.8) & (SCU_analytical_arr < 1.0))
+ n_unstable = np.sum(SCU_analytical_arr >= 1.0)
+ print(f" Critical cells (SCU≥0.8): {n_critical} ({n_critical/n_valid*100:.1f}%)")
+ print(f" Unstable cells (SCU≥1.0): {n_unstable} ({n_unstable/n_valid*100:.1f}%)")
+ else:
+ print(f" ⚠️ No analytical stresses computed (no fault mapping)")
+
+ return subset_mesh
+
+ # -------------------------------------------------------------------
+ def _save_principal_stress_vtu(self, mesh, time, timestep):
+ """
+ Save principal stress mesh to VTU file
+
+ Parameters:
+ mesh: PyVista mesh with principal stress data
+ time: Simulation time
+ timestep: Timestep index
+ """
+ # Create output directory
+ self.vtu_output_dir.mkdir(parents=True, exist_ok=True)
+
+ # Generate filename
+ vtu_filename = f"principal_stresses_{timestep:05d}.vtu"
+ vtu_path = self.vtu_output_dir / vtu_filename
+
+ # Save mesh
+ mesh.save(str(vtu_path))
+
+ # Store metadata for PVD
+ self.timestep_info.append({
+ 'time': time if time is not None else timestep,
+ 'timestep': timestep,
+ 'file': vtu_filename
+ })
+
+ print(f" 💾 Saved principal stresses: {vtu_filename}")
+
+ # -------------------------------------------------------------------
+ def save_pvd_collection(self, filename="principal_stresses.pvd"):
+ """
+ Create PVD file for time series visualization in ParaView
+
+ Parameters:
+ filename: Name of PVD file
+ """
+ if len(self.timestep_info) == 0:
+ print("⚠️ No timestep data to save in PVD")
+ return
+
+ pvd_path = self.vtu_output_dir / filename
+
+ print(f"\n💾 Creating PVD collection: {pvd_path}")
+ print(f" Timesteps: {len(self.timestep_info)}")
+
+ # Create XML structure
+ root = Element('VTKFile')
+ root.set('type', 'Collection')
+ root.set('version', '0.1')
+ root.set('byte_order', 'LittleEndian')
+
+ collection = SubElement(root, 'Collection')
+
+ for info in self.timestep_info:
+ dataset = SubElement(collection, 'DataSet')
+ dataset.set('timestep', str(info['time']))
+ dataset.set('group', '')
+ dataset.set('part', '0')
+ dataset.set('file', info['file'])
+
+ # Write to file
+ tree = ElementTree(root)
+ tree.write(str(pvd_path), encoding='utf-8', xml_declaration=True)
+
+ print(f" ✅ PVD file created successfully")
+ print(f" 📂 Output directory: {self.vtu_output_dir}")
+ print(f"\n 🎨 To visualize in ParaView:")
+ print(f" 1. Open: {pvd_path}")
+ print(f" 2. Apply")
+ print(f" 3. Color by: sigma1, sigma2, sigma3, mean_stress, etc.")
+ print(f" 4. Use 'side' filter to show plus/minus/both")
+
+
+# ============================================================================
+# MOHR COULOMB
+# ============================================================================
+class MohrCoulomb:
+ """Mohr-Coulomb failure criterion analysis"""
+
+ @staticmethod
+ def analyze(surface, cohesion, friction_angle_deg, time=0, verbose=True):
+ """
+ Perform Mohr-Coulomb stability analysis
+
+ Parameters:
+ surface: fault surface with stress data
+ cohesion: cohesion in bar
+ friction_angle_deg: friction angle in degrees
+ time: simulation time
+ verbose: print statistics
+ """
+ mu = np.tan(np.radians(friction_angle_deg))
+
+ # Extract stress components
+ sigma_n = surface.cell_data["sigma_n_eff"]
+ tau = surface.cell_data["tau_eff"]
+ d_sigma_n = surface.cell_data['delta_sigma_n_eff']
+ d_tau = surface.cell_data['delta_tau_eff']
+
+ # Mohr-Coulomb failure envelope
+ tau_crit = cohesion - sigma_n * mu
+
+ # Coulomb Failure Stress
+ CFS = tau - mu * sigma_n
+ # delta_CFS = d_tau - mu * d_sigma_n
+
+ # Shear Capacity Utilization: SCU = τ / τ_crit
+ SCU = np.divide(tau, tau_crit, out=np.zeros_like(tau), where=tau_crit != 0)
+
+ if "SCU_initial" not in surface.cell_data:
+ # First timestep: store as initial reference
+ SCU_initial = SCU.copy()
+ CFS_initial = CFS.copy()
+ delta_SCU = np.zeros_like(SCU)
+ delta_CFS = np.zeros_like(CFS)
+
+ surface.cell_data["SCU_initial"] = SCU_initial
+ surface.cell_data["CFS_initial"] = CFS_initial
+
+ is_initial = True
+ else:
+ # Subsequent timesteps: calculate change from initial
+ SCU_initial = surface.cell_data["SCU_initial"]
+ CFS_initial = surface.cell_data['CFS_initial']
+ delta_SCU = SCU - SCU_initial
+ delta_CFS = CFS - CFS_initial
+ is_initial = False
+
+ # Stability classification
+ stability = np.zeros_like(tau, dtype=int)
+ stability[SCU >= 0.8] = 1 # Critical
+ stability[SCU >= 1.0] = 2 # Unstable
+
+ # Failure probability (sigmoid)
+ k = 10.0
+ failure_prob = 1.0 / (1.0 + np.exp(-k * (SCU - 1.0)))
+
+ # Safety margin
+ safety = tau_crit - tau
+
+ # Store results
+ surface.cell_data.update({
+ "mohr_cohesion": np.full(surface.n_cells, cohesion),
+ "mohr_friction_angle": np.full(surface.n_cells, friction_angle_deg),
+ "mohr_friction_coefficient": np.full(surface.n_cells, mu),
+ "mohr_critical_shear_stress": tau_crit,
+ "SCU": SCU,
+ "delta_SCU": delta_SCU,
+ "CFS" : CFS,
+ "delta_CFS": delta_CFS,
+ "safety_margin": safety,
+ "stability_state": stability,
+ "failure_probability": failure_prob
+ })
+
+ if verbose:
+ n_stable = np.sum(stability == 0)
+ n_critical = np.sum(stability == 1)
+ n_unstable = np.sum(stability == 2)
+
+ # Additional info on delta_SCU
+ if not is_initial:
+ mean_delta = np.mean(np.abs(delta_SCU))
+ max_increase = np.max(delta_SCU)
+ max_decrease = np.min(delta_SCU)
+ print(f" ✅ Mohr-Coulomb: {n_unstable} unstable, {n_critical} critical, "
+ f"{n_stable} stable cells")
+ print(f" ΔSCU: mean={mean_delta:.3f}, max_increase={max_increase:.3f}, "
+ f"max_decrease={max_decrease:.3f}")
+ else:
+ print(f" ✅ Mohr-Coulomb (initial): {n_unstable} unstable, {n_critical} critical, "
+ f"{n_stable} stable cells")
+
+ return surface
+
+
+# ============================================================================
+# TIME SERIES PROCESSING
+# ============================================================================
+class TimeSeriesProcessor:
+ """Process multiple time steps from PVD file"""
+
+ # -------------------------------------------------------------------
+ def __init__(self, config):
+ self.config = config
+ self.output_dir = Path(config.OUTPUT_DIR)
+ self.output_dir.mkdir(exist_ok=True)
+
+ # -------------------------------------------------------------------
+ def process(self, path, fault_geometry, pvd_file):
+ """
+ Process all time steps using pre-computed fault geometry
+
+ Parameters:
+ path: base path for input files
+ fault_geometry: FaultGeometry object with initialized topology
+ pvd_file: PVD file name
+ """
+ pvd_reader = pv.PVDReader(path / pvd_file)
+ time_values = np.array(pvd_reader.time_values)
+
+ if self.config.TIME_INDEX:
+ time_values = time_values[self.config.TIME_INDEX]
+
+ output_files = []
+ data_initial = None
+ SCU_initial_reference = None
+
+ # Get pre-computed data from fault_geometry
+ surface = fault_geometry.fault_surface
+ adjacency_mapping = fault_geometry.adjacency_mapping
+ geometric_properties = fault_geometry.get_geometric_properties()
+
+ # Initialize projector with pre-computed topology
+ projector = StressProjector(self.config, adjacency_mapping, geometric_properties)
+
+
+ print('\n')
+ print("=" * 60)
+ print("TIME SERIES PROCESSING")
+ print("=" * 60)
+
+ for i, time in enumerate(time_values):
+ print(f"\n→ Step {i+1}/{len(time_values)}: {time/(365.25*24*3600):.2f} years")
+
+ # Read time step
+ idx = self.config.TIME_INDEX[i] if self.config.TIME_INDEX else i
+ pvd_reader.set_active_time_point(idx)
+ dataset = pvd_reader.read()
+
+ # Merge blocks
+ volume_data = self._merge_blocks(dataset)
+
+ if data_initial is None:
+ data_initial = volume_data
+
+ # -----------------------------------
+ # Projection using pre-computed topology
+ # -----------------------------------
+ # Projection
+ surface_result, volume_marked, contributing_cells = projector.project_stress_to_fault(
+ volume_data,
+ data_initial,
+ surface,
+ time=time_values[i], # Simulation time
+ timestep=i, # Timestep index
+ weighting_scheme=self.config.WEIGHTING_SCHEME
+ )
+
+ # -----------------------------------
+ # Mohr-Coulomb analysis
+ # -----------------------------------
+ cohesion = self.config.COHESION
+ friction_angle = self.config.FRICTION_ANGLE
+ surface_result = MohrCoulomb.analyze(surface_result, cohesion, friction_angle, time)
+
+ # -----------------------------------
+ # Visualize
+ # -----------------------------------
+ self._plot_results(surface_result, contributing_cells, time, self.output_dir)
+
+ # -----------------------------------
+ # Sensitivity analysis
+ # -----------------------------------
+ if self.config.RUN_SENSITIVITY:
+ analyzer = SensitivityAnalyzer(self.config)
+ sensitivity_results = analyzer.run_analysis(surface_result, time)
+
+ # Save
+ filename = f'fault_analysis_{i:04d}.vtu'
+ surface_result.save(self.output_dir / filename)
+ output_files.append((time, filename))
+ print(f" 💾 Saved: {filename}")
+
+ # Create master PVD
+ self._create_pvd(output_files)
+
+ return surface_result
+
+ # -------------------------------------------------------------------
+ def _merge_blocks(self, dataset):
+ """Merge multi-block dataset - descente automatique jusqu'aux données"""
+
+ # -----------------------------------------------
+ def extract_leaf_blocks(block, path="", depth=0):
+ """
+ Descend récursivement dans la structure MultiBlock jusqu'aux feuilles avec données
+
+ Returns:
+ list of (block, path, bounds) tuples
+ """
+ leaves = []
+
+ # Cas 1: C'est un MultiBlock avec des sous-blocs
+ if hasattr(block, 'n_blocks') and block.n_blocks > 0:
+ for i in range(block.n_blocks):
+ sub_block = block.GetBlock(i)
+ block_name = block.get_block_name(i) if hasattr(block, 'get_block_name') else f"Block{i}"
+ new_path = f"{path}/{block_name}" if path else block_name
+
+ if sub_block is not None:
+ # Récursion
+ leaves.extend(extract_leaf_blocks(sub_block, new_path, depth + 1))
+
+ # Cas 2: C'est un dataset final (feuille)
+ elif hasattr(block, 'n_cells') and block.n_cells > 0:
+ bounds = block.bounds
+ leaves.append((block, path, bounds))
+
+ return leaves
+
+ print(f" 📦 Extracting volume blocks")
+
+ # Extraire toutes les feuilles
+ all_blocks = extract_leaf_blocks(dataset)
+
+ # Filtrer et afficher
+ merged = []
+ blocks_with_pressure = 0
+ blocks_without_pressure = 0
+
+ for block, path, bounds in all_blocks:
+ has_pressure = 'pressure' in block.cell_data
+
+ if has_pressure:
+ blocks_with_pressure += 1
+ merged.append(block)
+ else:
+ blocks_without_pressure += 1
+
+ # Combiner
+ combined = pv.MultiBlock(merged).combine()
+
+ return combined
+
+ # -------------------------------------------------------------------
+ def _plot_results(self, surface, contributing_cells, time, path):
+
+ Visualizer.plot_mohr_coulomb_diagram( surface, time, path,
+ show=self.config.SHOW_PLOTS,
+ save=self.config.SAVE_PLOTS )
+
+ # Profils verticaux automatiques
+ if self.config.SHOW_DEPTH_PROFILES:
+ Visualizer.plot_depth_profiles(
+ self,
+ surface, time, path,
+ show=self.config.SHOW_PLOTS,
+ save=self.config.SAVE_PLOTS,
+ profile_start_points=self.config.PROFILE_START_POINTS )
+
+ visualizer = Visualizer(self.config)
+
+ if self.config.COMPUTE_PRINCIPAL_STRESS:
+
+ # Plot principal stress from volume cells
+ visualizer.plot_volume_stress_profiles(
+ volume_mesh=contributing_cells,
+ fault_surface=surface,
+ time=time,
+ path=path,
+ profile_start_points=self.config.PROFILE_START_POINTS )
+
+ # Visualize comparison analytical/numerical
+ visualizer.plot_analytical_vs_numerical_comparison(
+ volume_mesh=contributing_cells,
+ fault_surface=surface,
+ time=time,
+ path=path,
+ show=self.config.SHOW_PLOTS,
+ save=self.config.SAVE_PLOTS,
+ profile_start_points=self.config.PROFILE_START_POINTS)
+
+ # -------------------------------------------------------------------
+ def _create_pvd(self, output_files):
+ """Create PVD collection file"""
+ pvd_path = self.output_dir / 'fault_analysis.pvd'
+ with open(pvd_path, 'w') as f:
+ f.write('\n')
+ f.write(' \n')
+ for t, fname in output_files:
+ f.write(f' \n')
+ f.write(' \n')
+ f.write('\n')
+ print(f"\n✅ PVD created: {pvd_path}")
+
+
+# ============================================================================
+# SENSITIVITY ANALYSIS
+# ============================================================================
+class SensitivityAnalyzer:
+ """Performs sensitivity analysis on Mohr-Coulomb parameters"""
+
+ # -------------------------------------------------------------------
+ def __init__(self, config):
+ self.config = config
+ self.output_dir = Path(config.SENSITIVITY_OUTPUT_DIR)
+ self.output_dir.mkdir(exist_ok=True)
+ self.results = []
+
+ # -------------------------------------------------------------------
+ def run_analysis(self, surface_with_stress, time):
+ """Run sensitivity analysis for multiple friction angles and cohesions"""
+ friction_angles = self.config.SENSITIVITY_FRICTION_ANGLES
+ cohesions = self.config.SENSITIVITY_COHESIONS
+
+ print("\n" + "=" * 60)
+ print("SENSITIVITY ANALYSIS")
+ print("=" * 60)
+ print(f"Friction angles: {friction_angles}")
+ print(f"Cohesions: {cohesions}")
+ print(f"Total combinations: {len(friction_angles) * len(cohesions)}")
+
+ results = []
+
+ for friction_angle in friction_angles:
+ for cohesion in cohesions:
+ print(f"\n→ Testing φ={friction_angle}°, C={cohesion} bar")
+
+ surface_copy = surface_with_stress.copy()
+
+ surface_analyzed = MohrCoulomb.analyze(
+ surface_copy, cohesion, friction_angle, time, verbose=False)
+
+ stats = self._extract_statistics(surface_analyzed, friction_angle, cohesion)
+ results.append(stats)
+
+ print(f" Unstable: {stats['n_unstable']}, "
+ f"Critical: {stats['n_critical']}, "
+ f"Stable: {stats['n_stable']}")
+
+ self.results = results
+
+ # Generate plots
+ self._plot_sensitivity_results(results, time)
+
+ # Plot SCU vs depth
+ self._plot_scu_depth_profiles(results, time, surface_with_stress)
+
+ return results
+
+ # -------------------------------------------------------------------
+ def _extract_statistics(self, surface, friction_angle, cohesion):
+ """Extract statistical metrics from analyzed surface"""
+ stability = surface.cell_data["stability_state"]
+ SCU = surface.cell_data["SCU"]
+ failure_prob = surface.cell_data["failure_probability"]
+ safety_margin = surface.cell_data["safety_margin"]
+
+ stats = {
+ 'friction_angle': friction_angle,
+ 'cohesion': cohesion,
+ 'n_cells': surface.n_cells,
+ 'n_stable': np.sum(stability == 0),
+ 'n_critical': np.sum(stability == 1),
+ 'n_unstable': np.sum(stability == 2),
+ 'pct_unstable': np.sum(stability == 2) / surface.n_cells * 100,
+ 'pct_critical': np.sum(stability == 1) / surface.n_cells * 100,
+ 'pct_stable': np.sum(stability == 0) / surface.n_cells * 100,
+ 'mean_SCU': np.mean(SCU),
+ 'max_SCU': np.max(SCU),
+ 'mean_failure_prob': np.mean(failure_prob),
+ 'mean_safety_margin': np.mean(safety_margin),
+ 'min_safety_margin': np.min(safety_margin)
+ }
+
+ return stats
+
+ # -------------------------------------------------------------------
+ def _plot_sensitivity_results(self, results, time):
+ """Create comprehensive sensitivity analysis plots"""
+ import pandas as pd
+
+ df = pd.DataFrame(results)
+
+ fig, axes = plt.subplots(2, 2, figsize=(16, 12))
+
+ # Plot heatmaps
+ self._plot_heatmap(df, 'pct_unstable', 'Unstable Cells [%]', axes[0, 0])
+ self._plot_heatmap(df, 'pct_critical', 'Critical Cells [%]', axes[0, 1])
+ self._plot_heatmap(df, 'mean_SCU', 'Mean SCU [-]', axes[1, 0])
+ self._plot_heatmap(df, 'mean_safety_margin', 'Mean Safety Margin [bar]', axes[1, 1])
+
+ plt.tight_layout()
+
+ years = time / (365.25 * 24 * 3600)
+ filename = f'sensitivity_analysis_{years:.0f}y.png'
+ plt.savefig(self.output_dir / filename, dpi=300, bbox_inches='tight')
+ print(f"\n📊 Sensitivity plot saved: {filename}")
+
+ if self.config.SHOW_PLOTS:
+ plt.show()
+ else:
+ plt.close()
+
+ # -------------------------------------------------------------------
+ def _plot_heatmap(self, df, column, title, ax):
+ """Create a single heatmap for sensitivity analysis"""
+ pivot = df.pivot(index='cohesion', columns='friction_angle', values=column)
+
+ im = ax.imshow(pivot.values, cmap='RdYlGn_r', aspect='auto', origin='lower')
+
+ ax.set_xticks(np.arange(len(pivot.columns)))
+ ax.set_yticks(np.arange(len(pivot.index)))
+ ax.set_xticklabels(pivot.columns)
+ ax.set_yticklabels(pivot.index)
+
+ ax.set_xlabel('Friction Angle [°]')
+ ax.set_ylabel('Cohesion [bar]')
+ ax.set_title(title)
+
+ # Add values in cells
+ for i in range(len(pivot.index)):
+ for j in range(len(pivot.columns)):
+ value = pivot.values[i, j]
+ text_color = 'white' if value > pivot.values.max() * 0.5 else 'black'
+ ax.text(j, i, f'{value:.1f}', ha='center', va='center',
+ color=text_color, fontsize=9)
+
+ plt.colorbar(im, ax=ax)
+
+ # -------------------------------------------------------------------
+ def _plot_scu_depth_profiles(self, results, time, surface_with_stress):
+ """
+ Plot SCU depth profiles for all parameter combinations
+ Each (cohesion, friction) pair gets a unique color
+ Uses profile points from config.PROFILE_START_POINTS
+ """
+ import pandas as pd
+ from matplotlib.colors import Normalize
+ from matplotlib.cm import ScalarMappable
+
+ print("\n 📊 Creating SCU sensitivity depth profiles...")
+
+ # Extract depth data
+ centers = surface_with_stress.cell_data['elementCenter']
+ depth = centers[:, 2]
+
+ # Get profile points from config
+ profile_start_points = self.config.PROFILE_START_POINTS
+
+ # Auto-generate if not provided
+ if profile_start_points is None:
+ print(" ⚠️ No PROFILE_START_POINTS in config, auto-generating...")
+ x_min, x_max = np.min(centers[:, 0]), np.max(centers[:, 0])
+ y_min, y_max = np.min(centers[:, 1]), np.max(centers[:, 1])
+
+ x_range = x_max - x_min
+ y_range = y_max - y_min
+
+ if x_range > y_range:
+ # Fault oriented in X, sample at mid-Y
+ x_pos = (x_min + x_max) / 2
+ y_pos = (y_min + y_max) / 2
+ else:
+ # Fault oriented in Y, sample at mid-X
+ x_pos = (x_min + x_max) / 2
+ y_pos = (y_min + y_max) / 2
+
+ profile_start_points = [(x_pos, y_pos)]
+
+ # Get search radius from config or auto-compute
+ search_radius = getattr(self.config, 'PROFILE_SEARCH_RADIUS', None)
+ if search_radius is None:
+ x_min, x_max = np.min(centers[:, 0]), np.max(centers[:, 0])
+ y_min, y_max = np.min(centers[:, 1]), np.max(centers[:, 1])
+ x_range = x_max - x_min
+ y_range = y_max - y_min
+ search_radius = min(x_range, y_range) * 0.15
+
+ print(f" 📍 Using {len(profile_start_points)} profile point(s) from config")
+ print(f" Search radius: {search_radius:.1f}m")
+
+ # Create colormap for parameter combinations
+ n_combinations = len(results)
+ cmap = plt.cm.viridis
+ norm = Normalize(vmin=0, vmax=n_combinations-1)
+ sm = ScalarMappable(norm=norm, cmap=cmap)
+
+ # Create figure with subplots for each profile point
+ n_profiles = len(profile_start_points)
+ fig, axes = plt.subplots(1, n_profiles, figsize=(8*n_profiles, 10))
+
+ # Handle single subplot case
+ if n_profiles == 1:
+ axes = [axes]
+
+ # Plot each profile point
+ for profile_idx, (x_pos, y_pos, z_pos) in enumerate(profile_start_points):
+ ax = axes[profile_idx]
+
+ print(f"\n → Profile {profile_idx+1} at ({x_pos:.1f}, {y_pos:.1f}, {z_pos:.1f}):")
+
+
+ # Plot each parameter combination
+ for idx, params in enumerate(results):
+ friction_angle = params['friction_angle']
+ cohesion = params['cohesion']
+
+ # Re-analyze surface with these parameters
+ surface_copy = surface_with_stress.copy()
+ surface_analyzed = MohrCoulomb.analyze(
+ surface_copy, cohesion, friction_angle, time, verbose=False
+ )
+
+ # Extract SCU
+ SCU = np.abs(surface_analyzed.cell_data["SCU"])
+
+ # Extract profile using adaptive method
+ # depths_SCU, profile_SCU, _, _ = ProfileExtractor.extract_vertical_profile_topology_based(
+ # surface_analyzed, 'SCU', x_pos, y_pos, z_pos, verbose=False)
+ depths_SCU, profile_SCU, _, _ = ProfileExtractor.extract_adaptive_profile(
+ centers, SCU, x_pos, y_pos, search_radius)
+
+ if len(depths_SCU) >= 3:
+ color = cmap(norm(idx))
+ label = f'φ={friction_angle}°, C={cohesion} bar'
+ ax.plot(profile_SCU, depths_SCU,
+ color=color, label=label,
+ linewidth=2, alpha=0.8)
+
+ if idx == 0: # Print info only once per profile
+ print(f" ✅ {len(depths_SCU)} points extracted")
+ else:
+ if idx == 0:
+ print(f" ⚠️ Insufficient points ({len(depths_SCU)})")
+
+ # Add critical lines
+ ax.axvline(x=0.8, color='forestgreen', linestyle='--',
+ linewidth=2, label='Critical (SCU=0.8)', zorder=100)
+ ax.axvline(x=1.0, color='red', linestyle='--',
+ linewidth=2, label='Failure (SCU=1.0)', zorder=100)
+
+ # Configure plot
+ ax.set_xlabel('Shear Capacity Utilization (SCU) [-]', fontsize=14, weight='bold')
+ ax.set_ylabel('Depth [m]', fontsize=14, weight='bold')
+ ax.set_title(f'Profile {profile_idx+1} @ ({x_pos:.0f}, {y_pos:.0f})',
+ fontsize=14, weight='bold')
+ ax.grid(True, alpha=0.3, linestyle='--')
+ ax.set_xlim(left=0)
+
+ # Change verticale scale
+ if hasattr(self.config, 'MAX_DEPTH_PROFILES') and self.config.MAX_DEPTH_PROFILES is not None:
+ ax.set_ylim(bottom=self.config.MAX_DEPTH_PROFILES)
+
+ # Légende en dehors à droite
+ ax.legend(loc='center left', bbox_to_anchor=(1, 0.5), fontsize=9, ncol=1)
+
+ ax.tick_params(labelsize=12)
+
+ # Overall title
+ years = time / (365.25 * 24 * 3600)
+ fig.suptitle('SCU Depth Profiles - Sensitivity Analysis',
+ fontsize=16, weight='bold', y=0.98)
+
+ plt.tight_layout(rect=[0, 0, 1, 0.96])
+
+ # Save
+ filename = f'sensitivity_scu_profiles_{years:.0f}y.png'
+ plt.savefig(self.output_dir / filename, dpi=300, bbox_inches='tight')
+ print(f"\n 💾 SCU sensitivity profiles saved: {filename}")
+
+ if self.config.SHOW_PLOTS:
+ plt.show()
+ else:
+ plt.close()
+
+
+# ============================================================================
+# PROFILE EXTRACTOR
+# ============================================================================
+class ProfileExtractor:
+ """Utility class for extracting profiles along fault surfaces"""
+
+ # -------------------------------------------------------------------
+ @staticmethod
+ def extract_adaptive_profile(centers, values, x_start, y_start, z_start=None,
+ search_radius=None, step_size=20.0, max_steps=500,
+ verbose=True, fault_bounds=None, cell_data=None):
+ """
+ Extraction de profil vertical par COUCHES DE PROFONDEUR avec détection automatique de faille.
+
+ Stratégie:
+ 1. Trouver le point de départ le plus proche
+ 2. Identifier automatiquement la faille via cell_data (attribute, FaultMask, etc.)
+ 3. FILTRER pour ne garder QUE les cellules de cette faille
+ 4. Diviser en tranches Z
+ 5. Pour chaque tranche, prendre la cellule la plus proche en XY
+
+ Parameters
+ ----------
+ centers : ndarray
+ Cell centers (n_cells, 3)
+ values : ndarray
+ Values at cells (n_cells,)
+ x_start, y_start : float
+ Starting XY position
+ z_start : float, optional
+ Starting Z position (if None, uses highest point near XY)
+ search_radius : float, optional
+ Not used (kept for compatibility)
+ cell_data : dict, optional
+ Dictionary with cell data fields (e.g., {'attribute': array, 'FaultMask': array})
+ Used to automatically detect and filter by fault ID
+ verbose : bool
+ Print detailed information
+
+ Returns
+ -------
+ depths, profile_values, path_x, path_y : ndarrays
+ Extracted profile data
+ """
+
+ from scipy.spatial import cKDTree
+
+ # Convert to np arrays
+ centers = np.asarray(centers)
+ values = np.asarray(values)
+
+ if len(centers) == 0:
+ if verbose:
+ print(f" ⚠️ No cells provided")
+ return np.array([]), np.array([]), np.array([]), np.array([])
+
+ # ===================================================================
+ # ÉTAPE 1: TROUVER LE POINT DE DÉPART
+ # ===================================================================
+
+ if z_start is None:
+ # Chercher en 2D (XY), prendre le plus haut
+ if verbose:
+ print(f" Searching near ({x_start:.1f}, {y_start:.1f})")
+
+ d_xy = np.sqrt((centers[:, 0] - x_start)**2 + (centers[:, 1] - y_start)**2)
+ closest_indices = np.argsort(d_xy)[:20]
+
+ if len(closest_indices) == 0:
+ print(f" ⚠️ No cells found near start point")
+ return np.array([]), np.array([]), np.array([]), np.array([])
+
+ # Prendre le plus haut (plus grand Z)
+ closest_depths = centers[closest_indices, 2]
+ start_idx = closest_indices[np.argmax(closest_depths)]
+ else:
+ # Chercher en 3D
+ if verbose:
+ print(f" Searching near ({x_start:.1f}, {y_start:.1f}, {z_start:.1f})")
+
+ d_3d = np.sqrt((centers[:, 0] - x_start)**2 +
+ (centers[:, 1] - y_start)**2 +
+ (centers[:, 2] - z_start)**2)
+ start_idx = np.argmin(d_3d)
+
+ start_point = centers[start_idx]
+
+ if verbose:
+ print(f" Starting point: ({start_point[0]:.1f}, {start_point[1]:.1f}, {start_point[2]:.1f})")
+ print(f" Starting cell index: {start_idx}")
+
+ # ===================================================================
+ # ÉTAPE 2: DÉTECTER AUTOMATIQUEMENT L'ID DE LA FAILLE
+ # ===================================================================
+
+ fault_ids = None
+ target_fault_id = None
+
+ if cell_data is not None:
+ # Chercher dans l'ordre de priorité
+ fault_field_names = ['attribute', 'FaultMask', 'fault_id', 'region']
+
+ for field_name in fault_field_names:
+ if field_name in cell_data:
+ fault_ids = np.asarray(cell_data[field_name])
+
+ if len(fault_ids) != len(centers):
+ if verbose:
+ print(f" ⚠️ Field '{field_name}' length mismatch, skipping")
+ continue
+
+ # Récupérer l'ID au point de départ
+ target_fault_id = fault_ids[start_idx]
+
+ if verbose:
+ unique_ids = np.unique(fault_ids)
+ print(f" Found fault field: '{field_name}'")
+ print(f" Available fault IDs: {unique_ids}")
+ print(f" Target fault ID at start point: {target_fault_id}")
+
+ break
+
+ # ===================================================================
+ # ÉTAPE 3: FILTRER PAR FAILLE SI DÉTECTÉE
+ # ===================================================================
+
+ if target_fault_id is not None:
+ # FILTRER: garder SEULEMENT cette faille
+ mask_same_fault = (fault_ids == target_fault_id)
+ n_total = len(centers)
+ n_on_fault = np.sum(mask_same_fault)
+
+ if verbose:
+ print(f" Filtering to fault ID={target_fault_id}: {n_on_fault}/{n_total} cells ({n_on_fault/n_total*100:.1f}%)")
+
+ if n_on_fault == 0:
+ print(f" ⚠️ No cells found on target fault")
+ return np.array([]), np.array([]), np.array([]), np.array([])
+
+ # REMPLACER centers et values par le subset filtré
+ centers = centers[mask_same_fault].copy()
+ values = values[mask_same_fault].copy()
+
+ # Trouver le nouvel index de départ dans le subset
+ d_to_start = np.sqrt(np.sum((centers - start_point)**2, axis=1))
+ start_idx = np.argmin(d_to_start)
+
+ if verbose:
+ print(f" ✅ Profile will stay on fault ID={target_fault_id}")
+ else:
+ if verbose:
+ print(f" ⚠️ No fault identification field found")
+ if cell_data is not None:
+ print(f" Available fields: {list(cell_data.keys())}")
+ else:
+ print(f" cell_data not provided")
+ print(f" Profile may jump between faults!")
+
+ # À partir d'ici, centers/values ne contiennent QUE la faille cible
+
+ # ===================================================================
+ # ÉTAPE 4: POSITION DE RÉFÉRENCE
+ # ===================================================================
+
+ ref_x = centers[start_idx, 0]
+ ref_y = centers[start_idx, 1]
+
+ if verbose:
+ print(f" Reference XY: ({ref_x:.1f}, {ref_y:.1f})")
+
+ # ===================================================================
+ # ÉTAPE 5: GÉOMÉTRIE DE LA FAILLE
+ # ===================================================================
+
+ x_range = np.max(centers[:, 0]) - np.min(centers[:, 0])
+ y_range = np.max(centers[:, 1]) - np.min(centers[:, 1])
+ z_range = np.max(centers[:, 2]) - np.min(centers[:, 2])
+
+ if z_range <= 0:
+ print(f" ⚠️ Invalid z_range: {z_range}")
+ return np.array([]), np.array([]), np.array([]), np.array([])
+
+ lateral_extent = max(x_range, y_range)
+ xy_tolerance = max(lateral_extent * 0.3, 100.0)
+
+ if verbose:
+ print(f" Fault extent: X={x_range:.1f}m, Y={y_range:.1f}m, Z={z_range:.1f}m")
+ print(f" XY tolerance: {xy_tolerance:.1f}m")
+
+ # ===================================================================
+ # ÉTAPE 6: CALCUL DES TRANCHES
+ # ===================================================================
+
+ z_coords_sorted = np.sort(centers[:, 2])
+ z_diffs = np.diff(z_coords_sorted)
+ z_diffs_positive = z_diffs[z_diffs > 1e-6]
+
+ if len(z_diffs_positive) == 0:
+ if verbose:
+ print(f" ⚠️ All cells at same Z")
+
+ d_xy = np.sqrt((centers[:, 0] - ref_x)**2 + (centers[:, 1] - ref_y)**2)
+ sorted_indices = np.argsort(d_xy)
+
+ return (centers[sorted_indices, 2],
+ values[sorted_indices],
+ centers[sorted_indices, 0],
+ centers[sorted_indices, 1])
+
+ median_z_spacing = np.median(z_diffs_positive)
+
+ # Vérifier que median_z_spacing est raisonnable
+ if median_z_spacing <= 0 or median_z_spacing > z_range:
+ median_z_spacing = z_range / 100 # Fallback
+
+ # Taille de tranche = espacement médian
+ slice_thickness = median_z_spacing
+
+ z_min = np.min(centers[:, 2])
+ z_max = np.max(centers[:, 2])
+
+ n_slices = int(np.ceil(z_range / slice_thickness))
+ n_slices = min(n_slices, 10000) # Limiter à 10k tranches max
+
+ if n_slices <= 0:
+ print(f" ⚠️ Invalid n_slices: {n_slices}")
+ return np.array([]), np.array([]), np.array([]), np.array([])
+
+ if verbose:
+ print(f" Median Z spacing: {median_z_spacing:.1f}m")
+ print(f" Creating {n_slices} slices")
+
+ try:
+ z_slices = np.linspace(z_max, z_min, n_slices + 1)
+ except (MemoryError, ValueError) as e:
+ print(f" ⚠️ Error creating slices: {e}")
+ return np.array([]), np.array([]), np.array([]), np.array([])
+
+ # ===================================================================
+ # ÉTAPE 7: EXTRACTION PAR TRANCHES
+ # ===================================================================
+
+ profile_indices = []
+
+ for i in range(len(z_slices) - 1):
+ z_top = z_slices[i]
+ z_bottom = z_slices[i + 1]
+
+ # Cellules dans cette tranche
+ mask_in_slice = (centers[:, 2] <= z_top) & (centers[:, 2] >= z_bottom)
+ indices_in_slice = np.where(mask_in_slice)[0]
+
+ if len(indices_in_slice) == 0:
+ continue
+
+ # Distance XY à la référence
+ d_xy_in_slice = np.sqrt(
+ (centers[indices_in_slice, 0] - ref_x)**2 +
+ (centers[indices_in_slice, 1] - ref_y)**2
+ )
+
+ # Ne garder que celles dans la tolérance XY
+ valid_mask = d_xy_in_slice < xy_tolerance
+
+ if not np.any(valid_mask):
+ # Aucune dans la tolérance → prendre la plus proche
+ closest_in_slice = indices_in_slice[np.argmin(d_xy_in_slice)]
+ else:
+ # Prendre la plus proche parmi celles dans la tolérance
+ valid_indices = indices_in_slice[valid_mask]
+ d_xy_valid = d_xy_in_slice[valid_mask]
+ closest_in_slice = valid_indices[np.argmin(d_xy_valid)]
+
+ profile_indices.append(closest_in_slice)
+
+ # ===================================================================
+ # ÉTAPE 8: SUPPRIMER DOUBLONS ET TRIER
+ # ===================================================================
+
+ # Supprimer doublons
+ seen = set()
+ unique_indices = []
+ for idx in profile_indices:
+ if idx not in seen:
+ seen.add(idx)
+ unique_indices.append(idx)
+
+ if len(unique_indices) == 0:
+ if verbose:
+ print(f" ⚠️ No points extracted")
+ return np.array([]), np.array([]), np.array([]), np.array([])
+
+ profile_indices = np.array(unique_indices)
+
+ # Trier par profondeur décroissante (haut → bas)
+ sort_order = np.argsort(-centers[profile_indices, 2])
+ profile_indices = profile_indices[sort_order]
+
+ # Extraire résultats
+ depths = centers[profile_indices, 2]
+ profile_values = values[profile_indices]
+ path_x = centers[profile_indices, 0]
+ path_y = centers[profile_indices, 1]
+
+ # ===================================================================
+ # STATISTIQUES
+ # ===================================================================
+
+ if verbose:
+ depth_coverage = (depths.max() - depths.min()) / z_range * 100 if z_range > 0 else 0
+ xy_displacement = np.sqrt((path_x[-1] - path_x[0])**2 + (path_y[-1] - path_y[0])**2)
+
+ print(f" ✅ Extracted {len(profile_indices)} points")
+ print(f" Depth range: [{depths.max():.1f}, {depths.min():.1f}]m")
+ print(f" Coverage: {depth_coverage:.1f}% of fault depth")
+ print(f" XY displacement: {xy_displacement:.1f}m")
+
+ return (depths, profile_values, path_x, path_y)
+
+ # -------------------------------------------------------------------
+ @staticmethod
+ def extract_vertical_profile_topology_based(surface_mesh, field_name, x_start, y_start, z_start=None,
+ max_steps=500, verbose=True):
+ """
+ Extraction de profil vertical en utilisant la TOPOLOGIE du maillage de surface.
+ """
+
+ import pyvista as pv
+
+ if field_name not in surface_mesh.cell_data:
+ print(f" ⚠️ Field '{field_name}' not found in mesh")
+ return np.array([]), np.array([]), np.array([]), np.array([])
+
+ centers = surface_mesh.cell_centers().points
+ values = surface_mesh.cell_data[field_name]
+
+ # ===================================================================
+ # ÉTAPE 1: TROUVER LA CELLULE DE DÉPART
+ # ===================================================================
+
+ if z_start is None:
+ if verbose:
+ print(f" Searching near ({x_start:.1f}, {y_start:.1f})")
+
+ d_xy = np.sqrt((centers[:, 0] - x_start)**2 + (centers[:, 1] - y_start)**2)
+ closest_indices = np.argsort(d_xy)[:20]
+
+ if len(closest_indices) == 0:
+ print(f" ⚠️ No cells found")
+ return np.array([]), np.array([]), np.array([]), np.array([])
+
+ closest_depths = centers[closest_indices, 2]
+ start_idx = closest_indices[np.argmax(closest_depths)]
+ else:
+ if verbose:
+ print(f" Searching near ({x_start:.1f}, {y_start:.1f}, {z_start:.1f})")
+
+ d_3d = np.sqrt((centers[:, 0] - x_start)**2 +
+ (centers[:, 1] - y_start)**2 +
+ (centers[:, 2] - z_start)**2)
+ start_idx = np.argmin(d_3d)
+
+ start_point = centers[start_idx]
+
+ if verbose:
+ print(f" Starting cell: {start_idx}")
+ print(f" Starting point: ({start_point[0]:.1f}, {start_point[1]:.1f}, {start_point[2]:.1f})")
+
+ # ===================================================================
+ # ÉTAPE 2: IDENTIFIER LA FAILLE
+ # ===================================================================
+
+ target_fault_id = None
+ fault_ids = None
+ fault_field_names = ['attribute', 'FaultMask', 'fault_id', 'region']
+
+ for field_name_check in fault_field_names:
+ if field_name_check in surface_mesh.cell_data:
+ fault_ids = surface_mesh.cell_data[field_name_check]
+ target_fault_id = fault_ids[start_idx]
+
+ if verbose:
+ unique_ids = np.unique(fault_ids)
+ print(f" Fault field: '{field_name_check}'")
+ print(f" Target fault ID: {target_fault_id} (from {unique_ids})")
+
+ break
+
+ if target_fault_id is None and verbose:
+ print(f" ⚠️ No fault ID found - will use all cells")
+
+ # ===================================================================
+ # ÉTAPE 3: CONSTRUIRE LA CONNECTIVITÉ (VOISINS TOPOLOGIQUES)
+ # ===================================================================
+
+ if verbose:
+ print(f" Building cell connectivity...")
+
+ n_cells = surface_mesh.n_cells
+ connectivity = [[] for _ in range(n_cells)]
+
+ # Construire un dictionnaire arête -> cellules
+ edge_to_cells = {}
+
+ for cell_id in range(n_cells):
+ cell = surface_mesh.get_cell(cell_id)
+ n_points = cell.n_points
+
+ # Pour chaque arête de la cellule
+ for i in range(n_points):
+ p1 = cell.point_ids[i]
+ p2 = cell.point_ids[(i + 1) % n_points]
+
+ # Arête normalisée (ordre canonique)
+ edge = tuple(sorted([p1, p2]))
+
+ if edge not in edge_to_cells:
+ edge_to_cells[edge] = []
+ edge_to_cells[edge].append(cell_id)
+
+ # Pour chaque cellule, trouver ses voisins via arêtes partagées
+ for cell_id in range(n_cells):
+ cell = surface_mesh.get_cell(cell_id)
+ n_points = cell.n_points
+
+ neighbors_set = set()
+
+ for i in range(n_points):
+ p1 = cell.point_ids[i]
+ p2 = cell.point_ids[(i + 1) % n_points]
+ edge = tuple(sorted([p1, p2]))
+
+ # Toutes les cellules partageant cette arête sont voisines
+ for neighbor_id in edge_to_cells[edge]:
+ if neighbor_id != cell_id:
+ neighbors_set.add(neighbor_id)
+
+ connectivity[cell_id] = list(neighbors_set)
+
+ if verbose:
+ avg_neighbors = np.mean([len(c) for c in connectivity])
+ max_neighbors = np.max([len(c) for c in connectivity])
+ print(f" Connectivity built: avg={avg_neighbors:.1f} neighbors/cell, max={max_neighbors}")
+
+ # ===================================================================
+ # ÉTAPE 4: ALGORITHME DE DESCENTE PAR VOISINAGE TOPOLOGIQUE
+ # ===================================================================
+
+ profile_indices = [start_idx]
+ visited = {start_idx}
+ current_idx = start_idx
+
+ ref_xy = start_point[:2] # Position XY de référence
+
+ if verbose:
+ print(f" Starting descent from Z={start_point[2]:.1f}m...")
+
+ stuck_count = 0
+ max_stuck = 3
+
+ for step in range(max_steps):
+ current_z = centers[current_idx, 2]
+
+ # Obtenir les voisins topologiques
+ neighbor_indices = connectivity[current_idx]
+
+ # Filtrer les voisins:
+ # 1. Non visités
+ # 2. Même faille (si détectée)
+ # 3. Plus bas en Z
+ candidates = []
+
+ for idx in neighbor_indices:
+ if idx in visited:
+ continue
+
+ # Vérifier la faille
+ if target_fault_id is not None and fault_ids is not None:
+ if fault_ids[idx] != target_fault_id:
+ continue
+
+ # Vérifier qu'on descend
+ if centers[idx, 2] >= current_z:
+ continue
+
+ candidates.append(idx)
+
+ if len(candidates) == 0:
+ # Si bloqué, essayer de regarder les voisins des voisins
+ stuck_count += 1
+
+ if stuck_count >= max_stuck:
+ if verbose:
+ print(f" Reached bottom at Z={current_z:.1f}m after {step+1} steps (no more neighbors)")
+ break
+
+ # Essayer niveau 2 (voisins des voisins)
+ extended_candidates = []
+ for neighbor_idx in neighbor_indices:
+ if neighbor_idx in visited:
+ continue
+
+ for second_neighbor_idx in connectivity[neighbor_idx]:
+ if second_neighbor_idx in visited:
+ continue
+
+ if target_fault_id is not None and fault_ids is not None:
+ if fault_ids[second_neighbor_idx] != target_fault_id:
+ continue
+
+ if centers[second_neighbor_idx, 2] < current_z:
+ extended_candidates.append(second_neighbor_idx)
+
+ if len(extended_candidates) == 0:
+ if verbose:
+ print(f" Reached bottom at Z={current_z:.1f}m (extended search failed)")
+ break
+
+ candidates = extended_candidates
+ if verbose:
+ print(f" Used extended search at step {step+1}")
+ else:
+ stuck_count = 0
+
+ # Parmi les candidats, choisir celui le plus proche en XY de la référence
+ best_idx = None
+ best_distance_xy = float('inf')
+
+ for idx in candidates:
+ pos = centers[idx]
+ d_xy = np.sqrt((pos[0] - ref_xy[0])**2 + (pos[1] - ref_xy[1])**2)
+
+ if d_xy < best_distance_xy:
+ best_distance_xy = d_xy
+ best_idx = idx
+
+ if best_idx is None:
+ if verbose:
+ print(f" No valid neighbor at Z={current_z:.1f}m")
+ break
+
+ # Ajouter au profil
+ profile_indices.append(best_idx)
+ visited.add(best_idx)
+ current_idx = best_idx
+
+ # Debug
+ if verbose and (step + 1) % 100 == 0:
+ print(f" Step {step+1}: Z={centers[current_idx, 2]:.1f}m, XY=({centers[current_idx, 0]:.1f}, {centers[current_idx, 1]:.1f})")
+
+ # ===================================================================
+ # ÉTAPE 5: EXTRAIRE LES RÉSULTATS
+ # ===================================================================
+
+ if len(profile_indices) == 0:
+ if verbose:
+ print(f" ⚠️ No profile extracted")
+ return np.array([]), np.array([]), np.array([]), np.array([])
+
+ profile_indices = np.array(profile_indices)
+
+ depths = centers[profile_indices, 2]
+ profile_values = values[profile_indices]
+ path_x = centers[profile_indices, 0]
+ path_y = centers[profile_indices, 1]
+
+ # ===================================================================
+ # STATISTIQUES
+ # ===================================================================
+
+ if verbose:
+ z_range = np.max(centers[:, 2]) - np.min(centers[:, 2])
+ depth_coverage = (depths.max() - depths.min()) / z_range * 100 if z_range > 0 else 0
+ xy_displacement = np.sqrt((path_x[-1] - path_x[0])**2 + (path_y[-1] - path_y[0])**2)
+
+ print(f" ✅ {len(profile_indices)} points extracted")
+ print(f" Depth range: [{depths.max():.1f}, {depths.min():.1f}]m")
+ print(f" Coverage: {depth_coverage:.1f}% of fault depth")
+ print(f" XY displacement: {xy_displacement:.1f}m")
+
+ return (depths, profile_values, path_x, path_y)
+
+ # -------------------------------------------------------------------
+ @staticmethod
+ def plot_profile_path_3d(surface, path_x, path_y, path_z, profile_values=None,
+ scalar_name='SCU', save_path=None, show=True):
+ """
+ Visualize the extracted profile path on the fault surface in 3D using PyVista.
+
+ Parameters
+ ----------
+ surface : pyvista.PolyData
+ Fault surface mesh
+ path_x, path_y, path_z : array-like
+ Coordinates of the profile path
+ profile_values : array-like, optional
+ Values along the profile (for coloring the path)
+ scalar_name : str
+ Name of the scalar to display on the surface
+ save_path : Path or str, optional
+ Path to save the screenshot
+ show : bool
+ Whether to display the plot interactively
+ """
+ import pyvista as pv
+
+ if len(path_x) == 0:
+ print(" ⚠️ No path to plot (empty profile)")
+ return
+
+ print(f" 📊 Creating 3D visualization of profile path ({len(path_x)} points)")
+
+ # Create plotter
+ plotter = pv.Plotter(window_size=[1600, 1200])
+
+ # Add fault surface with scalar field
+ if scalar_name in surface.cell_data:
+ plotter.add_mesh(
+ surface,
+ scalars=scalar_name,
+ cmap='RdYlGn_r',
+ opacity=0.7,
+ show_edges=False,
+ lighting=True,
+ smooth_shading=True,
+ scalar_bar_args={
+ 'title': scalar_name,
+ 'title_font_size': 20,
+ 'label_font_size': 16,
+ 'n_labels': 5,
+ 'italic': False,
+ 'fmt': '%.2f',
+ 'font_family': 'arial',
+ }
+ )
+ else:
+ plotter.add_mesh(
+ surface,
+ color='lightgray',
+ opacity=0.5,
+ show_edges=True
+ )
+
+ # Create path as a polyline
+ path_points = np.column_stack([path_x, path_y, path_z])
+ path_polyline = pv.PolyData(path_points)
+
+ # Add connectivity for line
+ n_points = len(path_points)
+ lines = np.full((n_points - 1, 3), 2, dtype=np.int_)
+ lines[:, 1] = np.arange(n_points - 1)
+ lines[:, 2] = np.arange(1, n_points)
+ path_polyline.lines = lines.ravel()
+
+ # Color the path by profile values or depth
+ if profile_values is not None:
+ path_polyline['profile_value'] = profile_values
+ color_field = 'profile_value'
+ cmap_path = 'viridis'
+ else:
+ path_polyline['depth'] = path_z
+ color_field = 'depth'
+ cmap_path = 'turbo_r'
+
+ # Add path as thick tube
+ path_tube = path_polyline.tube(radius=10.0) # Adjust radius as needed
+ plotter.add_mesh(
+ path_tube,
+ scalars=color_field,
+ cmap=cmap_path,
+ line_width=8,
+ render_lines_as_tubes=True,
+ lighting=True,
+ scalar_bar_args={
+ 'title': 'Path ' + color_field,
+ 'title_font_size': 20,
+ 'label_font_size': 16,
+ 'position_x': 0.85,
+ 'position_y': 0.05,
+ }
+ )
+
+ # Add start and end markers
+ start_point = pv.Sphere(radius=30, center=path_points[0])
+ end_point = pv.Sphere(radius=30, center=path_points[-1])
+
+ plotter.add_mesh(start_point, color='lime', label='Start (Top)')
+ plotter.add_mesh(end_point, color='red', label='End (Bottom)')
+
+ # Add axes and labels
+ plotter.add_axes(
+ xlabel='X [m]',
+ ylabel='Y [m]',
+ zlabel='Z [m]',
+ line_width=3,
+ labels_off=False
+ )
+
+ # Add legend
+ plotter.add_legend(
+ labels=[('Start (Top)', 'lime'), ('End (Bottom)', 'red')],
+ bcolor='white',
+ border=True,
+ size=(0.15, 0.1),
+ loc='upper left'
+ )
+
+ # Set camera and lighting
+ plotter.camera_position = 'iso'
+ plotter.add_light(pv.Light(position=(1, 1, 1), intensity=0.8))
+
+ # Add title
+ path_length = np.sum(np.sqrt(np.sum(np.diff(path_points, axis=0)**2, axis=1)))
+ depth_range = path_z.max() - path_z.min()
+ title = f'Profile Path Extraction\n'
+ title += f'Points: {len(path_x)} | Length: {path_length:.1f}m | Depth range: {depth_range:.1f}m'
+ plotter.add_text(title, position='upper_edge', font_size=14, color='black')
+
+ # Save screenshot
+ # if save_path is not None:
+ # screenshot_path = save_path / 'profile_path_3d.png'
+ # plotter.screenshot(str(screenshot_path))
+ # print(f" 💾 Screenshot saved: {screenshot_path}")
+
+ # Show
+ if show:
+ plotter.show()
+ else:
+ plotter.close()
+
+
+# ============================================================================
+# VISUALIZATION
+# ============================================================================
+class Visualizer:
+ """Visualization utilities"""
+
+ # -------------------------------------------------------------------
+ def __init__(self, config):
+ self.config = config
+
+ # -------------------------------------------------------------------
+ @staticmethod
+ def plot_mohr_coulomb_diagram(surface, time, path, show=True, save=True):
+ """Create Mohr-Coulomb diagram with depth coloring"""
+
+ sigma_n = -surface.cell_data["sigma_n_eff"]
+ tau = np.abs(surface.cell_data["tau_eff"])
+ SCU = np.abs(surface.cell_data["SCU"])
+ depth = surface.cell_data['elementCenter'][:, 2]
+
+ cohesion = surface.cell_data["mohr_cohesion"][0]
+ mu = surface.cell_data["mohr_friction_coefficient"][0]
+ phi = surface.cell_data['mohr_friction_angle'][0]
+
+ fig, axes = plt.subplots(1, 2, figsize=(16, 8))
+
+ # Plot 1: τ vs σ_n
+ ax1 = axes[0]
+ sc1 = ax1.scatter(sigma_n, tau, c=depth, cmap='turbo_r', s=20, alpha=0.8)
+ sigma_range = np.linspace(0, np.max(sigma_n), 100)
+ tau_crit = cohesion + mu * sigma_range
+ ax1.plot(sigma_range, tau_crit, 'k--', linewidth=2,
+ label=f'M-C (C={cohesion} bar, φ={phi}°)')
+ ax1.set_xlabel('Normal Stress [bar]')
+ ax1.set_ylabel('Shear Stress [bar]')
+ ax1.legend()
+ ax1.grid(True, alpha=0.3)
+ ax1.set_title('Mohr-Coulomb Diagram')
+
+ # Plot 2: SCU vs σ_n
+ ax2 = axes[1]
+ sc2 = ax2.scatter(sigma_n, SCU, c=depth, cmap='turbo_r', s=20, alpha=0.8)
+ ax2.axhline(y=1.0, color='r', linestyle='--', label='Failure (SCU=1)')
+ ax2.set_xlabel('Normal Stress [bar]')
+ ax2.set_ylabel('SCU [-]')
+ ax2.legend()
+ ax2.grid(True, alpha=0.3)
+ ax2.set_title('Shear Capacity Utilization')
+ ax2.set_ylim(bottom=0)
+
+ plt.colorbar(sc2, ax=ax2, label='Depth [m]')
+ plt.tight_layout()
+
+ if save:
+ years = time / (365.25 * 24 * 3600)
+ filename = f'mohr_coulomb_phi{phi}_c{cohesion}_{years:.0f}y.png'
+ plt.savefig(path / filename, dpi=300, bbox_inches='tight')
+ print(f" 📊 Plot saved: {filename}")
+
+ if show:
+ plt.show()
+ else:
+ plt.close()
+
+ # -------------------------------------------------------------------
+ @staticmethod
+ def load_reference_data(time, script_dir=None, profile_id=1):
+ """
+ Load GEOS and analytical reference data for comparison
+
+ Parameters
+ ----------
+ time : float
+ Current simulation time in seconds
+ script_dir : str or Path, optional
+ Directory containing reference data files. If None, uses current directory.
+ profile_id : int, optional
+ Profile ID to extract from Excel (default: 1)
+
+ Returns
+ -------
+ dict
+ Dictionary with keys 'geos' and 'analytical', each containing numpy arrays or None
+ Format: {'geos': array or None, 'analytical': array or None}
+
+ For GEOS data from Excel, the array has columns:
+ [Depth_m, Normal_Stress_bar, Shear_Stress_bar, SCU, X_coordinate_m, Y_coordinate_m]
+ """
+ import pandas as pd
+
+ if script_dir is None:
+ script_dir = os.path.dirname(os.path.abspath(__file__))
+
+ result = {'geos': None, 'analytical': None}
+
+ # ===================================================================
+ # LOAD GEOS DATA - Try Excel first, then CSV
+ # ===================================================================
+
+ geos_file_xlsx = 'geos_data_numerical.xlsx'
+ geos_file_csv = 'geos_data_numerical.csv'
+
+ # Try Excel format with time-based sheets
+ geos_xlsx_path = os.path.join(script_dir, geos_file_xlsx)
+
+ if os.path.exists(geos_xlsx_path):
+ try:
+ # Generate sheet name based on current time
+ # Format: t_1.00e+02s
+ sheet_name = f"t_{time:.2e}s"
+
+ print(f" 📂 Loading GEOS data from Excel sheet: '{sheet_name}'")
+
+ # Try to read the specific sheet
+ try:
+ df = pd.read_excel(geos_xlsx_path, sheet_name=sheet_name)
+
+ # Filter by Profile_ID if column exists
+ if 'Profile_ID' in df.columns:
+ df_profile = df[df['Profile_ID'] == profile_id]
+
+ if len(df_profile) == 0:
+ print(f" ⚠️ Profile_ID {profile_id} not found in sheet '{sheet_name}'")
+ print(f" Available Profile_IDs: {sorted(df['Profile_ID'].unique())}")
+ # Take first profile as fallback
+ available_ids = sorted(df['Profile_ID'].unique())
+ if len(available_ids) > 0:
+ fallback_id = available_ids[0]
+ print(f" → Using Profile_ID {fallback_id} instead")
+ df_profile = df[df['Profile_ID'] == fallback_id]
+ else:
+ print(f" ✅ Loaded Profile_ID {profile_id}: {len(df_profile)} points")
+
+ # Extract relevant columns in the expected order
+ # Expected: [Depth, Normal_Stress, Shear_Stress, SCU, ...]
+ columns_to_extract = ['Depth_m', 'Normal_Stress_bar', 'Shear_Stress_bar', 'SCU']
+
+ # Check which columns exist
+ available_columns = [col for col in columns_to_extract if col in df_profile.columns]
+
+ if len(available_columns) > 0:
+ result['geos'] = df_profile[available_columns].values
+ print(f" Extracted columns: {available_columns}")
+ else:
+ print(f" ⚠️ No expected columns found in DataFrame")
+ print(f" Available columns: {list(df_profile.columns)}")
+ else:
+ # No Profile_ID column, use all data
+ print(f" ℹ️ No Profile_ID column, using all data")
+ columns_to_extract = ['Depth_m', 'Normal_Stress_bar', 'Shear_Stress_bar', 'SCU']
+ available_columns = [col for col in columns_to_extract if col in df.columns]
+
+ if len(available_columns) > 0:
+ result['geos'] = df[available_columns].values
+ print(f" ✅ Loaded {len(result['geos'])} points")
+
+ except ValueError:
+ # Sheet not found, try to find closest time
+ print(f" ⚠️ Sheet '{sheet_name}' not found, searching for closest time...")
+
+ # Read all sheet names
+ xl_file = pd.ExcelFile(geos_xlsx_path)
+ sheet_names = xl_file.sheet_names
+
+ # Extract times from sheet names
+ sheet_times = []
+ for sname in sheet_names:
+ if sname.startswith('t_') and sname.endswith('s'):
+ try:
+ # Extract time: t_1.00e+02s -> 100.0
+ time_str = sname[2:-1] # Remove 't_' and 's'
+ sheet_time = float(time_str)
+ sheet_times.append((sheet_time, sname))
+ except:
+ continue
+
+ if sheet_times:
+ # Find closest time
+ sheet_times.sort(key=lambda x: abs(x[0] - time))
+ closest_time, closest_sheet = sheet_times[0]
+ time_diff = abs(closest_time - time)
+
+ print(f" → Using closest sheet: '{closest_sheet}' (Δt={time_diff:.2e}s)")
+ df = pd.read_excel(geos_xlsx_path, sheet_name=closest_sheet)
+
+ # Filter by Profile_ID
+ if 'Profile_ID' in df.columns:
+ df_profile = df[df['Profile_ID'] == profile_id]
+
+ if len(df_profile) == 0:
+ # Fallback to first profile
+ available_ids = sorted(df['Profile_ID'].unique())
+ if len(available_ids) > 0:
+ df_profile = df[df['Profile_ID'] == available_ids[0]]
+ print(f" → Using Profile_ID {available_ids[0]}")
+
+ columns_to_extract = ['Depth_m', 'Normal_Stress_bar', 'Shear_Stress_bar', 'SCU']
+ available_columns = [col for col in columns_to_extract if col in df_profile.columns]
+
+ if len(available_columns) > 0:
+ result['geos'] = df_profile[available_columns].values
+ print(f" ✅ Loaded {len(result['geos'])} points")
+ else:
+ # Use all data
+ columns_to_extract = ['Depth_m', 'Normal_Stress_bar', 'Shear_Stress_bar', 'SCU']
+ available_columns = [col for col in columns_to_extract if col in df.columns]
+
+ if len(available_columns) > 0:
+ result['geos'] = df[available_columns].values
+ print(f" ✅ Loaded {len(result['geos'])} points")
+ else:
+ print(f" ⚠️ No valid time sheets found in Excel file")
+
+ except ImportError:
+ print(f" ⚠️ pandas not available, cannot read Excel file")
+ except Exception as e:
+ print(f" ⚠️ Error reading Excel: {e}")
+ import traceback
+ traceback.print_exc()
+
+ # Fallback to CSV if Excel not found or failed
+ if result['geos'] is None:
+ geos_csv_path = os.path.join(script_dir, geos_file_csv)
+ if os.path.exists(geos_csv_path):
+ try:
+ result['geos'] = np.loadtxt(geos_csv_path, delimiter=',', skiprows=1)
+ print(f" ✅ GEOS data loaded from CSV: {len(result['geos'])} points")
+ except Exception as e:
+ print(f" ⚠️ Error reading CSV: {e}")
+
+ # ===================================================================
+ # LOAD ANALYTICAL DATA
+ # ===================================================================
+
+ analytical_file = 'analytical_data.csv'
+ analytical_path = os.path.join(script_dir, analytical_file)
+
+ if os.path.exists(analytical_path):
+ try:
+ result['analytical'] = np.loadtxt(analytical_path, delimiter=',', skiprows=1)
+ print(f" ✅ Analytical data loaded: {len(result['analytical'])} points")
+ except Exception as e:
+ print(f" ⚠️ Error loading analytical data: {e}")
+
+ return result
+
+ # -------------------------------------------------------------------
+ @staticmethod
+ def plot_depth_profiles(self, surface, time, path, show=True, save=True,
+ profile_start_points=None,
+ max_profile_points=1000,
+ reference_profile_id=1
+ ):
+
+ """
+ Plot vertical profiles along the fault showing stress and SCU vs depth
+ """
+
+ print(" 📊 Creating depth profiles ")
+
+ # Extract data
+ centers = surface.cell_data['elementCenter']
+ depth = centers[:, 2]
+ sigma_n = surface.cell_data['sigma_n_eff']
+ tau = surface.cell_data['tau_eff']
+ SCU = surface.cell_data['SCU']
+ SCU = np.sqrt(SCU**2)
+ delta_SCU = surface.cell_data['delta_SCU']
+
+ # Extraire les IDs de faille
+ fault_ids = None
+ if 'FaultMask' in surface.cell_data:
+ fault_ids = surface.cell_data['FaultMask']
+ print(f" 📋 Detected {len(np.unique(fault_ids[fault_ids > 0]))} distinct faults")
+ elif 'attribute' in surface.cell_data:
+ fault_ids = surface.cell_data['attribute']
+ print(f" 📋 Using 'attribute' field for fault identification")
+ else:
+ print(f" ⚠️ No fault IDs found - profiles may jump between faults")
+
+ # ===================================================================
+ # LOAD REFERENCE DATA (GEOS + Analytical)
+ # ===================================================================
+ script_dir = os.path.dirname(os.path.abspath(__file__))
+ reference_data = Visualizer.load_reference_data(
+ time,
+ script_dir,
+ profile_id=reference_profile_id
+ )
+
+ geos_data = reference_data['geos']
+ analytical_data = reference_data['analytical']
+
+ # ===================================================================
+ # PROFILE EXTRACTION SETUP
+ # ===================================================================
+
+ # Get fault bounds
+ x_min, x_max = np.min(centers[:, 0]), np.max(centers[:, 0])
+ y_min, y_max = np.min(centers[:, 1]), np.max(centers[:, 1])
+ z_min, z_max = np.min(depth), np.max(depth)
+
+ # Auto-compute search radius if not provided
+ x_range = x_max - x_min
+ y_range = y_max - y_min
+ z_range = z_max - z_min
+
+ if self.config.PROFILE_SEARCH_RADIUS is not None:
+ search_radius = self.config.PROFILE_SEARCH_RADIUS
+ else:
+ search_radius = min(x_range, y_range) * 0.15
+
+
+ # Auto-generate profile points if not provided
+ if profile_start_points is None:
+ print(" ⚠️ No profile_start_points provided, auto-generating 5 profiles...")
+ n_profiles = 5
+
+ # Determine dominant fault direction
+ if x_range > y_range:
+ coord_name = 'X'
+ fixed_value = (y_min + y_max) / 2
+ sample_positions = np.linspace(x_min, x_max, n_profiles)
+ profile_start_points = [(x, fixed_value) for x in sample_positions]
+ else:
+ coord_name = 'Y'
+ fixed_value = (x_min + x_max) / 2
+ sample_positions = np.linspace(y_min, y_max, n_profiles)
+ profile_start_points = [(fixed_value, y) for y in sample_positions]
+
+ print(f" Auto-generated {n_profiles} profiles along {coord_name} direction")
+
+ n_profiles = len(profile_start_points)
+
+ # ===================================================================
+ # CREATE FIGURE
+ # ===================================================================
+
+ fig, axes = plt.subplots(1, 4, figsize=(24, 12))
+ colors = plt.cm.RdYlGn(np.linspace(0, 1, n_profiles))
+
+ print(f" 📍 Processing {n_profiles} profiles:")
+ print(f" Depth range: [{z_min:.1f}, {z_max:.1f}]m")
+
+ successful_profiles = 0
+
+ # ===================================================================
+ # EXTRACT AND PLOT PROFILES
+ # ===================================================================
+
+ for i, (x_pos, y_pos, z_pos) in enumerate(profile_start_points):
+ print(f" → Profile {i+1}: starting at ({x_pos:.1f}, {y_pos:.1f}, {z_pos:.1f})")
+
+ # depths_sigma, profile_sigma_n, path_x_s, path_y_s = ProfileExtractor.extract_vertical_profile_topology_based(
+ # surface, 'sigma_n_eff', x_pos, y_pos, z_pos, verbose=True)
+
+ # depths_tau, profile_tau, _, _ = ProfileExtractor.extract_vertical_profile_topology_based(
+ # surface, 'tau_eff', x_pos, y_pos, z_pos, verbose=False)
+
+ # depths_SCU, profile_SCU, _, _ = ProfileExtractor.extract_vertical_profile_topology_based(
+ # surface, 'SCU', x_pos, y_pos, z_pos, verbose=False)
+
+ # depths_deltaSCU, profile_deltaSCU, _, _ = ProfileExtractor.extract_vertical_profile_topology_based(
+ # surface, 'delta_SCU', x_pos, y_pos, z_pos, verbose=False)
+
+ depths_sigma, profile_sigma_n, path_x_s, path_y_s = ProfileExtractor.extract_adaptive_profile(
+ centers, sigma_n, x_pos, y_pos, search_radius)
+
+ depths_tau, profile_tau, _, _ = ProfileExtractor.extract_adaptive_profile(
+ centers, tau, x_pos, y_pos, search_radius)
+
+ depths_SCU, profile_SCU, _, _ = ProfileExtractor.extract_adaptive_profile(
+ centers, SCU, x_pos, y_pos, search_radius)
+
+ depths_deltaSCU, profile_deltaSCU, _, _ = ProfileExtractor.extract_adaptive_profile(
+ centers, SCU, x_pos, y_pos, search_radius)
+
+ # Calculate path length
+ if len(path_x_s) > 1:
+ path_length = np.sum(np.sqrt(
+ np.diff(path_x_s)**2 +
+ np.diff(path_y_s)**2 +
+ np.diff(depths_sigma)**2
+ ))
+ print(f" Path length: {path_length:.1f}m (horizontal displacement: {np.abs(path_x_s[-1] - path_x_s[0]):.1f}m)")
+
+ if self.config.SHOW_PROFILE_EXTRACTOR:
+ ProfileExtractor.plot_profile_path_3d(
+ surface=surface,
+ path_x=path_x_s,
+ path_y=path_y_s,
+ path_z=depths_sigma,
+ profile_values=profile_sigma_n,
+ scalar_name='SCU',
+ save_path=path,
+ show=show
+ )
+
+ # Check if we have enough points
+ min_points = 3
+ n_points = len(depths_sigma)
+
+ if n_points >= min_points:
+ label = f'Profile {i+1} → ({x_pos:.0f}, {y_pos:.0f})'
+
+ # Plot 1: Normal stress vs depth
+ axes[0].plot(profile_sigma_n, depths_sigma,
+ color=colors[i], label=label, linewidth=2.5, alpha=0.8,
+ marker='o', markersize=3, markevery=2)
+
+ # Plot 2: Shear stress vs depth
+ axes[1].plot(profile_tau, depths_tau,
+ color=colors[i], label=label, linewidth=2.5, alpha=0.8,
+ marker='o', markersize=3, markevery=2)
+
+ # Plot 3: SCU vs depth
+ axes[2].plot(profile_SCU, depths_SCU,
+ color=colors[i], label=label, linewidth=2.5, alpha=0.8,
+ marker='o', markersize=3, markevery=2)
+
+ # Plot 4: Detla SCU vs depth
+ axes[3].plot(profile_deltaSCU, depths_deltaSCU,
+ color=colors[i], label=label, linewidth=2.5, alpha=0.8,
+ marker='o', markersize=3, markevery=2)
+
+ successful_profiles += 1
+ print(f" ✅ {n_points} points found")
+ else:
+ print(f" ⚠️ Insufficient points ({n_points}), skipping")
+
+ if successful_profiles == 0:
+ print(" ❌ No valid profiles found!")
+ plt.close()
+ return
+
+ # ===================================================================
+ # ADD REFERENCE DATA (GEOS + Analytical) - Only once
+ # ===================================================================
+
+ if geos_data is not None:
+ # Colonnes: [Depth_m, Normal_Stress_bar, Shear_Stress_bar, SCU]
+ # Index: [0, 1, 2, 3]
+
+ axes[0].plot(geos_data[:, 1] *10, geos_data[:, 0], 'o',
+ color='blue', markersize=6, label='GEOS Contact Solver',
+ alpha=0.7, mec='k', mew=1, fillstyle='none')
+
+ axes[1].plot(geos_data[:, 2] *10, geos_data[:, 0], 'o',
+ color='blue', markersize=6, label='GEOS Contact Solver',
+ alpha=0.7, mec='k', mew=1, fillstyle='none')
+
+ if geos_data.shape[1] > 3: # SCU column exists
+ axes[2].plot(geos_data[:, 3], geos_data[:, 0], 'o',
+ color='blue', markersize=6, label='GEOS Contact Solver',
+ alpha=0.7, mec='k', mew=1, fillstyle='none')
+
+ if analytical_data is not None:
+ # Format analytique (peut varier)
+ axes[0].plot(analytical_data[:, 1] * 10, analytical_data[:, 0], '--',
+ color='darkorange', linewidth=2, label='Analytical', alpha=0.8)
+ if analytical_data.shape[1] > 2:
+ axes[1].plot(analytical_data[:, 2] * 10, analytical_data[:, 0], '--',
+ color='darkorange', linewidth=2, label='Analytical', alpha=0.8)
+
+ # ===================================================================
+ # CONFIGURE PLOTS
+ # ===================================================================
+
+ fsize = 14
+
+ # Plot 1: Normal Stress
+ axes[0].set_xlabel('Normal Stress σₙ [bar]', fontsize=fsize, weight="bold")
+ axes[0].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
+ axes[0].set_title('Normal Stress Profile', fontsize=fsize+2, weight="bold")
+ axes[0].grid(True, alpha=0.3, linestyle='--')
+ axes[0].legend(loc='upper left', fontsize=fsize-2)
+ axes[0].tick_params(labelsize=fsize-2)
+
+ # Plot 2: Shear Stress
+ axes[1].set_xlabel('Shear Stress τ [bar]', fontsize=fsize, weight="bold")
+ axes[1].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
+ axes[1].set_title('Shear Stress Profile', fontsize=fsize+2, weight="bold")
+ axes[1].grid(True, alpha=0.3, linestyle='--')
+ axes[1].legend(loc='upper left', fontsize=fsize-2)
+ axes[1].tick_params(labelsize=fsize-2)
+
+ # Plot 3: SCU
+ axes[2].set_xlabel('SCU [-]', fontsize=fsize, weight="bold")
+ axes[2].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
+ axes[2].set_title('Shear Capacity Utilization', fontsize=fsize+2, weight="bold")
+ axes[2].axvline(x=0.8, color='forestgreen', linestyle='--', linewidth=2, label='Critical (0.8)')
+ axes[2].axvline(x=1.0, color='red', linestyle='--', linewidth=2, label='Failure (1.0)')
+ axes[2].grid(True, alpha=0.3, linestyle='--')
+ axes[2].legend(loc='upper right', fontsize=fsize-2)
+ axes[2].tick_params(labelsize=fsize-2)
+ axes[2].set_xlim(left=0)
+
+ # Plot 4: Delta SCU
+ axes[3].set_xlabel('Δ SCU [-]', fontsize=fsize, weight="bold")
+ axes[3].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
+ axes[3].set_title('Delta SCU', fontsize=fsize+2, weight="bold")
+ axes[3].grid(True, alpha=0.3, linestyle='--')
+ axes[3].legend(loc='upper right', fontsize=fsize-2)
+ axes[3].tick_params(labelsize=fsize-2)
+ axes[3].set_xlim(left=0, right=2)
+
+ # Change verticale scale
+ if self.config.MAX_DEPTH_PROFILES != None :
+ for i in range(len(axes)):
+ axes[i].set_ylim(bottom=self.config.MAX_DEPTH_PROFILES)
+
+ if self.config.MIN_DEPTH_PROFILES != None :
+ for i in range(len(axes)):
+ axes[i].set_ylim(top=self.config.MIN_DEPTH_PROFILES)
+
+ # Overall title
+ years = time / (365.25 * 24 * 3600)
+ fig.suptitle(f'Fault Depth Profiles - t={years:.1f} years',
+ fontsize=fsize+2, fontweight='bold', y=0.98)
+
+ plt.tight_layout(rect=[0, 0, 1, 0.96])
+
+ # Save
+ if save:
+ filename = f'depth_profiles_{years:.0f}y.png'
+ plt.savefig(path / filename, dpi=300, bbox_inches='tight')
+ print(f" 💾 Depth profiles saved: {filename}")
+
+ # Show
+ if show:
+ plt.show()
+ else:
+ plt.close()
+
+ # -------------------------------------------------------------------
+ def plot_volume_stress_profiles(self, volume_mesh, fault_surface, time, path,
+ show=True, save=True,
+ profile_start_points=None,
+ max_profile_points=1000):
+ """
+ Plot stress profiles in volume cells adjacent to the fault
+ Extracts profiles through contributing cells on BOTH sides of the fault
+ Shows plus side and minus side on the same plots for comparison
+
+ NOTE: Cette fonction utilise extract_adaptive_profile pour les VOLUMES
+ car volume_mesh n'est PAS un maillage surfacique.
+ La méthode topologique (extract_vertical_profile_topology_based)
+ est réservée aux maillages SURFACIQUES (fault_surface).
+ """
+
+ print(" 📊 Creating volume stress profiles (both sides)")
+
+ # ===================================================================
+ # CHECK IF REQUIRED DATA EXISTS
+ # ===================================================================
+
+ required_fields = ['sigma1', 'sigma2', 'sigma3', 'side', 'elementCenter']
+
+ for field in required_fields:
+ if field not in volume_mesh.cell_data:
+ print(f" ⚠️ Missing required field: {field}")
+ return
+
+ # Check for pressure
+ if 'pressure_bar' in volume_mesh.cell_data:
+ pressure_field = 'pressure_bar'
+ pressure = volume_mesh.cell_data[pressure_field]
+ elif 'pressure' in volume_mesh.cell_data:
+ pressure_field = 'pressure'
+ pressure = volume_mesh.cell_data[pressure_field] / 1e5
+ print(" ℹ️ Converting pressure from Pa to bar")
+ else:
+ print(" ⚠️ No pressure field found")
+ pressure = None
+
+ # Extract volume data
+ centers = volume_mesh.cell_data['elementCenter']
+ sigma1 = volume_mesh.cell_data['sigma1']
+ sigma2 = volume_mesh.cell_data['sigma2']
+ sigma3 = volume_mesh.cell_data['sigma3']
+ side_data = volume_mesh.cell_data['side']
+
+ # ===================================================================
+ # FILTER CELLS BY SIDE (BOTH PLUS AND MINUS)
+ # ===================================================================
+
+ # Plus side (side = 1 or 3)
+ mask_plus = (side_data == 1) | (side_data == 3)
+ centers_plus = centers[mask_plus]
+ sigma1_plus = sigma1[mask_plus]
+ sigma2_plus = sigma2[mask_plus]
+ sigma3_plus = sigma3[mask_plus]
+ if pressure is not None:
+ pressure_plus = pressure[mask_plus]
+
+ # Créer subset de cell_data pour le côté plus
+ cell_data_plus = {}
+ for key in volume_mesh.cell_data.keys():
+ cell_data_plus[key] = volume_mesh.cell_data[key][mask_plus]
+
+ # Minus side (side = 2 or 3)
+ mask_minus = (side_data == 2) | (side_data == 3)
+ centers_minus = centers[mask_minus]
+ sigma1_minus = sigma1[mask_minus]
+ sigma2_minus = sigma2[mask_minus]
+ sigma3_minus = sigma3[mask_minus]
+ if pressure is not None:
+ pressure_minus = pressure[mask_minus]
+
+ # Créer subset de cell_data pour le côté minus
+ cell_data_minus = {}
+ for key in volume_mesh.cell_data.keys():
+ cell_data_minus[key] = volume_mesh.cell_data[key][mask_minus]
+
+ print(f" 📍 Plus side: {len(centers_plus):,} cells")
+ print(f" 📍 Minus side: {len(centers_minus):,} cells")
+
+ if len(centers_plus) == 0 and len(centers_minus) == 0:
+ print(" ⚠️ No contributing cells found!")
+ return
+
+ # ===================================================================
+ # GET FAULT BOUNDS
+ # ===================================================================
+
+ fault_centers = fault_surface.cell_data['elementCenter']
+
+ x_min, x_max = np.min(fault_centers[:, 0]), np.max(fault_centers[:, 0])
+ y_min, y_max = np.min(fault_centers[:, 1]), np.max(fault_centers[:, 1])
+ z_min, z_max = np.min(fault_centers[:, 2]), np.max(fault_centers[:, 2])
+
+ x_range = x_max - x_min
+ y_range = y_max - y_min
+ z_range = z_max - z_min
+
+ # Search radius (pour extract_adaptive_profile sur volumes)
+ if self.config.PROFILE_SEARCH_RADIUS is not None:
+ search_radius = self.config.PROFILE_SEARCH_RADIUS
+ else:
+ search_radius = min(x_range, y_range) * 0.2
+
+ # ===================================================================
+ # AUTO-GENERATE PROFILE POINTS IF NOT PROVIDED
+ # ===================================================================
+
+ if profile_start_points is None:
+ print(" ⚠️ No profile_start_points provided, auto-generating...")
+ n_profiles = 3
+
+ if x_range > y_range:
+ coord_name = 'X'
+ fixed_value = (y_min + y_max) / 2
+ sample_positions = np.linspace(x_min, x_max, n_profiles)
+ profile_start_points = [(x, fixed_value, z_max) for x in sample_positions]
+ else:
+ coord_name = 'Y'
+ fixed_value = (x_min + x_max) / 2
+ sample_positions = np.linspace(y_min, y_max, n_profiles)
+ profile_start_points = [(fixed_value, y, z_max) for y in sample_positions]
+
+ print(f" Auto-generated {n_profiles} profiles along {coord_name}")
+
+ n_profiles = len(profile_start_points)
+
+ # ===================================================================
+ # CREATE FIGURE WITH 5 SUBPLOTS
+ # ===================================================================
+
+ fig, axes = plt.subplots(1, 5, figsize=(22, 10))
+
+ # Colors: different for plus and minus sides
+ colors_plus = plt.cm.Reds(np.linspace(0.4, 0.9, n_profiles))
+ colors_minus = plt.cm.Blues(np.linspace(0.4, 0.9, n_profiles))
+
+ print(f" 📍 Processing {n_profiles} volume profiles:")
+ print(f" Depth range: [{z_min:.1f}, {z_max:.1f}]m")
+
+ successful_profiles = 0
+
+ # ===================================================================
+ # EXTRACT AND PLOT PROFILES FOR BOTH SIDES
+ # ===================================================================
+
+ for i, (x_pos, y_pos, z_pos) in enumerate(profile_start_points):
+ print(f"\n → Profile {i+1}: starting at ({x_pos:.1f}, {y_pos:.1f}, {z_pos:.1f})")
+
+ # ================================================================
+ # PLUS SIDE
+ # ================================================================
+ if len(centers_plus) > 0:
+ print(f" Processing PLUS side...")
+
+ # Pour VOLUMES, utiliser extract_adaptive_profile avec cell_data
+ depths_s1_p, profile_s1_p, _, _ = ProfileExtractor.extract_adaptive_profile(
+ centers_plus, sigma1_plus, x_pos, y_pos, z_pos,
+ search_radius, verbose=True, cell_data=cell_data_plus)
+
+ depths_s2_p, profile_s2_p, _, _ = ProfileExtractor.extract_adaptive_profile(
+ centers_plus, sigma2_plus, x_pos, y_pos, z_pos,
+ search_radius, verbose=False, cell_data=cell_data_plus)
+
+ depths_s3_p, profile_s3_p, _, _ = ProfileExtractor.extract_adaptive_profile(
+ centers_plus, sigma3_plus, x_pos, y_pos, z_pos,
+ search_radius, verbose=False, cell_data=cell_data_plus)
+
+ if pressure is not None:
+ depths_p_p, profile_p_p, _, _ = ProfileExtractor.extract_adaptive_profile(
+ centers_plus, pressure_plus, x_pos, y_pos, z_pos,
+ search_radius, verbose=False, cell_data=cell_data_plus)
+
+ if len(depths_s1_p) >= 3:
+ label_plus = f'Plus side'
+
+ # Plot Pressure
+ if pressure is not None:
+ axes[0].plot(profile_p_p, depths_p_p,
+ color=colors_plus[i], label=label_plus if i == 0 else '',
+ linewidth=2.5, alpha=0.8, marker='o', markersize=3, markevery=2)
+
+ # Plot σ1
+ axes[1].plot(profile_s1_p, depths_s1_p,
+ color=colors_plus[i], label=label_plus if i == 0 else '',
+ linewidth=2.5, alpha=0.8, marker='o', markersize=3, markevery=2)
+
+ # Plot σ2
+ axes[2].plot(profile_s2_p, depths_s2_p,
+ color=colors_plus[i], label=label_plus if i == 0 else '',
+ linewidth=2.5, alpha=0.8, marker='o', markersize=3, markevery=2)
+
+ # Plot σ3
+ axes[3].plot(profile_s3_p, depths_s3_p,
+ color=colors_plus[i], label=label_plus if i == 0 else '',
+ linewidth=2.5, alpha=0.8, marker='o', markersize=3, markevery=2)
+
+ # Plot All stresses
+ axes[4].plot(profile_s1_p, depths_s1_p,
+ color=colors_plus[i], linewidth=2.5, alpha=0.8,
+ linestyle='-', marker="o", markersize=2, markevery=2)
+ axes[4].plot(profile_s2_p, depths_s2_p,
+ color=colors_plus[i], linewidth=2.0, alpha=0.6,
+ linestyle='-', marker="s", markersize=2, markevery=2)
+ axes[4].plot(profile_s3_p, depths_s3_p,
+ color=colors_plus[i], linewidth=2.5, alpha=0.8,
+ linestyle='-', marker="v", markersize=2, markevery=2)
+
+ print(f" ✅ PLUS: {len(depths_s1_p)} points")
+ successful_profiles += 1
+
+ # ================================================================
+ # MINUS SIDE
+ # ================================================================
+ if len(centers_minus) > 0:
+ print(f" Processing MINUS side...")
+
+ # Pour VOLUMES, utiliser extract_adaptive_profile avec cell_data
+ depths_s1_m, profile_s1_m, _, _ = ProfileExtractor.extract_adaptive_profile(
+ centers_minus, sigma1_minus, x_pos, y_pos, z_pos,
+ search_radius, verbose=True, cell_data=cell_data_minus)
+
+ depths_s2_m, profile_s2_m, _, _ = ProfileExtractor.extract_adaptive_profile(
+ centers_minus, sigma2_minus, x_pos, y_pos, z_pos,
+ search_radius, verbose=False, cell_data=cell_data_minus)
+
+ depths_s3_m, profile_s3_m, _, _ = ProfileExtractor.extract_adaptive_profile(
+ centers_minus, sigma3_minus, x_pos, y_pos, z_pos,
+ search_radius, verbose=False, cell_data=cell_data_minus)
+
+ if pressure is not None:
+ depths_p_m, profile_p_m, _, _ = ProfileExtractor.extract_adaptive_profile(
+ centers_minus, pressure_minus, x_pos, y_pos, z_pos,
+ search_radius, verbose=False, cell_data=cell_data_minus)
+
+ if len(depths_s1_m) >= 3:
+ label_minus = f'Minus side'
+
+ # Plot Pressure
+ if pressure is not None:
+ axes[0].plot(profile_p_m, depths_p_m,
+ color=colors_minus[i], label=label_minus if i == 0 else '',
+ linewidth=2.5, alpha=0.8, marker='s', markersize=3, markevery=2)
+
+ # Plot σ1
+ axes[1].plot(profile_s1_m, depths_s1_m,
+ color=colors_minus[i], label=label_minus if i == 0 else '',
+ linewidth=2.5, alpha=0.8, marker='s', markersize=3, markevery=2)
+
+ # Plot σ2
+ axes[2].plot(profile_s2_m, depths_s2_m,
+ color=colors_minus[i], label=label_minus if i == 0 else '',
+ linewidth=2.5, alpha=0.8, marker='s', markersize=3, markevery=2)
+
+ # Plot σ3
+ axes[3].plot(profile_s3_m, depths_s3_m,
+ color=colors_minus[i], label=label_minus if i == 0 else '',
+ linewidth=2.5, alpha=0.8, marker='s', markersize=3, markevery=2)
+
+ # Plot All stresses
+ axes[4].plot(profile_s1_m, depths_s1_m,
+ color=colors_minus[i], linewidth=2.5, alpha=0.8,
+ linestyle='-', marker="o", markersize=2, markevery=2)
+ axes[4].plot(profile_s2_m, depths_s2_m,
+ color=colors_minus[i], linewidth=2.0, alpha=0.6,
+ linestyle='-', marker="s", markersize=2, markevery=2)
+ axes[4].plot(profile_s3_m, depths_s3_m,
+ color=colors_minus[i], linewidth=2.5, alpha=0.8,
+ linestyle='-', marker='v', markersize=2, markevery=2)
+
+ print(f" ✅ MINUS: {len(depths_s1_m)} points")
+ successful_profiles += 1
+
+ if successful_profiles == 0:
+ print(" ❌ No valid profiles found!")
+ plt.close()
+ return
+
+ # ===================================================================
+ # CONFIGURE PLOTS
+ # ===================================================================
+
+ fsize = 14
+
+ # Plot 0: Pressure
+ axes[0].set_xlabel('Pressure [bar]', fontsize=fsize, weight="bold")
+ axes[0].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
+ axes[0].grid(True, alpha=0.3, linestyle='--')
+ axes[0].legend(loc='best', fontsize=fsize-2)
+ axes[0].tick_params(labelsize=fsize-2)
+
+ if pressure is None:
+ axes[0].text(0.5, 0.5, 'No pressure data available',
+ ha='center', va='center', transform=axes[0].transAxes,
+ fontsize=fsize, style='italic', color='gray')
+
+ # Plot 1: σ1 (Maximum principal stress)
+ axes[1].set_xlabel('σ₁ (Max Principal) [bar]', fontsize=fsize, weight="bold")
+ axes[1].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
+ axes[1].grid(True, alpha=0.3, linestyle='--')
+ axes[1].legend(loc='best', fontsize=fsize-2)
+ axes[1].tick_params(labelsize=fsize-2)
+
+ # Plot 2: σ2 (Intermediate principal stress)
+ axes[2].set_xlabel('σ₂ (Inter Principal) [bar]', fontsize=fsize, weight="bold")
+ axes[2].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
+ axes[2].grid(True, alpha=0.3, linestyle='--')
+ axes[2].legend(loc='best', fontsize=fsize-2)
+ axes[2].tick_params(labelsize=fsize-2)
+
+ # Plot 3: σ3 (Min principal stress)
+ axes[3].set_xlabel('σ₃ (Min Principal) [bar]', fontsize=fsize, weight="bold")
+ axes[3].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
+ axes[3].grid(True, alpha=0.3, linestyle='--')
+ axes[3].legend(loc='best', fontsize=fsize-2)
+ axes[3].tick_params(labelsize=fsize-2)
+
+ # Plot 4: All stresses together
+ axes[4].set_xlabel('Principal Stresses [bar]', fontsize=fsize, weight="bold")
+ axes[4].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
+ axes[4].grid(True, alpha=0.3, linestyle='--')
+ axes[4].tick_params(labelsize=fsize-2)
+
+ # Add legend for line styles
+ from matplotlib.lines import Line2D
+ custom_lines = [
+ Line2D([0], [0], color='red', linewidth=2.5, marker=None, label='Plus side', alpha=0.5),
+ Line2D([0], [0], color='blue', linewidth=2.5, marker=None, label='Minus side', alpha=0.5),
+ Line2D([0], [0], color='gray', linewidth=2.5, linestyle='-', marker='o', label='σ₁ (max)'),
+ Line2D([0], [0], color='gray', linewidth=2.0, linestyle='-', marker='s', label='σ₂ (inter)'),
+ Line2D([0], [0], color='gray', linewidth=2.5, linestyle='-', marker='v', label='σ₃ (min)')
+ ]
+ axes[4].legend(handles=custom_lines, loc='best', fontsize=fsize-3, ncol=1)
+
+ # Change verticale scale
+ if self.config.MAX_DEPTH_PROFILES != None :
+ for i in range(len(axes)):
+ axes[i].set_ylim(bottom=self.config.MAX_DEPTH_PROFILES)
+
+ if self.config.MIN_DEPTH_PROFILES != None :
+ for i in range(len(axes)):
+ axes[i].set_ylim(top=self.config.MIN_DEPTH_PROFILES)
+
+ # Overall title
+ years = time / (365.25 * 24 * 3600)
+ fig.suptitle(f'Volume Stress Profiles - Both Sides Comparison - t={years:.1f} years',
+ fontsize=fsize+2, fontweight='bold', y=0.98)
+
+ plt.tight_layout(rect=[0, 0, 1, 0.96])
+
+ # Save
+ if save:
+ filename = f'volume_stress_profiles_both_sides_{years:.0f}y.png'
+ plt.savefig(path / filename, dpi=300, bbox_inches='tight')
+ print(f" 💾 Volume profiles saved: {filename}")
+
+ # Show
+ if show:
+ plt.show()
+ else:
+ plt.close()
+
+ # -------------------------------------------------------------------
+ def plot_analytical_vs_numerical_comparison(self, volume_mesh, fault_surface, time, path,
+ show=True, save=True,
+ profile_start_points=None,
+ reference_profile_id=1):
+ """
+ Plot comparison between analytical fault stresses (Anderson formulas)
+ and numerical tensor projection - COMBINED PLOTS ONLY
+
+ Parameters
+ ----------
+ volume_mesh : pyvista.UnstructuredGrid
+ Volume mesh with principal stresses AND analytical stresses
+ fault_surface : pyvista.PolyData
+ Fault surface mesh with projected stresses
+ time : float
+ Simulation time
+ path : Path
+ Output directory
+ show : bool
+ Show plot interactively
+ save : bool
+ Save plot to file
+ profile_start_points : list of tuples
+ Starting points (x, y, z) for profiles
+ reference_profile_id : int
+ Which profile ID to load from Excel reference data
+ """
+
+ print("\n 📊 Creating Analytical vs Numerical Comparison")
+
+ # ===================================================================
+ # CHECK IF ANALYTICAL DATA EXISTS
+ # ===================================================================
+
+ required_analytical = ['sigma_n_analytical', 'tau_analytical', 'side', 'elementCenter']
+
+ for field in required_analytical:
+ if field not in volume_mesh.cell_data:
+ print(f" ⚠️ Missing analytical field: {field}")
+ print(f" Analytical stresses not computed in volume mesh")
+ return
+
+ # Check numerical data on fault surface
+ if 'sigma_n_eff' not in fault_surface.cell_data:
+ print(f" ⚠️ Missing numerical stress data on fault surface")
+ return
+
+ # ===================================================================
+ # LOAD REFERENCE DATA (GEOS Contact Solver)
+ # ===================================================================
+
+ print(" 📂 Loading GEOS Contact Solver reference data...")
+ script_dir = os.path.dirname(os.path.abspath(__file__))
+ reference_data = Visualizer.load_reference_data(
+ time,
+ script_dir,
+ profile_id=reference_profile_id
+ )
+
+ geos_contact_data = reference_data.get('geos', None)
+
+ if geos_contact_data is not None:
+ print(f" ✅ Loaded {len(geos_contact_data)} reference points from GEOS Contact Solver")
+ else:
+ print(f" ⚠️ No GEOS Contact Solver reference data found")
+
+ # Extraire les IDs de faille
+ fault_ids_volume = None
+ fault_ids_surface = None
+
+ if 'fault_id' in volume_mesh.cell_data:
+ fault_ids_volume = volume_mesh.cell_data['fault_id']
+
+ if 'FaultMask' in fault_surface.cell_data:
+ fault_ids_surface = fault_surface.cell_data['FaultMask']
+ elif 'attribute' in fault_surface.cell_data:
+ fault_ids_surface = fault_surface.cell_data['attribute']
+
+ # ===================================================================
+ # EXTRACT DATA
+ # ===================================================================
+
+ # Volume analytical data
+ centers_volume = volume_mesh.cell_data['elementCenter']
+ side_data = volume_mesh.cell_data['side']
+ sigma_n_analytical = volume_mesh.cell_data['sigma_n_analytical']
+ tau_analytical = volume_mesh.cell_data['tau_analytical']
+
+ # Optional: SCU if available
+ has_SCU_analytical = 'SCU_analytical' in volume_mesh.cell_data
+ if has_SCU_analytical:
+ SCU_analytical = volume_mesh.cell_data['SCU_analytical']
+
+ # Fault numerical data
+ centers_fault = fault_surface.cell_data['elementCenter']
+ sigma_n_numerical = fault_surface.cell_data['sigma_n_eff']
+ tau_numerical = fault_surface.cell_data['tau_eff']
+
+ # Optional: SCU numerical
+ has_SCU_numerical = 'SCU' in fault_surface.cell_data
+ if has_SCU_numerical:
+ SCU_numerical = fault_surface.cell_data['SCU']
+
+ # Filter volume by side
+ mask_plus = (side_data == 1) | (side_data == 3)
+ mask_minus = (side_data == 2) | (side_data == 3)
+
+ centers_plus = centers_volume[mask_plus]
+ sigma_n_analytical_plus = sigma_n_analytical[mask_plus]
+ tau_analytical_plus = tau_analytical[mask_plus]
+ if has_SCU_analytical:
+ SCU_analytical_plus = SCU_analytical[mask_plus]
+
+ centers_minus = centers_volume[mask_minus]
+ sigma_n_analytical_minus = sigma_n_analytical[mask_minus]
+ tau_analytical_minus = tau_analytical[mask_minus]
+ if has_SCU_analytical:
+ SCU_analytical_minus = SCU_analytical[mask_minus]
+
+ print(f" 📍 Plus side: {len(centers_plus):,} cells with analytical data")
+ print(f" 📍 Minus side: {len(centers_minus):,} cells with analytical data")
+ print(f" 📍 Fault surface: {len(centers_fault):,} cells with numerical data")
+
+ # ===================================================================
+ # GET FAULT BOUNDS AND PROFILE SETUP
+ # ===================================================================
+
+ x_min, x_max = np.min(centers_fault[:, 0]), np.max(centers_fault[:, 0])
+ y_min, y_max = np.min(centers_fault[:, 1]), np.max(centers_fault[:, 1])
+ z_min, z_max = np.min(centers_fault[:, 2]), np.max(centers_fault[:, 2])
+
+ x_range = x_max - x_min
+ y_range = y_max - y_min
+
+ # Search radius
+ if self.config.PROFILE_SEARCH_RADIUS is not None:
+ search_radius = self.config.PROFILE_SEARCH_RADIUS
+ else:
+ search_radius = min(x_range, y_range) * 0.2
+
+ # Auto-generate profile points if not provided
+ if profile_start_points is None:
+ print(" ⚠️ No profile_start_points provided, auto-generating...")
+ n_profiles = 3
+
+ if x_range > y_range:
+ coord_name = 'X'
+ fixed_value = (y_min + y_max) / 2
+ sample_positions = np.linspace(x_min, x_max, n_profiles)
+ profile_start_points = [(x, fixed_value, z_max) for x in sample_positions]
+ else:
+ coord_name = 'Y'
+ fixed_value = (x_min + x_max) / 2
+ sample_positions = np.linspace(y_min, y_max, n_profiles)
+ profile_start_points = [(fixed_value, y, z_max) for y in sample_positions]
+
+ print(f" Auto-generated {n_profiles} profiles along {coord_name}")
+
+ n_profiles = len(profile_start_points)
+
+ # ===================================================================
+ # CREATE FIGURE: COMBINED PLOTS ONLY
+ # 3 columns (σ_n, τ, SCU) x 1 row
+ # ===================================================================
+
+ fig, axes = plt.subplots(1, 3, figsize=(18, 10))
+
+ print(f" 📍 Processing {n_profiles} profiles for comparison:")
+
+ successful_profiles = 0
+
+ # ===================================================================
+ # EXTRACT AND PLOT PROFILES
+ # ===================================================================
+
+ for i, (x_pos, y_pos, z_pos) in enumerate(profile_start_points):
+ print(f"\n → Profile {i+1}: starting at ({x_pos:.1f}, {y_pos:.1f}, {z_pos:.1f})")
+
+ # ================================================================
+ # PLUS SIDE - ANALYTICAL
+ # ================================================================
+ if len(centers_plus) > 0:
+ depths_sn_ana_p, profile_sn_ana_p, _, _ = ProfileExtractor.extract_adaptive_profile(
+ centers_plus, sigma_n_analytical_plus, x_pos, y_pos, z_pos,
+ search_radius, verbose=False)
+
+ depths_tau_ana_p, profile_tau_ana_p, _, _ = ProfileExtractor.extract_adaptive_profile(
+ centers_plus, tau_analytical_plus, x_pos, y_pos, z_pos,
+ search_radius, verbose=False)
+
+ if has_SCU_analytical:
+ depths_scu_ana_p, profile_scu_ana_p, _, _ = ProfileExtractor.extract_adaptive_profile(
+ centers_plus, SCU_analytical_plus, x_pos, y_pos, z_pos,
+ search_radius, verbose=False, )
+
+ if len(depths_sn_ana_p) >= 3:
+ # Plot σ_n
+ axes[0].plot(profile_sn_ana_p, depths_sn_ana_p,
+ color='red', linestyle='-', linewidth=2,
+ alpha=0.3, label='Analytical Side +' if i == 0 else '', marker=None)
+
+ # Plot τ
+ axes[1].plot(profile_tau_ana_p, depths_tau_ana_p,
+ color='red', linestyle='-', linewidth=2,
+ alpha=0.3, label='Analytical Side +' if i == 0 else '', marker=None)
+
+ # Plot SCU if available
+ if has_SCU_analytical and len(depths_scu_ana_p) >= 3:
+ axes[2].plot(profile_scu_ana_p, depths_scu_ana_p,
+ color='red', linestyle='-', linewidth=2,
+ alpha=0.3, label='Analytical Side +' if i == 0 else '', marker=None)
+
+ # ================================================================
+ # MINUS SIDE - ANALYTICAL
+ # ================================================================
+ if len(centers_minus) > 0:
+ depths_sn_ana_m, profile_sn_ana_m, _, _ = ProfileExtractor.extract_adaptive_profile(
+ centers_minus, sigma_n_analytical_minus, x_pos, y_pos, z_pos,
+ search_radius, verbose=False)
+
+ depths_tau_ana_m, profile_tau_ana_m, _, _ = ProfileExtractor.extract_adaptive_profile(
+ centers_minus, tau_analytical_minus, x_pos, y_pos, z_pos,
+ search_radius, verbose=False)
+
+ if has_SCU_analytical:
+ depths_scu_ana_m, profile_scu_ana_m, _, _ = ProfileExtractor.extract_adaptive_profile(
+ centers_minus, SCU_analytical_minus, x_pos, y_pos, z_pos,
+ search_radius, verbose=False)
+
+ if len(depths_sn_ana_m) >= 3:
+ # Plot σ_n
+ axes[0].plot(profile_sn_ana_m, depths_sn_ana_m,
+ color='blue', linestyle='-', linewidth=2,
+ alpha=0.3, label='Analytical Side -' if i == 0 else '', marker=None)
+
+ # Plot τ
+ axes[1].plot(profile_tau_ana_m, depths_tau_ana_m,
+ color='blue', linestyle='-', linewidth=2,
+ alpha=0.3, label='Analytical Side -' if i == 0 else '', marker=None)
+
+ # Plot SCU if available
+ if has_SCU_analytical and len(depths_scu_ana_m) >= 3:
+ axes[2].plot(profile_scu_ana_m, depths_scu_ana_m,
+ color='blue', linestyle='-', linewidth=2,
+ alpha=0.3, label='Analytical Side -' if i == 0 else '', marker=None)
+
+ # ================================================================
+ # AVERAGES - ANALYTICAL (only for first profile to avoid clutter)
+ # ================================================================
+ if i == 0 and len(depths_sn_ana_m) >= 3 and len(depths_sn_ana_p) >= 3:
+ # Arithmetic average
+ avg_sn_arith = (profile_sn_ana_m + profile_sn_ana_p) / 2
+ avg_tau_arith = (profile_tau_ana_m + profile_tau_ana_p) / 2
+
+ axes[0].plot(avg_sn_arith, depths_sn_ana_m,
+ color='darkorange', linestyle='-', linewidth=2,
+ alpha=0.6, label='Arithmetic average')
+
+ axes[1].plot(avg_tau_arith, depths_sn_ana_m,
+ color='darkorange', linestyle='-', linewidth=2,
+ alpha=0.6, label='Arithmetic average')
+
+ # Geometric average
+ avg_tau_geom = np.sqrt(profile_tau_ana_m * profile_tau_ana_p)
+
+ axes[1].plot(avg_tau_geom, depths_sn_ana_m,
+ color='purple', linestyle='-', linewidth=2,
+ alpha=0.6, label='Geometric average')
+
+ # Harmonic average
+ avg_sn_harm = 2 / (1/profile_sn_ana_m + 1/profile_sn_ana_p)
+ avg_tau_harm = 2 / (1/profile_tau_ana_m + 1/profile_tau_ana_p)
+
+ axes[0].plot(avg_sn_harm, depths_sn_ana_m,
+ color='green', linestyle='-', linewidth=2,
+ alpha=0.6, label='Harmonic average')
+
+ axes[1].plot(avg_tau_harm, depths_sn_ana_m,
+ color='green', linestyle='-', linewidth=2,
+ alpha=0.6, label='Harmonic average')
+
+ # ================================================================
+ # NUMERICAL DATA FROM FAULT SURFACE (Continuum)
+ # ================================================================
+ print(f" Extracting numerical data from fault surface...")
+
+ depths_sn_num, profile_sn_num, _, _ = ProfileExtractor.extract_adaptive_profile(
+ centers_fault, sigma_n_numerical, x_pos, y_pos, z_pos,
+ search_radius, verbose=False)
+
+ depths_tau_num, profile_tau_num, _, _ = ProfileExtractor.extract_adaptive_profile(
+ centers_fault, tau_numerical, x_pos, y_pos, z_pos,
+ search_radius, verbose=False)
+
+ if has_SCU_numerical:
+ depths_scu_num, profile_scu_num, _, _ = ProfileExtractor.extract_adaptive_profile(
+ centers_fault, SCU_numerical, x_pos, y_pos, z_pos,
+ search_radius, verbose=False)
+
+ if len(depths_sn_num) >= 3:
+ # Plot numerical with distinct style
+ axes[0].plot(profile_sn_num, depths_sn_num,
+ color='black', linestyle='-', linewidth=2,
+ alpha=0.7, label='GEOS Continuum' if i == 0 else '',
+ marker='x', markersize=5, markevery=3)
+
+ axes[1].plot(profile_tau_num, depths_tau_num,
+ color='black', linestyle='-', linewidth=2,
+ alpha=0.7, label='GEOS Continuum' if i == 0 else '',
+ marker='x', markersize=5, markevery=3)
+
+ if has_SCU_numerical and len(depths_scu_num) >= 3:
+ axes[2].plot(profile_scu_num, depths_scu_num,
+ color='black', linestyle='-', linewidth=2,
+ alpha=0.7, label='GEOS Continuum' if i == 0 else '',
+ marker='x', markersize=5, markevery=3)
+
+ successful_profiles += 1
+
+ if successful_profiles == 0:
+ print(" ❌ No valid profiles found!")
+ plt.close()
+ return
+
+ # ===================================================================
+ # ADD GEOS CONTACT SOLVER REFERENCE DATA (only once)
+ # ===================================================================
+
+ if geos_contact_data is not None:
+ # Format: [Depth_m, Normal_Stress_bar, Shear_Stress_bar, SCU]
+ # Index: [0, 1, 2, 3]
+
+ print(" 📊 Adding GEOS Contact Solver reference data...")
+
+ # Normal stress
+ axes[0].plot(geos_contact_data[:, 1], geos_contact_data[:, 0],
+ marker='o', color='black', markersize=7,
+ label='GEOS Contact Solver', linestyle='none',
+ alpha=0.8, mec='black', mew=1.5, fillstyle='none')
+
+ # Shear stress
+ axes[1].plot(geos_contact_data[:, 2], geos_contact_data[:, 0],
+ marker='o', color='black', markersize=7,
+ label='GEOS Contact Solver', linestyle='none',
+ alpha=0.8, mec='black', mew=1.5, fillstyle='none')
+
+ # SCU (if available)
+ if geos_contact_data.shape[1] > 3:
+ axes[2].plot(geos_contact_data[:, 3], geos_contact_data[:, 0],
+ marker='o', color='black', markersize=7,
+ label='GEOS Contact Solver', linestyle='none',
+ alpha=0.8, mec='black', mew=1.5, fillstyle='none')
+
+ # ===================================================================
+ # CONFIGURE PLOTS
+ # ===================================================================
+
+ fsize = 14
+
+ # Plot 0: Normal Stress
+ axes[0].set_xlabel('Normal Stress σₙ [bar]', fontsize=fsize, weight="bold")
+ axes[0].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
+ axes[0].grid(True, alpha=0.3, linestyle='--')
+ axes[0].legend(loc='best', fontsize=fsize-2)
+ axes[0].tick_params(labelsize=fsize-1)
+
+ # Plot 1: Shear Stress
+ axes[1].set_xlabel('Shear Stress τ [bar]', fontsize=fsize, weight="bold")
+ axes[1].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
+ axes[1].grid(True, alpha=0.3, linestyle='--')
+ axes[1].legend(loc='best', fontsize=fsize-2)
+ axes[1].tick_params(labelsize=fsize-1)
+
+ # Plot 2: SCU
+ axes[2].set_xlabel('SCU [-]', fontsize=fsize, weight="bold")
+ axes[2].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
+ axes[2].axvline(x=0.8, color='forestgreen', linestyle='--', linewidth=2,
+ alpha=0.5, label='Critical (0.8)')
+ axes[2].axvline(x=1.0, color='red', linestyle='--', linewidth=2,
+ alpha=0.5, label='Failure (1.0)')
+ axes[2].grid(True, alpha=0.3, linestyle='--')
+ axes[2].legend(loc='upper right', fontsize=fsize-2, ncol=1)
+ axes[2].tick_params(labelsize=fsize-1)
+ axes[2].set_xlim(left=0)
+
+ # Overall title
+ years = time / (365.25 * 24 * 3600)
+ fig.suptitle(f'Analytical (Anderson) vs Numerical (GEOS Continuum & Contact) - t={years:.1f} years',
+ fontsize=fsize+2, fontweight='bold', y=0.995)
+
+ # Change verticale scale
+ if self.config.MAX_DEPTH_PROFILES != None :
+ for i in range(len(axes)):
+ axes[i].set_ylim(bottom=self.config.MAX_DEPTH_PROFILES)
+
+ if self.config.MIN_DEPTH_PROFILES != None :
+ for i in range(len(axes)):
+ axes[i].set_ylim(top=self.config.MIN_DEPTH_PROFILES)
+
+ plt.tight_layout(rect=[0, 0, 1, 0.99])
+
+ # Save
+ if save:
+ filename = f'analytical_vs_numerical_comparison_{years:.0f}y.png'
+ plt.savefig(path / filename, dpi=300, bbox_inches='tight')
+ print(f"\n 💾 Comparison plot saved: {filename}")
+
+ # Show
+ if show:
+ plt.show()
+ else:
+ plt.close()
+
+
+# ============================================================================
+# MAIN
+# ============================================================================
+def main():
+
+ """Main execution function"""
+ config = Config()
+
+ print("=" * 62)
+ ascii_banner = pyfiglet.figlet_format("Fault Analysis")
+ print(ascii_banner)
+ print("=" * 62)
+
+ path = Path(config.PATH)
+
+ # Load fault geometry
+ mesh = pv.read(path / config.GRID_FILE)
+ print(f"✅ Mesh loaded: {config.GRID_FILE} | {mesh.n_cells} cells")
+
+ # Read first volume dataset
+ pvd_reader = pv.PVDReader(path / config.PVD_FILE)
+ pvd_reader.set_active_time_point(0)
+ dataset = pvd_reader.read()
+
+ # IMPORTANT : Utiliser le même merge que dans la boucle
+ processor = TimeSeriesProcessor(config)
+ volume_mesh = processor._merge_blocks(dataset)
+ print(f"✅ Volume mesh extracted: {volume_mesh.n_cells} cells")
+
+
+ # Initialize fault geometry with topology pre-computation
+ print("\n📐 Initialize fault geometry")
+ fault_geometry = FaultGeometry(
+ config = config,
+ mesh=mesh,
+ fault_values=config.FAULT_VALUES,
+ fault_attribute=config.FAULT_ATTRIBUTE,
+ volume_mesh=volume_mesh)
+
+
+ # Compute normals and adjacency topology (done once!)
+ print("🔧 Computing normals and adjacency topology")
+ fault_surface, adjacency_mapping = fault_geometry.initialize( scale_factor=50.0 )
+
+
+ # Process time series
+ processor = TimeSeriesProcessor(config)
+ processor.process(path, fault_geometry, config.PVD_FILE)
+
+ print("\n" + "=" * 60)
+ print("✅ ANALYSIS COMPLETE")
+ print("=" * 60)
+
+
+if __name__ == "__main__":
+ main()
From 6a95227cbf603ae81154a194a12628a8461c0630 Mon Sep 17 00:00:00 2001
From: Paloma Martinez <104762252+paloma-martinez@users.noreply.github.com>
Date: Thu, 5 Feb 2026 17:15:23 +0100
Subject: [PATCH 2/5] Split and change to camel case
---
.../geos/geomechanics/model/StressTensor.py | 57 +
.../post_processing/FaultGeometry.py | 918 ++++
.../post_processing/FaultStabilityAnalysis.py | 4465 +----------------
.../post_processing/ProfileExtractor.py | 731 +++
.../post_processing/SensitivityAnalyzer.py | 283 ++
.../post_processing/StressProjector.py | 678 +++
.../geos/processing/tools/FaultVisualizer.py | 1312 +++++
7 files changed, 4228 insertions(+), 4216 deletions(-)
create mode 100644 geos-geomechanics/src/geos/geomechanics/model/StressTensor.py
create mode 100644 geos-processing/src/geos/processing/post_processing/FaultGeometry.py
create mode 100644 geos-processing/src/geos/processing/post_processing/ProfileExtractor.py
create mode 100644 geos-processing/src/geos/processing/post_processing/SensitivityAnalyzer.py
create mode 100644 geos-processing/src/geos/processing/post_processing/StressProjector.py
create mode 100644 geos-processing/src/geos/processing/tools/FaultVisualizer.py
diff --git a/geos-geomechanics/src/geos/geomechanics/model/StressTensor.py b/geos-geomechanics/src/geos/geomechanics/model/StressTensor.py
new file mode 100644
index 00000000..c64ebf90
--- /dev/null
+++ b/geos-geomechanics/src/geos/geomechanics/model/StressTensor.py
@@ -0,0 +1,57 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright 2023-2026 TotalEnergies.
+# SPDX-FileContributor: Nicolas Pillardou, Paloma Martinez
+
+import numpy as np
+
+# ============================================================================
+# STRESS TENSOR OPERATIONS
+# ============================================================================
+class StressTensor:
+ """Utility class for stress tensor operations"""
+
+ @staticmethod
+ def buildFromArray(arr):
+ """Convert stress array to 3x3 tensor format"""
+ n = arr.shape[0]
+ tensors = np.zeros((n, 3, 3))
+
+ if arr.shape[1] == 6: # Voigt notation
+ tensors[:, 0, 0] = arr[:, 0] # Sxx
+ tensors[:, 1, 1] = arr[:, 1] # Syy
+ tensors[:, 2, 2] = arr[:, 2] # Szz
+ tensors[:, 1, 2] = tensors[:, 2, 1] = arr[:, 3] # Syz
+ tensors[:, 0, 2] = tensors[:, 2, 0] = arr[:, 4] # Sxz
+ tensors[:, 0, 1] = tensors[:, 1, 0] = arr[:, 5] # Sxy
+ elif arr.shape[1] == 9:
+ tensors = arr.reshape((-1, 3, 3))
+ else:
+ raise ValueError(f"Unsupported stress shape: {arr.shape}")
+
+ return tensors
+
+ @staticmethod
+ def rotateToFaultFrame(stressTensor, normal, tangent1, tangent2):
+ """Rotate stress tensor to fault local coordinate system"""
+ # Verify orthonormality
+ assert np.abs(np.linalg.norm(tangent1) - 1.0) < 1e-10
+ assert np.abs(np.linalg.norm(tangent2) - 1.0) < 1e-10
+ assert np.abs(np.dot(normal, tangent1)) < 1e-10
+ assert np.abs(np.dot(normal, tangent2)) < 1e-10
+
+ # Rotation matrix: columns = local directions (n, t1, t2)
+ R = np.column_stack((normal, tangent1, tangent2))
+
+ # Rotate tensor
+ stressLocal = R.T @ stressTensor @ R
+
+ # Traction on fault plane (normal = [1,0,0] in local frame)
+ tractionLocal = stressLocal @ np.array([1.0, 0.0, 0.0])
+
+ return {
+ 'stressLocal': stressLocal,
+ 'normalStress': tractionLocal[0],
+ 'shearStress': np.sqrt(tractionLocal[1]**2 + tractionLocal[2]**2),
+ 'shearStrike': tractionLocal[1],
+ 'shearDip': tractionLocal[2]
+ }
diff --git a/geos-processing/src/geos/processing/post_processing/FaultGeometry.py b/geos-processing/src/geos/processing/post_processing/FaultGeometry.py
new file mode 100644
index 00000000..67f92ecb
--- /dev/null
+++ b/geos-processing/src/geos/processing/post_processing/FaultGeometry.py
@@ -0,0 +1,918 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright 2023-2026 TotalEnergies.
+# SPDX-FileContributor: Nicolas Pillardou, Paloma Martinez
+# ============================================================================
+# FAULT GEOMETRY
+# ============================================================================
+import pyvista as pv
+from pathlib import Path
+from vtkmodules.vtkCommonDataModel import vtkCellLocator
+
+class FaultGeometry:
+
+ """Handles fault surface extraction and normal computation with optimizations"""
+
+ # -------------------------------------------------------------------
+ def __init__(self, config, mesh, faultValues, faultAttribute, volumeMesh):
+ """
+ Initialize fault geometry with pre-computed topology.
+
+ Args:
+ config (Config):
+ mesh (): pv.read(path / config.GRID_FILE) -> "mesh_faulted_reservoir_60_mod.vtu"
+ faultValues (list[int]): Config.FAULT_VALUES
+ faultAttribute (str): Config.FAULT_ATTRIBUTES
+ volumeMesh (): processor._merge_blocks(dataset)
+ """
+ self.mesh = mesh
+ self.faultValues = faultValues
+ self.faultAttribute = faultAttribute
+ self.volumeMesh = volumeMesh
+
+ # These will be computed once
+ self.faultSurface = None
+ self.surfaces = None
+ self.adjacencyMapping = None
+ self.contributingCells = None
+ self.contributingCellsPlus = None
+ self.contributingCellsMinus = None
+
+ # NEW: Pre-computed geometric properties
+ self.volumeCellVolumes = None # Volume of each cell
+ self.volumeCenters = None # Center coordinates
+ self.distanceToFault = None # Distance from each volume cell to nearest fault
+ self.faultTree = None # KDTree for fault surface
+
+ # Config
+ self.config = config
+
+ # -------------------------------------------------------------------
+ def initialize(self, scaleFactor=50.0, processFaultsSeparately=True):
+ """
+ One-time initialization: compute normals, adjacency topology, and geometric properties
+ """
+
+ # Extract and compute normals
+ self.faultSurface, self.surfaces = self._extractAndComputeNormals(
+ showPlot=self.config.SHOW_NORMAL_PLOTS,
+ scaleFactor=scaleFactor,
+ zScale=self.config.Z_SCALE)
+
+ # Pre-compute adjacency mapping
+ print("\n🔍 Pre-computing volume-fault adjacency topology")
+ print(" Method: Face-sharing (adaptive epsilon)")
+
+ self.adjacencyMapping = self._buildAdjacencyMappingFaceSharing(
+ processFaultsSeparately=processFaultsSeparately)
+
+ # Mark and optionally save contributing cells
+ self._markContributingCells()
+
+ # NEW: Pre-compute geometric properties
+ self._precomputeGeometricProperties()
+
+ nMapped = len(self.adjacencyMapping)
+ nWithBoth = sum(1 for m in self.adjacencyMapping.values()
+ if len(m['plus']) > 0 and len(m['minus']) > 0)
+
+ print("\n✅ Adjacency topology computed:")
+ print(f" - {nMapped}/{self.faultSurface.n_cells} fault cells mapped")
+ print(f" - {nWithBoth} cells have neighbors on both sides")
+
+ # Visualize contributions if requested
+ if self.config.SHOW_CONTRIBUTION_VIZ:
+ self._visualizeContributions()
+
+ return self.faultSurface, self.adjacencyMapping
+
+ # -------------------------------------------------------------------
+ def _markContributingCells(self):
+ """
+ Mark volume cells that contribute to fault stress projection
+ """
+ print("\n📦 Marking contributing volume cells...")
+
+ nVolume = self.volumeMesh.n_cells
+
+ # Collect contributing cells by side
+ allPlus = set()
+ allMinus = set()
+
+ for faultIdx, neighbors in self.adjacencyMapping.items():
+ allPlus.update(neighbors['plus'])
+ allMinus.update(neighbors['minus'])
+
+ # Create classification array
+ contributionSide = np.zeros(nVolume, dtype=int)
+
+ for idx in allPlus:
+ if 0 <= idx < nVolume:
+ contributionSide[idx] += 1
+
+ for idx in allMinus:
+ if 0 <= idx < nVolume:
+ contributionSide[idx] += 2
+
+ # Add classification to volume mesh
+ self.volumeMesh.cell_data["contributionSide"] = contributionSide
+ contribMask = contributionSide > 0
+ self.volumeMesh.cell_data["contribution_to_faults"] = contribMask.astype(int)
+
+ # Extract subsets
+ maskAll = contribMask
+ maskPlus = (contributionSide == 1) | (contributionSide == 3)
+ maskMinus = (contributionSide == 2) | (contributionSide == 3)
+
+ self.contributingCells = self.volumeMesh.extract_cells(maskAll)
+ self.contributingCellsPlus = self.volumeMesh.extract_cells(maskPlus)
+ self.contributingCellsMinus = self.volumeMesh.extract_cells(maskMinus)
+
+ # Statistics
+ nContrib = np.sum(maskAll)
+ nPlus = np.sum(contributionSide == 1)
+ nMinus = np.sum(contributionSide == 2)
+ nBoth = np.sum(contributionSide == 3)
+ pctContrib = nContrib / nVolume * 100
+
+ print(f" ✅ Total contributing: {nContrib}/{nVolume} ({pctContrib:.1f}%)")
+ print(f" Plus side only: {nPlus} cells")
+ print(f" Minus side only: {nMinus} cells")
+ print(f" Both sides: {nBoth} cells")
+
+ # Save to files if requested
+ if self.config.SAVE_CONTRIBUTION_CELLS:
+ self._saveContributingCells()
+
+ # -------------------------------------------------------------------
+ def _saveContributingCells(self):
+ """
+ Save contributing volume cells to VTU files
+ Saves three files: all, plus side, minus side
+ """
+ from pathlib import Path
+
+ # Create output directory if it doesn't exist
+ outputDir = Path(self.config.OUTPUT_DIR) if hasattr(self.config, 'OUTPUT_DIR') else Path('.')
+ outputDir.mkdir(parents=True, exist_ok=True)
+
+ # Save all contributing cells
+ filenameAll = outputDir / "contributing_cells_all.vtu"
+ self.contributingCells.save(str(filenameAll))
+ print(f"\n 💾 All contributing cells saved: {filenameAll}")
+ print(f" ({self.contributingCells.n_cells} cells, {self.contributingCells.n_points} points)")
+
+ # Save plus side
+ filenamePlus = outputDir / "contributingCellsPlus.vtu"
+ # self.contributingCellsPlus.save(str(filenamePlus))
+ # print(f" 💾 Plus side cells saved: {filenamePlus}")
+ print(f" ({self.contributingCellsPlus.n_cells} cells, {self.contributingCellsPlus.n_points} points)")
+
+ # Save minus side
+ filenameMinus = outputDir / "contributingCellsMinus.vtu"
+ # self.contributingCellsMinus.save(str(filenameMinus))
+ # print(f" 💾 Minus side cells saved: {filenameMinus}")
+ print(f" ({self.contributingCellsMinus.n_cells} cells, {self.contributingCellsMinus.n_points} points)")
+
+ # -------------------------------------------------------------------
+ def getContributingCells(self, side='all'):
+ """
+ Get the extracted contributing cells
+
+ Parameters:
+ side: 'all', 'plus', or 'minus'
+
+ Returns:
+ pyvista.UnstructuredGrid: Contributing volume cells
+ """
+ if self.contributingCells is None:
+ raise ValueError("Contributing cells not yet computed. Call initialize() first.")
+
+ if side == 'all':
+ return self.contributingCells
+ elif side == 'plus':
+ return self.contributingCellsPlus
+ elif side == 'minus':
+ return self.contributingCellsMinus
+ else:
+ raise ValueError(f"Invalid side '{side}'. Must be 'all', 'plus', or 'minus'.")
+
+ # -------------------------------------------------------------------
+ def getGeometricProperties(self):
+ """
+ Get pre-computed geometric properties
+
+ Returns
+ -------
+ dict with keys:
+ - 'volumes': ndarray of cell volumes
+ - 'centers': ndarray of cell centers (nCells, 3)
+ - 'distances': ndarray of distances to nearest fault cell
+ - 'faultTree': KDTree for fault surface
+ """
+ if self.volumeCellVolumes is None:
+ raise ValueError("Geometric properties not computed. Call initialize() first.")
+
+ return {
+ 'volumes': self.volumeCellVolumes,
+ 'centers': self.volumeCenters,
+ 'distances': self.distanceToFault,
+ 'faultTree': self.faultTree
+ }
+
+ # -------------------------------------------------------------------
+ def _precomputeGeometricProperties(self):
+ """
+ Pre-compute geometric properties of volume mesh for efficient stress projection
+
+ Computes:
+ - Cell volumes (for volume-weighted averaging)
+ - Cell centers (for distance calculations)
+ - Distance from each volume cell to nearest fault cell
+ - KDTree for fault surface
+ """
+ print("\n📐 Pre-computing geometric properties...")
+
+ nVolume = self.volumeMesh.n_cells
+
+ # 1. Compute volume centers
+ print(" Computing cell centers...")
+ self.volumeCenters = self.volumeMesh.cell_centers().points
+
+ # 2. Compute cell volumes
+ print(" Computing cell volumes...")
+ volumeWithSizes = self.volumeMesh.compute_cell_sizes(
+ length=False, area=False, volume=True
+ )
+ self.volumeCellVolumes = volumeWithSizes.cell_data['Volume']
+
+ print(f" Volume range: [{np.min(self.volumeCellVolumes):.1e}, "
+ f"{np.max(self.volumeCellVolumes):.1e}] m³")
+
+ # 3. Build KDTree for fault surface (for fast distance queries)
+ print(" Building KDTree for fault surface...")
+
+ faultCenters = self.faultSurface.cell_centers().points
+ self.faultTree = cKDTree(faultCenters)
+
+ # 4. Compute distance from each volume cell to nearest fault cell
+ print(" Computing distances to fault...")
+ self.distanceToFault = np.zeros(nVolume)
+
+ # Vectorized query for all points at once (much faster)
+ distances, _ = self.faultTree.query(self.volumeCenters)
+ self.distanceToFault = distances
+
+ print(f" Distance range: [{np.min(self.distanceToFault):.1f}, "
+ f"{np.max(self.distanceToFault):.1f}] m")
+
+ # 5. Add these properties to volume mesh for reference
+ self.volumeMesh.cell_data['cellVolume'] = self.volumeCellVolumes # TODO FIX
+ self.volumeMesh.cell_data['distanceToFault'] = self.distanceToFault
+
+ print(" ✅ Geometric properties computed and cached")
+
+ # -------------------------------------------------------------------
+ def _buildAdjacencyMappingFaceSharing(self, processFaultsSeparately=True):
+ """
+ Build adjacency for cells sharing faces with fault
+ Uses adaptive epsilon optimization
+ """
+
+ faultIds = np.unique(self.faultSurface.cell_data[self.faultAttribute])
+ nFaults = len(faultIds)
+ print(f" 📋 Processing {nFaults} separate faults: {faultIds}")
+
+ allMappings = {}
+
+ for faultId in faultIds:
+ mask = self.faultSurface.cell_data[self.faultAttribute] == faultId
+ indices = np.where(mask)[0]
+ singleFault = self.faultSurface.extract_cells(indices)
+
+ print(f" 🔧 Mapping Fault {faultId}...")
+
+ # Build face-sharing mapping with adaptive epsilon
+ localMapping = self._findFaceSharingCells(singleFault)
+
+ # Remap local indices to global fault indices
+ for localIdx, neighbors in localMapping.items():
+ globalIdx = indices[localIdx]
+ allMappings[globalIdx] = neighbors
+
+ return allMappings
+
+ # -------------------------------------------------------------------
+ def _findFaceSharingCells(self, faultSurface):
+ """
+ Find volume cells that share a FACE with fault cells
+
+ Uses FindCell with adaptive epsilon to maximize cells with both neighbors
+ """
+ volMesh = self.volumeMesh
+ volCenters = volMesh.cell_centers().points
+ faultNormals = faultSurface.cell_data["Normals"]
+ faultCenters = faultSurface.cell_centers().points
+
+ # Determine base epsilon based on mesh size
+ volBounds = volMesh.bounds
+ typicalSize = np.mean([
+ volBounds[1] - volBounds[0],
+ volBounds[3] - volBounds[2],
+ volBounds[5] - volBounds[4]
+ ]) / 100.0
+
+ # Build VTK cell locator (once)
+ locator = vtkCellLocator()
+ locator.SetDataSet(volMesh)
+ locator.BuildLocator()
+
+ # Try multiple epsilon values and keep the best
+ epsilonCandidates = [
+ typicalSize * 0.005,
+ typicalSize * 0.01,
+ typicalSize * 0.05,
+ typicalSize * 0.1,
+ typicalSize * 0.2,
+ typicalSize * 0.5,
+ typicalSize * 1.0
+ ]
+
+ print(f" Testing {len(epsilonCandidates)} epsilon values...")
+
+ bestEpsilon = None
+ bestMapping = None
+ bestScore = -1
+ bestStats = None
+
+ for epsilon in epsilonCandidates:
+ # Test this epsilon
+ mapping, stats = self._testEpsilon(
+ faultSurface, locator, epsilon,
+ faultCenters, faultNormals, volCenters
+ )
+
+ # Score = percentage with both sides + penalty for no neighbors
+ score = stats['pctBoth'] - 2.0 * stats['pctNone']
+
+ print(f" ε={epsilon:.3f}m → Both: {stats['pctBoth']:.1f}%, "
+ f"One: {stats['pctOne']:.1f}%, None: {stats['pctNone']:.1f}%, "
+ f"Avg: {stats['avgNeighbors']:.2f} (score: {score:.1f})")
+
+ if score > bestScore:
+ bestScore = score
+ bestEpsilon = epsilon
+ bestMapping = mapping
+ bestStats = stats
+
+ print(f"\n ✅ Best epsilon: {bestEpsilon:.6f}m")
+ print(f" ✅ Face-sharing mapping completed:")
+ print(f" Both sides: {bestStats['nBoth']} ({bestStats['pctBoth']:.1f}%)")
+ print(f" One side: {bestStats['nOne']} ({bestStats['pctOne']:.1f}%)")
+ print(f" No neighbors: {bestStats['nNone']} ({bestStats['pctNone']:.1f}%)")
+ print(f" Average neighbors per fault cell: {bestStats['avgNeighbors']:.2f}")
+
+ return bestMapping
+
+ # -------------------------------------------------------------------
+ def _testEpsilon(self, faultSurface, locator, epsilon,
+ faultCenters, faultNormals, volCenters):
+ """
+ Test a specific epsilon value and return mapping + statistics
+ """
+ mapping = {}
+ nFoundBoth = 0
+ nFoundOne = 0
+ nFoundNone = 0
+ totalNeighbors = 0
+
+ for fid in range(faultSurface.n_cells):
+ fcenter = faultCenters[fid]
+ fnormal = faultNormals[fid]
+
+ plusCells = []
+ minusCells = []
+
+ # Search on PLUS side
+ pointPlus = fcenter + epsilon * fnormal
+ cellIdPlus = locator.FindCell(pointPlus)
+ if cellIdPlus >= 0:
+ plusCells.append(cellIdPlus)
+
+ # Search on MINUS side
+ pointMinus = fcenter - epsilon * fnormal
+ cellIdMinus = locator.FindCell(pointMinus)
+ if cellIdMinus >= 0:
+ minusCells.append(cellIdMinus)
+
+ mapping[fid] = {"plus": plusCells, "minus": minusCells}
+
+ # Statistics
+ nNeighbors = len(plusCells) + len(minusCells)
+ totalNeighbors += nNeighbors
+
+ if len(plusCells) > 0 and len(minusCells) > 0:
+ nFoundBoth += 1
+ elif len(plusCells) > 0 or len(minusCells) > 0:
+ nFoundOne += 1
+ else:
+ nFoundNone += 1
+
+ nCells = faultSurface.n_cells
+ avgNeighbors = totalNeighbors / nCells if nCells > 0 else 0
+
+ stats = {
+ 'nBoth': nFoundBoth,
+ 'nOne': nFoundOne,
+ 'nNone': nFoundNone,
+ 'pctBoth': nFoundBoth / nCells * 100,
+ 'pctOne': nFoundOne / nCells * 100,
+ 'pctNone': nFoundNone / nCells * 100,
+ 'avgNeighbors': avgNeighbors
+ }
+
+ return mapping, stats
+
+ # -------------------------------------------------------------------
+ def _visualizeContributions(self):
+ """
+ Unified visualization of volume contributions to fault surfaces
+ 4-panel view combining full context, side classification, clip, and slice
+ """
+
+
+ print("\n📊 Creating contribution visualization...")
+
+ # Create plotter with 4 subplots
+ plotter = pv.Plotter(shape=(2, 2), window_size=[1800, 1400])
+
+ # ========== PLOT 1: Full context (top-left) ==========
+ plotter.subplot(0, 0)
+ plotter.add_text("Full Context - Volume & Fault", font_size=14, position='upper_edge')
+
+ # All volume (transparent)
+ plotter.add_mesh(self.mesh, color='lightgray', opacity=0.05,
+ show_edges=False, label='Volume')
+
+ # Fault surface (red)
+ plotter.add_mesh(self.faultSurface, color='red', opacity=1,
+ show_edges=True, label='Fault Surface')
+
+ plotter.add_legend(loc="upper left")
+ plotter.add_axes()
+ plotter.set_scale(zscale=self.config.Z_SCALE)
+
+ # ========== PLOT 2: Contributing cells by side (top-right) ==========
+ plotter.subplot(0, 1)
+ plotter.add_text("Contributing Cells",
+ font_size=14, position='upper_edge')
+
+ if 'contributionSide' in self.volumeMesh.cell_data:
+ # Plus side (blue)
+ if self.contributingCellsPlus.n_cells > 0:
+ plotter.add_mesh(self.contributingCellsPlus, color='dodgerblue',
+ opacity=1.0, show_edges=True,
+ label=f'Plus side ({self.contributingCellsPlus.n_cells} cells)')
+
+ # Minus side (orange)
+ if self.contributingCellsMinus.n_cells > 0:
+ plotter.add_mesh(self.contributingCellsMinus, color='darkorange',
+ opacity=1.0, show_edges=True,
+ label=f'Minus side ({self.contributingCellsMinus.n_cells} cells)')
+
+ # Fault surface for reference
+ plotter.add_mesh(self.faultSurface, color='red', opacity=1.0,
+ show_edges=True, label='Fault')
+
+ plotter.add_legend(loc='upper right')
+ plotter.add_axes()
+ plotter.set_scale(zscale=self.config.Z_SCALE)
+
+ # ========== PLOT 3: Clipped view (bottom-left) ==========
+ plotter.subplot(1, 0)
+ plotter.add_text("Clipped View - Contributing Cells",
+ font_size=14, position='upper_edge')
+
+ # Determine clip position (middle of fault)
+ bounds = self.faultSurface.bounds
+ clip_normal = [0, 0, -1] # Clip along Z axis
+ clip_origin = [0,0, (bounds[4] + bounds[5]) / 2]
+
+ # Clip and show contributing cells
+ if self.contributingCells.n_cells > 0:
+ plotter.add_mesh_clip_plane(
+ self.contributingCells,
+ normal=clip_normal,
+ origin=clip_origin,
+ color='blue',
+ opacity=1,
+ show_edges=True,
+ label='Contributing (clipped)'
+ )
+
+ # Fault surface
+ plotter.add_mesh(self.faultSurface, color='red', opacity=1.0,
+ show_edges=True, label='Fault')
+
+ plotter.add_legend(loc='upper left')
+ plotter.add_axes()
+ plotter.set_scale(zscale=self.config.Z_SCALE)
+
+ # ========== PLOT 4: Slice view (bottom-right) ==========
+ plotter.subplot(1, 1)
+
+ # Determine slice position (middle of fault in Z)
+ slice_position = (bounds[4] + bounds[5]) / 2
+ plotter.add_text(f"Slice View at Z={slice_position:.1f}m",
+ font_size=14, position='upper_edge')
+
+ # Create slice of volume
+ sliceVol = self.volumeMesh.slice(normal='z', origin=[0, 0, slice_position])
+ sliceFault = self.faultSurface.slice(normal='z', origin=[0, 0, slice_position])
+
+ # Show contributing vs non-contributing in slice
+ if 'contributionSide' in sliceVol.cell_data:
+ # Non-contributing cells (gray)
+ nonContribMask = sliceVol.cell_data['contributionSide'] == 0
+ if np.sum(nonContribMask) > 0:
+ nonContrib = sliceVol.extract_cells(nonContribMask)
+ plotter.add_mesh(nonContrib, color='lightgray', opacity=0.15,
+ show_edges=True, line_width=1, label='Non-contributing')
+
+ # Plus side (blue)
+ plusMask = (sliceVol.cell_data['contributionSide'] == 1) | \
+ (sliceVol.cell_data['contributionSide'] == 3)
+ if np.sum(plusMask) > 0:
+ plusCells = sliceVol.extract_cells(plusMask)
+ plotter.add_mesh(plusCells, color='dodgerblue', opacity=0.7,
+ show_edges=True, line_width=2, label='Plus side')
+
+ # Minus side (orange)
+ minusMask = (sliceVol.cell_data['contributionSide'] == 2) | \
+ (sliceVol.cell_data['contributionSide'] == 3)
+ if np.sum(minusMask) > 0:
+ minusCells = sliceVol.extract_cells(minusMask)
+ plotter.add_mesh(minusCells, color='darkorange', opacity=0.7,
+ show_edges=True, line_width=2, label='Minus side')
+
+ # Fault slice (thick red line)
+ if sliceFault.n_cells > 0:
+ plotter.add_mesh(sliceFault, color='red', line_width=6,
+ label='Fault', render_lines_as_tubes=True)
+
+ plotter.add_legend(loc='upper right')
+ plotter.add_axes()
+ plotter.set_scale(zscale=self.config.Z_SCALE)
+ plotter.view_xy()
+
+ # Link all views for synchronized rotation
+ plotter.link_views()
+
+ # Show or save
+ if self.config.SHOW_PLOTS:
+ plotter.show()
+ else:
+ # Save screenshot
+
+ outputDir = Path(self.config.OUTPUT_DIR) if hasattr(self.config, 'OUTPUT_DIR') else Path('.')
+ outputDir.mkdir(parents=True, exist_ok=True)
+ screenshot_path = outputDir / "contribution_visualization.png"
+ plotter.screenshot(str(screenshot_path))
+ print(f" 💾 Visualization saved: {screenshot_path}")
+ plotter.close()
+
+ # -------------------------------------------------------------------
+ # NORMALS
+ # -------------------------------------------------------------------
+ def _extractAndComputeNormals(self, showPlot=False, scaleFactor=50.0, zScale=1.0):
+ """Extract fault surfaces and compute oriented normals/tangents"""
+ surfaces = []
+
+ for faultId in self.faultValues:
+ # Extract fault cells
+ faultMask = self.mesh.cell_data[self.faultAttribute] == faultId
+ faultCells = self.mesh.extract_cells(faultMask)
+
+ if faultCells.n_cells == 0:
+ print(f"⚠️ No cells for fault {faultId}")
+ continue
+
+ # Extract surface
+ surf = faultCells.extract_surface()
+ if surf.n_cells == 0:
+ continue
+
+ # Compute normals
+ surf.compute_normals(cell_normals=True, point_normals=True, inplace=True)
+
+ # Orient normals consistently within the fault
+ surf = self._orientNormals(surf)
+
+ surfaces.append(surf)
+
+ merged = pv.MultiBlock(surfaces).combine()
+ print(f"✅ Normals computed for {merged.n_cells} fault cells")
+
+ if showPlot:
+ self.plotGeometry(merged, scaleFactor, zScale)
+
+ return merged, surfaces
+
+ # -------------------------------------------------------------------
+ def _orientNormals(self, surf):
+ """Ensure normals point in consistent direction within the fault"""
+ normals = surf.cell_data['Normals']
+ meanNormal = np.mean(normals, axis=0)
+ meanNormal /= np.linalg.norm(meanNormal)
+
+ nCells = len(normals)
+ tangents1 = np.zeros((nCells, 3))
+ tangents2 = np.zeros((nCells, 3))
+
+ for i, normal in enumerate(normals):
+
+ # Flip if pointing opposite to mean
+ if np.dot(normal, meanNormal) < 0:
+ normals[i] = -normal
+
+ if self.config.ROTATE_NORMALS:
+ normals[i] = -normal
+
+ # Compute orthogonal tangents
+ normal = normals[i]
+ if abs(normal[0]) > 1e-6 or abs(normal[1]) > 1e-6:
+ t1 = np.array([-normal[1], normal[0], 0])
+ else:
+ t1 = np.array([0, -normal[2], normal[1]])
+
+ t1 /= np.linalg.norm(t1)
+ t2 = np.cross(normal, t1)
+ t2 /= np.linalg.norm(t2)
+
+ tangents1[i] = t1
+ tangents2[i] = t2
+
+ surf.cell_data['Normals'] = normals
+ surf.cell_data['tangent1'] = tangents1
+ surf.cell_data['tangent2'] = tangents2
+
+ dip_angles, strike_angles = self.computeDipStrikeFromCellBase(normals, tangents1, tangents2)
+
+ surf.cell_data['dipAngle'] = dip_angles
+ surf.cell_data['strikeAngle'] = strike_angles
+
+ return surf
+
+ # -------------------------------------------------------------------
+ def computeDipStrikeFromCellBase(self, normals, tangent1, tangent2):
+ """
+ Calcule les angles dip et strike à partir des vecteurs normaux et tangents des cellules.
+ Hypothèses :
+ - Système de coordonnées : X=Est, Y=Nord, Z=Haut.
+ - Vecteurs donnés par cellule (shape: (nCells, 3)).
+ - Les vecteurs d'entrée sont supposés orthonormés (n = t1 x t2).
+
+ Retourne :
+ dipDeg, strikeDeg (two arrays of shape (nCells,))
+ """
+ # 1. Identifier le vecteur strike (le plus horizontal)
+ t1Horizontal = tangent1 - (tangent1[:, 2][:, np.newaxis] * np.array([0, 0, 1]))
+ t2Horizontal = tangent2 - (tangent2[:, 2][:, np.newaxis] * np.array([0, 0, 1]))
+ normT1Horizontal = np.linalg.norm(t1Horizontal, axis=1)
+ normT2Horizontal = np.linalg.norm(t2Horizontal, axis=1)
+
+ useT1 = normT1Horizontal > normT2Horizontal
+ strikeVector = np.zeros_like(tangent1)
+ strikeVector[useT1] = t1Horizontal[useT1]
+ strikeVector[~useT1] = t2Horizontal[~useT1]
+
+ # Normaliser
+ strikeNorm = np.linalg.norm(strikeVector, axis=1)
+ # Éviter la division par zéro (si la faille est parfaitement verticale, le strike est bien défini par l'autre vecteur)
+ strikeNorm[strikeNorm == 0] = 1.0
+ strikeVector = strikeVector / strikeNorm[:, np.newaxis]
+
+ # 2. Calculer le strike (azimut depuis le Nord, sens horaire)
+ strikeRad = np.arctan2(strikeVector[:, 0], strikeVector[:, 1]) # atan2(E, N)
+ strikeDeg = np.degrees(strikeRad)
+ strikeDeg = np.where(strikeDeg < 0, strikeDeg + 360, strikeDeg)
+
+ # 3. Calculer le dip
+ normHorizontal = np.linalg.norm(normals[:, :2], axis=1)
+ dipRad = np.arcsin(np.clip(normHorizontal, 0, 1)) # clip pour éviter les erreurs d'arrondi
+ dipDeg = np.degrees(dipRad)
+
+ return dipDeg, strikeDeg
+
+ # -------------------------------------------------------------------
+ def plotGeometry(self, surface, scaleFactor, zScale):
+ """Visualize fault geometry with normals"""
+ plotter = pv.Plotter()
+ plotter.add_mesh(self.mesh, color='lightgray', opacity=0.1, label='Volume')
+ plotter.add_mesh(surface, color='darkgray', opacity=0.7, show_edges=True, label='Fault')
+
+ centers = surface.cell_centers()
+ for name, color in [('Normals', 'red'), ('tangent1', 'green'), ('tangent2', 'blue')]:
+ arrows = centers.glyph(orient=name, scale=zScale, factor=scaleFactor)
+ plotter.add_mesh(arrows, color=color, label=name)
+
+ plotter.add_legend()
+ plotter.add_axes()
+ plotter.set_scale(zscale=zScale)
+ plotter.show()
+
+ # -------------------------------------------------------------------
+ def diagnoseNormals(self, scaleFactor=50.0, zScale=1.0):
+ """
+ Diagnostic visualization to check normal quality
+ Shows orthogonality and orientation issues
+ """
+ surface = self.faultSurface
+
+ print("\n🔍 DIAGNOSTIC DES NORMALES")
+ print("=" * 60)
+
+ normals = surface.cell_data['Normals']
+ tangent1 = surface.cell_data['tangent1']
+ tangent2 = surface.cell_data['tangent2']
+
+ nCells = len(normals)
+
+ # Check orthogonality
+ dotNormT1 = np.array([np.dot(normals[i], tangent1[i]) for i in range(nCells)])
+ dotNormT2 = np.array([np.dot(normals[i], tangent2[i]) for i in range(nCells)])
+ dotT1T2 = np.array([np.dot(tangent1[i], tangent2[i]) for i in range(nCells)])
+
+ print(f"Orthogonalité (doit être proche de 0):")
+ print(f" Normal · Tangent1 : max={np.max(np.abs(dotNormT1)):.2e}, mean={np.mean(np.abs(dotNormT1)):.2e}")
+ print(f" Normal · Tangent2 : max={np.max(np.abs(dotNormT2)):.2e}, mean={np.mean(np.abs(dotNormT2)):.2e}")
+ print(f" Tangent1 · Tangent2: max={np.max(np.abs(dotT1T2)):.2e}, mean={np.mean(np.abs(dotT1T2)):.2e}")
+
+ # Check unit vectors
+ normN = np.linalg.norm(normals, axis=1)
+ normT1 = np.linalg.norm(tangent1, axis=1)
+ normT2 = np.linalg.norm(tangent2, axis=1)
+
+ print(f"\nNormes (doit être proche de 1):")
+ print(f" Normals : min={np.min(normN):.6f}, max={np.max(normN):.6f}")
+ print(f" Tangent1 : min={np.min(normT1):.6f}, max={np.max(normT1):.6f}")
+ print(f" Tangent2 : min={np.min(normT2):.6f}, max={np.max(normT2):.6f}")
+
+ # Check orientation consistency
+ meanNormal = np.mean(normals, axis=0)
+ meanNormal = meanNormal / np.linalg.norm(meanNormal)
+
+ dotsWithMean = np.array([np.dot(normals[i], meanNormal) for i in range(nCells)])
+ nReversed = np.sum(dotsWithMean < 0)
+
+ print(f"\nCohérence d'orientation:")
+ print(f" Normale moyenne: [{meanNormal[0]:.3f}, {meanNormal[1]:.3f}, {meanNormal[2]:.3f}]")
+ print(f" Normales inversées: {nReversed}/{nCells} ({nReversed/nCells*100:.1f}%)")
+
+ if nReversed > nCells * 0.1:
+ print(f" ⚠️ Plus de 10% des normales pointent dans la direction opposée!")
+ else:
+ print(f" ✅ Orientation cohérente")
+
+ print("=" * 60)
+
+ # Visualization
+ plotter = pv.Plotter(shape=(1, 2))
+
+ # Plot 1: Surface with normals
+ plotter.subplot(0, 0)
+ plotter.add_mesh(surface, color='lightgray', show_edges=True, opacity=0.8)
+
+ centers = surface.cell_centers()
+ arrowsNorm = centers.glyph(orient='Normals', scale=False, factor=scaleFactor)
+ plotter.add_mesh(arrowsNorm, color='red', label='Normals')
+
+ plotter.add_legend()
+ plotter.add_axes()
+ plotter.add_text("Normales (Rouge)", position='upper_edge')
+ plotter.set_scale(zscale=zScale)
+
+ # Plot 2: All vectors
+ plotter.subplot(0, 1)
+ plotter.add_mesh(surface, color='lightgray', show_edges=True, opacity=0.5)
+
+ arrowsNorm = centers.glyph(orient='Normals', scale=False, factor=scaleFactor)
+ arrowsT1 = centers.glyph(orient='tangent1', scale=False, factor=scaleFactor)
+ arrowsT2 = centers.glyph(orient='tangent2', scale=False, factor=scaleFactor)
+
+ plotter.add_mesh(arrowsNorm, color='red', label='Normal')
+ plotter.add_mesh(arrowsT1, color='green', label='Tangent1')
+ plotter.add_mesh(arrowsT2, color='blue', label='Tangent2')
+
+ plotter.add_legend()
+ plotter.add_axes()
+ plotter.add_text("Système complet (R,G,B)", position='upper_edge')
+ plotter.set_scale(zscale=zScale)
+
+ plotter.link_views()
+ plotter.show()
+
+ return surface
+
+
+ """
+ Diagnostic visualization to check normal quality
+ Shows orthogonality and orientation issues
+ """
+ print("\n🔍 DIAGNOSTIC DES NORMALES")
+ print("=" * 60)
+
+ normals = surface.cell_data['Normals']
+ tangent1 = surface.cell_data['tangent1']
+ tangent2 = surface.cell_data['tangent2']
+
+ nCells = len(normals)
+
+ # Check orthogonality
+ dotNormT1 = np.array([np.dot(normals[i], tangent1[i]) for i in range(nCells)])
+ dotNormT2 = np.array([np.dot(normals[i], tangent2[i]) for i in range(nCells)])
+ dotT1T2 = np.array([np.dot(tangent1[i], tangent2[i]) for i in range(nCells)])
+
+ print(f"Orthogonalité (doit être proche de 0):")
+ print(f" Normal · Tangent1 : max={np.max(np.abs(dotNormT1)):.2e}, mean={np.mean(np.abs(dotNormT1)):.2e}")
+ print(f" Normal · Tangent2 : max={np.max(np.abs(dotNormT2)):.2e}, mean={np.mean(np.abs(dotNormT2)):.2e}")
+ print(f" Tangent1 · Tangent2: max={np.max(np.abs(dotT1T2)):.2e}, mean={np.mean(np.abs(dotT1T2)):.2e}")
+
+ # Check unit vectors
+ normN = np.array([np.linalg.norm(normals[i]) for i in range(nCells)])
+ normT1 = np.array([np.linalg.norm(tangent1[i]) for i in range(nCells)])
+ normT2 = np.array([np.linalg.norm(tangent2[i]) for i in range(nCells)])
+
+ print(f"\nNormes (doit être proche de 1):")
+ print(f" Normals : min={np.min(normN):.6f}, max={np.max(normN):.6f}")
+ print(f" Tangent1 : min={np.min(normT1):.6f}, max={np.max(normT1):.6f}")
+ print(f" Tangent2 : min={np.min(normT2):.6f}, max={np.max(normT2):.6f}")
+
+ # Check orientation consistency
+ meanNormal = np.mean(normals, axis=0)
+ meanNormal = meanNormal / np.linalg.norm(meanNormal)
+
+ dotsWithMean = np.array([np.dot(normals[i], meanNormal) for i in range(nCells)])
+ nReversed = np.sum(dotsWithMean < 0)
+
+ print(f"\nCohérence d'orientation:")
+ print(f" Normale moyenne: [{meanNormal[0]:.3f}, {meanNormal[1]:.3f}, {meanNormal[2]:.3f}]")
+ print(f" Normales inversées: {nReversed}/{nCells} ({nReversed/nCells*100:.1f}%)")
+
+ # Visual check
+ if nReversed > nCells * 0.1:
+ print(f" ⚠️ Plus de 10% des normales pointent dans la direction opposée!")
+ else:
+ print(f" ✅ Orientation cohérente")
+
+ # Check for problematic cells
+ badOrtho = (np.abs(dotNormT1) > 1e-3) | (np.abs(dotNormT2) > 1e-3) | (np.abs(dotT1T2) > 1e-3)
+ nBad = np.sum(badOrtho)
+
+ if nBad > 0:
+ print(f"\n⚠️ {nBad} cellules avec orthogonalité douteuse (|dot| > 1e-3)")
+ surface.cell_data['orthogonality_error'] = np.maximum.reduce([
+ np.abs(dotNormT1), np.abs(dotNormT2), np.abs(dotT1T2)
+ ])
+ else:
+ print(f"\n✅ Toutes les cellules ont une bonne orthogonalité")
+
+ print("=" * 60)
+
+ # Visualization
+ plotter = pv.Plotter(shape=(1, 2))
+
+ # Plot 1: Surface with normals
+ plotter.subplot(0, 0)
+ plotter.add_mesh(surface, color='lightgray', show_edges=True, opacity=0.8)
+
+ centers = surface.cell_centers()
+ arrowsNorm = centers.glyph(orient='Normals', scale=False, factor=scaleFactor)
+ plotter.add_mesh(arrowsNorm, color='red', label='Normals')
+
+ plotter.add_legend()
+ plotter.add_axes()
+ plotter.add_text("Normales (Rouge)", position='upper_edge')
+ plotter.set_scale(zscale=zScale)
+
+ # Plot 2: All vectors (normal + tangents)
+ plotter.subplot(0, 1)
+ plotter.add_mesh(surface, color='lightgray', show_edges=True, opacity=0.5)
+
+ arrowsNorm = centers.glyph(orient='Normals', scale=False, factor=scaleFactor)
+ arrowsT1 = centers.glyph(orient='tangent1', scale=False, factor=scaleFactor)
+ arrowsT2 = centers.glyph(orient='tangent2', scale=False, factor=scaleFactor)
+
+ plotter.add_mesh(arrowsNorm, color='red', label='Normal')
+ plotter.add_mesh(arrowsT1, color='green', label='Tangent1')
+ plotter.add_mesh(arrowsT2, color='blue', label='Tangent2')
+
+ plotter.add_legend()
+ plotter.add_axes()
+ plotter.add_text("Système complet (R,G,B)", position='upper_edge')
+ plotter.set_scale(zscale=zScale)
+
+ plotter.link_views()
+ plotter.show()
+
+ return surface
+
diff --git a/geos-processing/src/geos/processing/post_processing/FaultStabilityAnalysis.py b/geos-processing/src/geos/processing/post_processing/FaultStabilityAnalysis.py
index a8e2476e..1fcbc5f0 100755
--- a/geos-processing/src/geos/processing/post_processing/FaultStabilityAnalysis.py
+++ b/geos-processing/src/geos/processing/post_processing/FaultStabilityAnalysis.py
@@ -12,7 +12,6 @@
import math
-
# ============================================================================
# CONFIGURATION
# ============================================================================
@@ -76,4283 +75,317 @@ class Config:
# ============================================================================
-# STRESS TENSOR OPERATIONS
+# MOHR COULOMB
# ============================================================================
-class StressTensor:
- """Utility class for stress tensor operations"""
-
- @staticmethod
- def build_from_array(arr):
- """Convert stress array to 3x3 tensor format"""
- n = arr.shape[0]
- tensors = np.zeros((n, 3, 3))
-
- if arr.shape[1] == 6: # Voigt notation
- tensors[:, 0, 0] = arr[:, 0] # Sxx
- tensors[:, 1, 1] = arr[:, 1] # Syy
- tensors[:, 2, 2] = arr[:, 2] # Szz
- tensors[:, 1, 2] = tensors[:, 2, 1] = arr[:, 3] # Syz
- tensors[:, 0, 2] = tensors[:, 2, 0] = arr[:, 4] # Sxz
- tensors[:, 0, 1] = tensors[:, 1, 0] = arr[:, 5] # Sxy
- elif arr.shape[1] == 9:
- tensors = arr.reshape((-1, 3, 3))
- else:
- raise ValueError(f"Unsupported stress shape: {arr.shape}")
-
- return tensors
+class MohrCoulomb:
+ """Mohr-Coulomb failure criterion analysis"""
@staticmethod
- def rotate_to_fault_frame(stress_tensor, normal, tangent1, tangent2):
- """Rotate stress tensor to fault local coordinate system"""
- # Verify orthonormality
- assert np.abs(np.linalg.norm(tangent1) - 1.0) < 1e-10
- assert np.abs(np.linalg.norm(tangent2) - 1.0) < 1e-10
- assert np.abs(np.dot(normal, tangent1)) < 1e-10
- assert np.abs(np.dot(normal, tangent2)) < 1e-10
-
- # Rotation matrix: columns = local directions (n, t1, t2)
- R = np.column_stack((normal, tangent1, tangent2))
-
- # Rotate tensor
- stress_local = R.T @ stress_tensor @ R
-
- # Traction on fault plane (normal = [1,0,0] in local frame) # TODO is it aways that way ?
- traction_local = stress_local @ np.array([1.0, 0.0, 0.0])
-
- return {
- 'stress_local': stress_local,
- 'normal_stress': traction_local[0],
- 'shear_stress': np.sqrt(traction_local[1]**2 + traction_local[2]**2),
- 'shear_strike': traction_local[1],
- 'shear_dip': traction_local[2]
- }
-
-# ============================================================================
-# FAULT GEOMETRY
-# ============================================================================
-class FaultGeometry:
-
- """Handles fault surface extraction and normal computation with optimizations"""
-
- # -------------------------------------------------------------------
- def __init__(self, config, mesh, fault_values, fault_attribute, volume_mesh):
- """
- Initialize fault geometry with pre-computed topology.
-
- Args:
- config (Config):
- mesh (): pv.read(path / config.GRID_FILE) -> "mesh_faulted_reservoir_60_mod.vtu"
- fault_values (list[int]): Config.FAULT_VALUES
- fault_attribute (str): Config.FAULT_ATTRIBUTES
- volume_mesh (): processor._merge_blocks(dataset)
- """
- self.mesh = mesh
- self.fault_values = fault_values
- self.fault_attribute = fault_attribute
- self.volume_mesh = volume_mesh
-
- # These will be computed once
- self.fault_surface = None
- self.surfaces = None
- self.adjacency_mapping = None
- self.contributing_cells = None
- self.contributing_cells_plus = None
- self.contributing_cells_minus = None
-
- # NEW: Pre-computed geometric properties
- self.volume_cell_volumes = None # Volume of each cell
- self.volume_centers = None # Center coordinates
- self.distance_to_fault = None # Distance from each volume cell to nearest fault
- self.fault_tree = None # KDTree for fault surface
-
- # Config
- self.config = config
-
- # -------------------------------------------------------------------
- def initialize(self, scale_factor=50.0, process_faults_separately=True):
- """
- One-time initialization: compute normals, adjacency topology, and geometric properties
- """
-
- # Extract and compute normals
- self.fault_surface, self.surfaces = self._extract_and_compute_normals(
- show_plot=self.config.SHOW_NORMAL_PLOTS,
- scale_factor=scale_factor,
- z_scale=self.config.Z_SCALE)
-
- # Pre-compute adjacency mapping
- print("\n🔍 Pre-computing volume-fault adjacency topology")
- print(" Method: Face-sharing (adaptive epsilon)")
-
- self.adjacency_mapping = self._build_adjacency_mapping_face_sharing(
- process_faults_separately=process_faults_separately)
-
- # Mark and optionally save contributing cells
- self._mark_contributing_cells()
-
- # NEW: Pre-compute geometric properties
- self._precompute_geometric_properties()
-
- n_mapped = len(self.adjacency_mapping)
- n_with_both = sum(1 for m in self.adjacency_mapping.values()
- if len(m['plus']) > 0 and len(m['minus']) > 0)
-
- print("\n✅ Adjacency topology computed:")
- print(f" - {n_mapped}/{self.fault_surface.n_cells} fault cells mapped")
- print(f" - {n_with_both} cells have neighbors on both sides")
-
- # Visualize contributions if requested
- if self.config.SHOW_CONTRIBUTION_VIZ:
- self._visualize_contributions()
-
- return self.fault_surface, self.adjacency_mapping
-
- # -------------------------------------------------------------------
- def _mark_contributing_cells(self):
- """
- Mark volume cells that contribute to fault stress projection
- """
- print("\n📦 Marking contributing volume cells...")
-
- n_volume = self.volume_mesh.n_cells
-
- # Collect contributing cells by side
- all_plus = set()
- all_minus = set()
-
- for fault_idx, neighbors in self.adjacency_mapping.items():
- all_plus.update(neighbors['plus'])
- all_minus.update(neighbors['minus'])
-
- # Create classification array
- contribution_side = np.zeros(n_volume, dtype=int)
-
- for idx in all_plus:
- if 0 <= idx < n_volume:
- contribution_side[idx] += 1
-
- for idx in all_minus:
- if 0 <= idx < n_volume:
- contribution_side[idx] += 2
-
- # Add classification to volume mesh
- self.volume_mesh.cell_data["contribution_side"] = contribution_side
- contrib_mask = contribution_side > 0
- self.volume_mesh.cell_data["contribution_to_faults"] = contrib_mask.astype(int)
-
- # Extract subsets
- mask_all = contrib_mask
- mask_plus = (contribution_side == 1) | (contribution_side == 3)
- mask_minus = (contribution_side == 2) | (contribution_side == 3)
-
- self.contributing_cells = self.volume_mesh.extract_cells(mask_all)
- self.contributing_cells_plus = self.volume_mesh.extract_cells(mask_plus)
- self.contributing_cells_minus = self.volume_mesh.extract_cells(mask_minus)
-
- # Statistics
- n_contrib = np.sum(mask_all)
- n_plus = np.sum(contribution_side == 1)
- n_minus = np.sum(contribution_side == 2)
- n_both = np.sum(contribution_side == 3)
- pct_contrib = n_contrib / n_volume * 100
-
- print(f" ✅ Total contributing: {n_contrib}/{n_volume} ({pct_contrib:.1f}%)")
- print(f" Plus side only: {n_plus} cells")
- print(f" Minus side only: {n_minus} cells")
- print(f" Both sides: {n_both} cells")
-
- # Save to files if requested
- if self.config.SAVE_CONTRIBUTION_CELLS:
- self._save_contributing_cells()
-
- # -------------------------------------------------------------------
- def _save_contributing_cells(self):
- """
- Save contributing volume cells to VTU files
- Saves three files: all, plus side, minus side
- """
- from pathlib import Path
-
- # Create output directory if it doesn't exist
- output_dir = Path(self.config.OUTPUT_DIR) if hasattr(self.config, 'OUTPUT_DIR') else Path('.')
- output_dir.mkdir(parents=True, exist_ok=True)
-
- # Save all contributing cells
- filename_all = output_dir / "contributing_cells_all.vtu"
- self.contributing_cells.save(str(filename_all))
- print(f"\n 💾 All contributing cells saved: {filename_all}")
- print(f" ({self.contributing_cells.n_cells} cells, {self.contributing_cells.n_points} points)")
-
- # Save plus side
- filename_plus = output_dir / "contributing_cells_plus.vtu"
- # self.contributing_cells_plus.save(str(filename_plus))
- # print(f" 💾 Plus side cells saved: {filename_plus}")
- print(f" ({self.contributing_cells_plus.n_cells} cells, {self.contributing_cells_plus.n_points} points)")
-
- # Save minus side
- filename_minus = output_dir / "contributing_cells_minus.vtu"
- # self.contributing_cells_minus.save(str(filename_minus))
- # print(f" 💾 Minus side cells saved: {filename_minus}")
- print(f" ({self.contributing_cells_minus.n_cells} cells, {self.contributing_cells_minus.n_points} points)")
-
- # -------------------------------------------------------------------
- def get_contributing_cells(self, side='all'):
+ def analyze(surface, cohesion, frictionAngleDeg, time=0, verbose=True):
"""
- Get the extracted contributing cells
+ Perform Mohr-Coulomb stability analysis
Parameters:
- side: 'all', 'plus', or 'minus'
-
- Returns:
- pyvista.UnstructuredGrid: Contributing volume cells
- """
- if self.contributing_cells is None:
- raise ValueError("Contributing cells not yet computed. Call initialize() first.")
-
- if side == 'all':
- return self.contributing_cells
- elif side == 'plus':
- return self.contributing_cells_plus
- elif side == 'minus':
- return self.contributing_cells_minus
- else:
- raise ValueError(f"Invalid side '{side}'. Must be 'all', 'plus', or 'minus'.")
-
- # -------------------------------------------------------------------
- def get_geometric_properties(self):
- """
- Get pre-computed geometric properties
-
- Returns
- -------
- dict with keys:
- - 'volumes': ndarray of cell volumes
- - 'centers': ndarray of cell centers (n_cells, 3)
- - 'distances': ndarray of distances to nearest fault cell
- - 'fault_tree': KDTree for fault surface
- """
- if self.volume_cell_volumes is None:
- raise ValueError("Geometric properties not computed. Call initialize() first.")
-
- return {
- 'volumes': self.volume_cell_volumes,
- 'centers': self.volume_centers,
- 'distances': self.distance_to_fault,
- 'fault_tree': self.fault_tree
- }
-
- # -------------------------------------------------------------------
- def _precompute_geometric_properties(self):
- """
- Pre-compute geometric properties of volume mesh for efficient stress projection
-
- Computes:
- - Cell volumes (for volume-weighted averaging)
- - Cell centers (for distance calculations)
- - Distance from each volume cell to nearest fault cell
- - KDTree for fault surface
+ surface: fault surface with stress data
+ cohesion: cohesion in bar
+ frictionAngleDeg: friction angle in degrees
+ time: simulation time
+ verbose: print statistics
"""
- print("\n📐 Pre-computing geometric properties...")
-
- n_volume = self.volume_mesh.n_cells
-
- # 1. Compute volume centers
- print(" Computing cell centers...")
- self.volume_centers = self.volume_mesh.cell_centers().points
-
- # 2. Compute cell volumes
- print(" Computing cell volumes...")
- volume_with_sizes = self.volume_mesh.compute_cell_sizes(
- length=False, area=False, volume=True
- )
- self.volume_cell_volumes = volume_with_sizes.cell_data['Volume']
-
- print(f" Volume range: [{np.min(self.volume_cell_volumes):.1e}, "
- f"{np.max(self.volume_cell_volumes):.1e}] m³")
+ mu = np.tan(np.radians(frictionAngleDeg))
- # 3. Build KDTree for fault surface (for fast distance queries)
- print(" Building KDTree for fault surface...")
- from scipy.spatial import cKDTree
+ # Extract stress components
+ sigmaN = surface.cell_data["sigmaNEffective"]
+ tau = surface.cell_data["tauEffective"]
+ deltaSigmaN = surface.cell_data['deltaSigmaNEffective']
+ deltaTau = surface.cell_data['deltaTauEffective']
- fault_centers = self.fault_surface.cell_centers().points
- self.fault_tree = cKDTree(fault_centers)
+ # Mohr-Coulomb failure envelope
+ tauCritical = cohesion - sigmaN * mu
- # 4. Compute distance from each volume cell to nearest fault cell
- print(" Computing distances to fault...")
- self.distance_to_fault = np.zeros(n_volume)
+ # Coulomb Failure Stress
+ CFS = tau - mu * sigmaN
+ # deltaCFS = deltaTau - mu * deltaSigmaN
- # Vectorized query for all points at once (much faster)
- distances, _ = self.fault_tree.query(self.volume_centers)
- self.distance_to_fault = distances
+ # Shear Capacity Utilization: SCU = τ / τ_crit
+ SCU = np.divide(tau, tauCritical, out=np.zeros_like(tau), where=tauCritical != 0)
- print(f" Distance range: [{np.min(self.distance_to_fault):.1f}, "
- f"{np.max(self.distance_to_fault):.1f}] m")
+ if "SCUInitial" not in surface.cell_data:
+ # First timestep: store as initial reference
+ SCUInitial = SCU.copy()
+ CFSInitial = CFS.copy()
+ deltaSCU = np.zeros_like(SCU)
+ deltaCFS = np.zeros_like(CFS)
- # 5. Add these properties to volume mesh for reference
- self.volume_mesh.cell_data['cell_volume'] = self.volume_cell_volumes
- self.volume_mesh.cell_data['distance_to_fault'] = self.distance_to_fault
+ surface.cell_data["SCUInitial"] = SCUInitial
+ surface.cell_data["CFSInitial"] = CFSInitial
- print(" ✅ Geometric properties computed and cached")
+ isInitial = True
+ else:
+ # Subsequent timesteps: calculate change from initial
+ SCUInitial = surface.cell_data["SCUInitial"]
+ CFSInitial = surface.cell_data['CFSInitial']
+ deltaSCU = SCU - SCUInitial
+ deltaCFS = CFS - CFSInitial
+ isInitial = False
- # -------------------------------------------------------------------
- def _build_adjacency_mapping_face_sharing(self, process_faults_separately=True):
- """
- Build adjacency for cells sharing faces with fault
- Uses adaptive epsilon optimization
- """
+ # Stability classification
+ stability = np.zeros_like(tau, dtype=int)
+ stability[SCU >= 0.8] = 1 # Critical
+ stability[SCU >= 1.0] = 2 # Unstable
- fault_ids = np.unique(self.fault_surface.cell_data[self.fault_attribute])
- n_faults = len(fault_ids)
- print(f" 📋 Processing {n_faults} separate faults: {fault_ids}")
+ # Failure probability (sigmoid)
+ k = 10.0
+ failureProba = 1.0 / (1.0 + np.exp(-k * (SCU - 1.0)))
- all_mappings = {}
+ # Safety margin
+ safety = tauCritical - tau
- for fault_id in fault_ids:
- mask = self.fault_surface.cell_data[self.fault_attribute] == fault_id
- indices = np.where(mask)[0]
- single_fault = self.fault_surface.extract_cells(indices)
+ # Store results
+ surface.cell_data.update({
+ "mohrCohesion": np.full(surface.n_cells, cohesion),
+ "mohrFrictionAngle": np.full(surface.n_cells, frictionAngleDeg),
+ "mohrFrictionCoefficient": np.full(surface.n_cells, mu),
+ "mohr_critical_shear_stress": tauCritical,
+ "SCU": SCU,
+ "deltaSCU": deltaSCU,
+ "CFS" : CFS,
+ "deltaCFS": deltaCFS,
+ "safetyMargin": safety,
+ "stabilityState": stability,
+ "failureProbability": failureProba
+ })
- print(f" 🔧 Mapping Fault {fault_id}...")
+ if verbose:
+ nStable = np.sum(stability == 0)
+ nCritical = np.sum(stability == 1)
+ nUnstable = np.sum(stability == 2)
+
+ # Additional info on deltaSCU
+ if not isInitial:
+ meanDelta = np.mean(np.abs(deltaSCU))
+ maxIncrease = np.max(deltaSCU)
+ maxDecrease = np.min(deltaSCU)
+ print(f" ✅ Mohr-Coulomb: {nUnstable} unstable, {nCritical} critical, "
+ f"{nStable} stable cells")
+ print(f" ΔSCU: mean={meanDelta:.3f}, maxIncrease={maxIncrease:.3f}, "
+ f"maxDecrease={maxDecrease:.3f}")
+ else:
+ print(f" ✅ Mohr-Coulomb (initial): {nUnstable} unstable, {nCritical} critical, "
+ f"{nStable} stable cells")
- # Build face-sharing mapping with adaptive epsilon
- local_mapping = self._find_face_sharing_cells(single_fault)
+ return surface
- # Remap local indices to global fault indices
- for local_idx, neighbors in local_mapping.items():
- global_idx = indices[local_idx]
- all_mappings[global_idx] = neighbors
- return all_mappings
+# ============================================================================
+# TIME SERIES PROCESSING
+# ============================================================================
+class TimeSeriesProcessor:
+ """Process multiple time steps from PVD file"""
# -------------------------------------------------------------------
- def _find_face_sharing_cells(self, fault_surface):
- """
- Find volume cells that share a FACE with fault cells
-
- Uses FindCell with adaptive epsilon to maximize cells with both neighbors
- """
- vol_mesh = self.volume_mesh
- vol_centers = vol_mesh.cell_centers().points
- fault_normals = fault_surface.cell_data["Normals"]
- fault_centers = fault_surface.cell_centers().points
-
- # Determine base epsilon based on mesh size
- vol_bounds = vol_mesh.bounds
- typical_size = np.mean([
- vol_bounds[1] - vol_bounds[0],
- vol_bounds[3] - vol_bounds[2],
- vol_bounds[5] - vol_bounds[4]
- ]) / 100.0
-
- # Build VTK cell locator (once)
- from vtkmodules.vtkCommonDataModel import vtkCellLocator
-
- locator = vtkCellLocator()
- locator.SetDataSet(vol_mesh)
- locator.BuildLocator()
-
- # Try multiple epsilon values and keep the best
- epsilon_candidates = [
- typical_size * 0.005,
- typical_size * 0.01,
- typical_size * 0.05,
- typical_size * 0.1,
- typical_size * 0.2,
- typical_size * 0.5,
- typical_size * 1.0
- ]
-
- print(f" Testing {len(epsilon_candidates)} epsilon values...")
-
- best_epsilon = None
- best_mapping = None
- best_score = -1
- best_stats = None
-
- for epsilon in epsilon_candidates:
- # Test this epsilon
- mapping, stats = self._test_epsilon(
- fault_surface, locator, epsilon,
- fault_centers, fault_normals, vol_centers
- )
-
- # Score = percentage with both sides + penalty for no neighbors
- score = stats['pct_both'] - 2.0 * stats['pct_none']
-
- print(f" ε={epsilon:.3f}m → Both: {stats['pct_both']:.1f}%, "
- f"One: {stats['pct_one']:.1f}%, None: {stats['pct_none']:.1f}%, "
- f"Avg: {stats['avg_neighbors']:.2f} (score: {score:.1f})")
-
- if score > best_score:
- best_score = score
- best_epsilon = epsilon
- best_mapping = mapping
- best_stats = stats
-
- print(f"\n ✅ Best epsilon: {best_epsilon:.6f}m")
- print(f" ✅ Face-sharing mapping completed:")
- print(f" Both sides: {best_stats['n_both']} ({best_stats['pct_both']:.1f}%)")
- print(f" One side: {best_stats['n_one']} ({best_stats['pct_one']:.1f}%)")
- print(f" No neighbors: {best_stats['n_none']} ({best_stats['pct_none']:.1f}%)")
- print(f" Average neighbors per fault cell: {best_stats['avg_neighbors']:.2f}")
-
- return best_mapping
+ def __init__(self, config):
+ self.config = config
+ self.outputDir = Path(config.OUTPUT_DIR)
+ self.outputDir.mkdir(exist_ok=True)
# -------------------------------------------------------------------
- def _test_epsilon(self, fault_surface, locator, epsilon,
- fault_centers, fault_normals, vol_centers):
+ def process(self, path, faultGeometry, pvdFile):
"""
- Test a specific epsilon value and return mapping + statistics
- """
- mapping = {}
- n_found_both = 0
- n_found_one = 0
- n_found_none = 0
- total_neighbors = 0
-
- for fid in range(fault_surface.n_cells):
- fcenter = fault_centers[fid]
- fnormal = fault_normals[fid]
-
- plus_cells = []
- minus_cells = []
-
- # Search on PLUS side
- point_plus = fcenter + epsilon * fnormal
- cell_id_plus = locator.FindCell(point_plus)
- if cell_id_plus >= 0:
- plus_cells.append(cell_id_plus)
-
- # Search on MINUS side
- point_minus = fcenter - epsilon * fnormal
- cell_id_minus = locator.FindCell(point_minus)
- if cell_id_minus >= 0:
- minus_cells.append(cell_id_minus)
-
- mapping[fid] = {"plus": plus_cells, "minus": minus_cells}
-
- # Statistics
- n_neighbors = len(plus_cells) + len(minus_cells)
- total_neighbors += n_neighbors
-
- if len(plus_cells) > 0 and len(minus_cells) > 0:
- n_found_both += 1
- elif len(plus_cells) > 0 or len(minus_cells) > 0:
- n_found_one += 1
- else:
- n_found_none += 1
-
- n_cells = fault_surface.n_cells
- avg_neighbors = total_neighbors / n_cells if n_cells > 0 else 0
-
- stats = {
- 'n_both': n_found_both,
- 'n_one': n_found_one,
- 'n_none': n_found_none,
- 'pct_both': n_found_both / n_cells * 100,
- 'pct_one': n_found_one / n_cells * 100,
- 'pct_none': n_found_none / n_cells * 100,
- 'avg_neighbors': avg_neighbors
- }
-
- return mapping, stats
+ Process all time steps using pre-computed fault geometry
- # -------------------------------------------------------------------
- def _visualize_contributions(self):
- """
- Unified visualization of volume contributions to fault surfaces
- 4-panel view combining full context, side classification, clip, and slice
+ Parameters:
+ path: base path for input files
+ faultGeometry: FaultGeometry object with initialized topology
+ pvdFile: PVD file name
"""
- import pyvista as pv
-
- print("\n📊 Creating contribution visualization...")
-
- # Create plotter with 4 subplots
- plotter = pv.Plotter(shape=(2, 2), window_size=[1800, 1400])
-
- # ========== PLOT 1: Full context (top-left) ==========
- plotter.subplot(0, 0)
- plotter.add_text("Full Context - Volume & Fault", font_size=14, position='upper_edge')
+ pvdReader = pv.PVDReader(path / pvdFile)
+ timeValues = np.array(pvdReader.timeValues)
- # All volume (transparent)
- plotter.add_mesh(self.mesh, color='lightgray', opacity=0.05,
- show_edges=False, label='Volume')
+ if self.config.TIME_INDEX:
+ timeValues = timeValues[self.config.TIME_INDEX]
- # Fault surface (red)
- plotter.add_mesh(self.fault_surface, color='red', opacity=1,
- show_edges=True, label='Fault Surface')
+ outputFiles = []
+ dataInitial = None
+ SCUInitialReference = None
- plotter.add_legend(loc="upper left")
- plotter.add_axes()
- plotter.set_scale(zscale=self.config.Z_SCALE)
+ # Get pre-computed data from faultGeometry
+ surface = faultGeometry.faultSurface
+ adjacencyMapping = faultGeometry.adjacencyMapping
+ geometricProperties = faultGeometry.getGeometricProperties()
- # ========== PLOT 2: Contributing cells by side (top-right) ==========
- plotter.subplot(0, 1)
- plotter.add_text("Contributing Cells",
- font_size=14, position='upper_edge')
+ # Initialize projector with pre-computed topology
+ projector = StressProjector(self.config, adjacencyMapping, geometricProperties)
- if 'contribution_side' in self.volume_mesh.cell_data:
- # Plus side (blue)
- if self.contributing_cells_plus.n_cells > 0:
- plotter.add_mesh(self.contributing_cells_plus, color='dodgerblue',
- opacity=1.0, show_edges=True,
- label=f'Plus side ({self.contributing_cells_plus.n_cells} cells)')
- # Minus side (orange)
- if self.contributing_cells_minus.n_cells > 0:
- plotter.add_mesh(self.contributing_cells_minus, color='darkorange',
- opacity=1.0, show_edges=True,
- label=f'Minus side ({self.contributing_cells_minus.n_cells} cells)')
+ print('\n')
+ print("=" * 60)
+ print("TIME SERIES PROCESSING")
+ print("=" * 60)
- # Fault surface for reference
- plotter.add_mesh(self.fault_surface, color='red', opacity=1.0,
- show_edges=True, label='Fault')
+ for i, time in enumerate(timeValues):
+ print(f"\n→ Step {i+1}/{len(timeValues)}: {time/(365.25*24*3600):.2f} years")
- plotter.add_legend(loc='upper right')
- plotter.add_axes()
- plotter.set_scale(zscale=self.config.Z_SCALE)
+ # Read time step
+ idx = self.config.TIME_INDEX[i] if self.config.TIME_INDEX else i
+ pvdReader.set_active_time_point(idx)
+ dataset = pvdReader.read()
- # ========== PLOT 3: Clipped view (bottom-left) ==========
- plotter.subplot(1, 0)
- plotter.add_text("Clipped View - Contributing Cells",
- font_size=14, position='upper_edge')
+ # Merge blocks
+ volumeData = self._mergeBlocks(dataset)
- # Determine clip position (middle of fault)
- bounds = self.fault_surface.bounds
- clip_normal = [0, 0, -1] # Clip along Z axis
- clip_origin = [0,0, (bounds[4] + bounds[5]) / 2]
+ if dataInitial is None:
+ dataInitial = volumeData
- # Clip and show contributing cells
- if self.contributing_cells.n_cells > 0:
- plotter.add_mesh_clip_plane(
- self.contributing_cells,
- normal=clip_normal,
- origin=clip_origin,
- color='blue',
- opacity=1,
- show_edges=True,
- label='Contributing (clipped)'
+ # -----------------------------------
+ # Projection using pre-computed topology
+ # -----------------------------------
+ # Projection
+ surfaceResult, volumeMarked, contributingCells = projector.projectStressToFault(
+ volumeData,
+ dataInitial,
+ surface,
+ time=timeValues[i], # Simulation time
+ timestep=i, # Timestep index
+ weightingScheme=self.config.WEIGHTING_SCHEME
)
- # Fault surface
- plotter.add_mesh(self.fault_surface, color='red', opacity=1.0,
- show_edges=True, label='Fault')
-
- plotter.add_legend(loc='upper left')
- plotter.add_axes()
- plotter.set_scale(zscale=self.config.Z_SCALE)
-
- # ========== PLOT 4: Slice view (bottom-right) ==========
- plotter.subplot(1, 1)
-
- # Determine slice position (middle of fault in Z)
- slice_position = (bounds[4] + bounds[5]) / 2
- plotter.add_text(f"Slice View at Z={slice_position:.1f}m",
- font_size=14, position='upper_edge')
-
- # Create slice of volume
- slice_vol = self.volume_mesh.slice(normal='z', origin=[0, 0, slice_position])
- slice_fault = self.fault_surface.slice(normal='z', origin=[0, 0, slice_position])
-
- # Show contributing vs non-contributing in slice
- if 'contribution_side' in slice_vol.cell_data:
- # Non-contributing cells (gray)
- non_contrib_mask = slice_vol.cell_data['contribution_side'] == 0
- if np.sum(non_contrib_mask) > 0:
- non_contrib = slice_vol.extract_cells(non_contrib_mask)
- plotter.add_mesh(non_contrib, color='lightgray', opacity=0.15,
- show_edges=True, line_width=1, label='Non-contributing')
-
- # Plus side (blue)
- plus_mask = (slice_vol.cell_data['contribution_side'] == 1) | \
- (slice_vol.cell_data['contribution_side'] == 3)
- if np.sum(plus_mask) > 0:
- plus_cells = slice_vol.extract_cells(plus_mask)
- plotter.add_mesh(plus_cells, color='dodgerblue', opacity=0.7,
- show_edges=True, line_width=2, label='Plus side')
+ # -----------------------------------
+ # Mohr-Coulomb analysis
+ # -----------------------------------
+ cohesion = self.config.COHESION
+ frictionAngle = self.config.FRICTION_ANGLE
+ surfaceResult = MohrCoulomb.analyze(surfaceResult, cohesion, frictionAngle, time)
- # Minus side (orange)
- minus_mask = (slice_vol.cell_data['contribution_side'] == 2) | \
- (slice_vol.cell_data['contribution_side'] == 3)
- if np.sum(minus_mask) > 0:
- minus_cells = slice_vol.extract_cells(minus_mask)
- plotter.add_mesh(minus_cells, color='darkorange', opacity=0.7,
- show_edges=True, line_width=2, label='Minus side')
+ # -----------------------------------
+ # Visualize
+ # -----------------------------------
+ self._plotResults(surfaceResult, contributingCells, time, self.outputDir)
- # Fault slice (thick red line)
- if slice_fault.n_cells > 0:
- plotter.add_mesh(slice_fault, color='red', line_width=6,
- label='Fault', render_lines_as_tubes=True)
+ # -----------------------------------
+ # Sensitivity analysis
+ # -----------------------------------
+ if self.config.RUN_SENSITIVITY:
+ analyzer = SensitivityAnalyzer(self.config)
+ sensitivityResults = analyzer.runAnalysis(surfaceResult, time)
- plotter.add_legend(loc='upper right')
- plotter.add_axes()
- plotter.set_scale(zscale=self.config.Z_SCALE)
- plotter.view_xy()
+ # Save
+ filename = f'fault_analysis_{i:04d}.vtu'
+ surfaceResult.save(self.outputDir / filename)
+ outputFiles.append((time, filename))
+ print(f" 💾 Saved: {filename}")
- # Link all views for synchronized rotation
- plotter.link_views()
+ # Create master PVD
+ self._createPVD(outputFiles)
- # Show or save
- if self.config.SHOW_PLOTS:
- plotter.show()
- else:
- # Save screenshot
- from pathlib import Path
- output_dir = Path(self.config.OUTPUT_DIR) if hasattr(self.config, 'OUTPUT_DIR') else Path('.')
- output_dir.mkdir(parents=True, exist_ok=True)
- screenshot_path = output_dir / "contribution_visualization.png"
- plotter.screenshot(str(screenshot_path))
- print(f" 💾 Visualization saved: {screenshot_path}")
- plotter.close()
+ return surfaceResult
# -------------------------------------------------------------------
- # NORMALS
- # -------------------------------------------------------------------
- def _extract_and_compute_normals(self, show_plot=False, scale_factor=50.0, z_scale=1.0):
- """Extract fault surfaces and compute oriented normals/tangents"""
- surfaces = []
-
- for fault_id in self.fault_values:
- # Extract fault cells
- fault_mask = self.mesh.cell_data[self.fault_attribute] == fault_id
- fault_cells = self.mesh.extract_cells(fault_mask)
-
- if fault_cells.n_cells == 0:
- print(f"⚠️ No cells for fault {fault_id}")
- continue
-
- # Extract surface
- surf = fault_cells.extract_surface()
- if surf.n_cells == 0:
- continue
-
- # Compute normals
- surf.compute_normals(cell_normals=True, point_normals=True, inplace=True)
+ def _mergeBlocks(self, dataset):
+ """Merge multi-block dataset - descente automatique jusqu'aux données"""
- # Orient normals consistently within the fault
- surf = self._orient_normals(surf)
+ # -----------------------------------------------
+ def extractLeafBlocks(block, path="", depth=0):
+ """
+ Descend récursivement dans la structure MultiBlock jusqu'aux feuilles avec données
- surfaces.append(surf)
+ Returns:
+ list of (block, path, bounds) tuples
+ """
+ leaves = []
- merged = pv.MultiBlock(surfaces).combine()
- print(f"✅ Normals computed for {merged.n_cells} fault cells")
+ # Cas 1: C'est un MultiBlock avec des sous-blocs
+ if hasattr(block, 'n_blocks') and block.n_blocks > 0:
+ for i in range(block.n_blocks):
+ subBlock = block.GetBlock(i)
+ blockName = block.get_block_name(i) if hasattr(block, 'get_block_name') else f"Block{i}"
+ newPath = f"{path}/{blockName}" if path else blockName
- if show_plot:
- self._plot_geometry(merged, scale_factor, z_scale)
+ if subBlock is not None:
+ # Récursion
+ leaves.extend(extractLeafBlocks(subBlock, newPath, depth + 1))
- return merged, surfaces
+ # Cas 2: C'est un dataset final (feuille)
+ elif hasattr(block, 'n_cells') and block.n_cells > 0:
+ bounds = block.bounds
+ leaves.append((block, path, bounds))
- # -------------------------------------------------------------------
- def _orient_normals(self, surf):
- """Ensure normals point in consistent direction within the fault"""
- normals = surf.cell_data['Normals']
- mean_normal = np.mean(normals, axis=0)
- mean_normal /= np.linalg.norm(mean_normal)
+ return leaves
- n_cells = len(normals)
- tangents1 = np.zeros((n_cells, 3))
- tangents2 = np.zeros((n_cells, 3))
+ print(f" 📦 Extracting volume blocks")
- for i, normal in enumerate(normals):
+ # Extraire toutes les feuilles
+ allBlocks = extractLeafBlocks(dataset)
- # Flip if pointing opposite to mean
- if np.dot(normal, mean_normal) < 0:
- normals[i] = -normal
+ # Filtrer et afficher
+ merged = []
+ blocksWithPressure = 0
+ blocksWithoutPressure = 0
- if self.config.ROTATE_NORMALS:
- normals[i] = -normal
+ for block, path, bounds in allBlocks:
+ hasPressure = 'pressure' in block.cell_data
- # Compute orthogonal tangents
- normal = normals[i]
- if abs(normal[0]) > 1e-6 or abs(normal[1]) > 1e-6:
- t1 = np.array([-normal[1], normal[0], 0])
+ if hasPressure:
+ blocksWithPressure += 1
+ merged.append(block)
else:
- t1 = np.array([0, -normal[2], normal[1]])
-
- t1 /= np.linalg.norm(t1)
- t2 = np.cross(normal, t1)
- t2 /= np.linalg.norm(t2)
-
- tangents1[i] = t1
- tangents2[i] = t2
+ blocksWithoutPressure += 1
- surf.cell_data['Normals'] = normals
- surf.cell_data['tangent1'] = tangents1
- surf.cell_data['tangent2'] = tangents2
-
- dip_angles, strike_angles = self.compute_dip_strike_from_cell_base(normals, tangents1, tangents2)
-
- surf.cell_data['dip_angle'] = dip_angles
- surf.cell_data['strike_angle'] = strike_angles
+ # Combiner
+ combined = pv.MultiBlock(merged).combine()
- return surf
+ return combined
# -------------------------------------------------------------------
- def compute_dip_strike_from_cell_base(self, normals, tangent1, tangent2):
- """
- Calcule les angles dip et strike à partir des vecteurs normaux et tangents des cellules.
- Hypothèses :
- - Système de coordonnées : X=Est, Y=Nord, Z=Haut.
- - Vecteurs donnés par cellule (shape: (n_cells, 3)).
- - Les vecteurs d'entrée sont supposés orthonormés (n = t1 x t2).
-
- Retourne :
- dip_deg, strike_deg (two arrays of shape (n_cells,))
- """
- # 1. Identifier le vecteur strike (le plus horizontal)
- t1_horiz = tangent1 - (tangent1[:, 2][:, np.newaxis] * np.array([0, 0, 1]))
- t2_horiz = tangent2 - (tangent2[:, 2][:, np.newaxis] * np.array([0, 0, 1]))
- norm_t1_h = np.linalg.norm(t1_horiz, axis=1)
- norm_t2_h = np.linalg.norm(t2_horiz, axis=1)
+ def _plotResults(self, surface, contributingCells, time, path):
- use_t1 = norm_t1_h > norm_t2_h
- strike_vector = np.zeros_like(tangent1)
- strike_vector[use_t1] = t1_horiz[use_t1]
- strike_vector[~use_t1] = t2_horiz[~use_t1]
-
- # Normaliser
- strike_norm = np.linalg.norm(strike_vector, axis=1)
- # Éviter la division par zéro (si la faille est parfaitement verticale, le strike est bien défini par l'autre vecteur)
- strike_norm[strike_norm == 0] = 1.0
- strike_vector = strike_vector / strike_norm[:, np.newaxis]
-
- # 2. Calculer le strike (azimut depuis le Nord, sens horaire)
- strike_rad = np.arctan2(strike_vector[:, 0], strike_vector[:, 1]) # atan2(E, N)
- strike_deg = np.degrees(strike_rad)
- strike_deg = np.where(strike_deg < 0, strike_deg + 360, strike_deg)
+ Visualizer.plotMohrCoulombDiagram( surface, time, path,
+ show=self.config.SHOW_PLOTS,
+ save=self.config.SAVE_PLOTS )
- # 3. Calculer le dip
- norm_horiz = np.linalg.norm(normals[:, :2], axis=1)
- dip_rad = np.arcsin(np.clip(norm_horiz, 0, 1)) # clip pour éviter les erreurs d'arrondi
- dip_deg = np.degrees(dip_rad)
+ # Profils verticaux automatiques
+ if self.config.SHOW_DEPTH_PROFILES:
+ Visualizer.plotDepthProfiles(
+ self,
+ surface, time, path,
+ show=self.config.SHOW_PLOTS,
+ save=self.config.SAVE_PLOTS,
+ profileStartPoints=self.config.PROFILE_START_POINTS )
- return dip_deg, strike_deg
+ visualizer = Visualizer(self.config)
- # -------------------------------------------------------------------
- def _plot_geometry(self, surface, scale_factor, z_scale):
- """Visualize fault geometry with normals"""
- plotter = pv.Plotter()
- plotter.add_mesh(self.mesh, color='lightgray', opacity=0.1, label='Volume')
- plotter.add_mesh(surface, color='darkgray', opacity=0.7, show_edges=True, label='Fault')
+ if self.config.COMPUTE_PRINCIPAL_STRESS:
- centers = surface.cell_centers()
- for name, color in [('Normals', 'red'), ('tangent1', 'green'), ('tangent2', 'blue')]:
- arrows = centers.glyph(orient=name, scale=z_scale, factor=scale_factor)
- plotter.add_mesh(arrows, color=color, label=name)
+ # Plot principal stress from volume cells
+ visualizer.plotVolumeStressProfiles(
+ volumeMesh=contributingCells,
+ faultSurface=surface,
+ time=time,
+ path=path,
+ profileStartPoints=self.config.PROFILE_START_POINTS )
- plotter.add_legend()
- plotter.add_axes()
- plotter.set_scale(zscale=z_scale)
- plotter.show()
+ # Visualize comparison analytical/numerical
+ visualizer.plotAnalyticalVsNumericalComparison(
+ volumeMesh=contributingCells,
+ faultSurface=surface,
+ time=time,
+ path=path,
+ show=self.config.SHOW_PLOTS,
+ save=self.config.SAVE_PLOTS,
+ profileStartPoints=self.config.PROFILE_START_POINTS)
# -------------------------------------------------------------------
- def diagnose_normals(self, scale_factor=50.0, z_scale=1.0):
- """
- Diagnostic visualization to check normal quality
- Shows orthogonality and orientation issues
- """
- surface = self.fault_surface
-
- print("\n🔍 DIAGNOSTIC DES NORMALES")
- print("=" * 60)
-
- normals = surface.cell_data['Normals']
- tangent1 = surface.cell_data['tangent1']
- tangent2 = surface.cell_data['tangent2']
-
- n_cells = len(normals)
-
- # Check orthogonality
- dot_n_t1 = np.array([np.dot(normals[i], tangent1[i]) for i in range(n_cells)])
- dot_n_t2 = np.array([np.dot(normals[i], tangent2[i]) for i in range(n_cells)])
- dot_t1_t2 = np.array([np.dot(tangent1[i], tangent2[i]) for i in range(n_cells)])
-
- print(f"Orthogonalité (doit être proche de 0):")
- print(f" Normal · Tangent1 : max={np.max(np.abs(dot_n_t1)):.2e}, mean={np.mean(np.abs(dot_n_t1)):.2e}")
- print(f" Normal · Tangent2 : max={np.max(np.abs(dot_n_t2)):.2e}, mean={np.mean(np.abs(dot_n_t2)):.2e}")
- print(f" Tangent1 · Tangent2: max={np.max(np.abs(dot_t1_t2)):.2e}, mean={np.mean(np.abs(dot_t1_t2)):.2e}")
-
- # Check unit vectors
- norm_n = np.linalg.norm(normals, axis=1)
- norm_t1 = np.linalg.norm(tangent1, axis=1)
- norm_t2 = np.linalg.norm(tangent2, axis=1)
-
- print(f"\nNormes (doit être proche de 1):")
- print(f" Normals : min={np.min(norm_n):.6f}, max={np.max(norm_n):.6f}")
- print(f" Tangent1 : min={np.min(norm_t1):.6f}, max={np.max(norm_t1):.6f}")
- print(f" Tangent2 : min={np.min(norm_t2):.6f}, max={np.max(norm_t2):.6f}")
-
- # Check orientation consistency
- mean_normal = np.mean(normals, axis=0)
- mean_normal = mean_normal / np.linalg.norm(mean_normal)
-
- dots_with_mean = np.array([np.dot(normals[i], mean_normal) for i in range(n_cells)])
- n_reversed = np.sum(dots_with_mean < 0)
-
- print(f"\nCohérence d'orientation:")
- print(f" Normale moyenne: [{mean_normal[0]:.3f}, {mean_normal[1]:.3f}, {mean_normal[2]:.3f}]")
- print(f" Normales inversées: {n_reversed}/{n_cells} ({n_reversed/n_cells*100:.1f}%)")
-
- if n_reversed > n_cells * 0.1:
- print(f" ⚠️ Plus de 10% des normales pointent dans la direction opposée!")
- else:
- print(f" ✅ Orientation cohérente")
-
- print("=" * 60)
-
- # Visualization
- plotter = pv.Plotter(shape=(1, 2))
-
- # Plot 1: Surface with normals
- plotter.subplot(0, 0)
- plotter.add_mesh(surface, color='lightgray', show_edges=True, opacity=0.8)
-
- centers = surface.cell_centers()
- arrows_n = centers.glyph(orient='Normals', scale=False, factor=scale_factor)
- plotter.add_mesh(arrows_n, color='red', label='Normals')
-
- plotter.add_legend()
- plotter.add_axes()
- plotter.add_text("Normales (Rouge)", position='upper_edge')
- plotter.set_scale(zscale=z_scale)
-
- # Plot 2: All vectors
- plotter.subplot(0, 1)
- plotter.add_mesh(surface, color='lightgray', show_edges=True, opacity=0.5)
-
- arrows_n = centers.glyph(orient='Normals', scale=False, factor=scale_factor)
- arrows_t1 = centers.glyph(orient='tangent1', scale=False, factor=scale_factor)
- arrows_t2 = centers.glyph(orient='tangent2', scale=False, factor=scale_factor)
-
- plotter.add_mesh(arrows_n, color='red', label='Normal')
- plotter.add_mesh(arrows_t1, color='green', label='Tangent1')
- plotter.add_mesh(arrows_t2, color='blue', label='Tangent2')
-
- plotter.add_legend()
- plotter.add_axes()
- plotter.add_text("Système complet (R,G,B)", position='upper_edge')
- plotter.set_scale(zscale=z_scale)
-
- plotter.link_views()
- plotter.show()
-
- return surface
-
-
- """
- Diagnostic visualization to check normal quality
- Shows orthogonality and orientation issues
- """
- print("\n🔍 DIAGNOSTIC DES NORMALES")
- print("=" * 60)
-
- normals = surface.cell_data['Normals']
- tangent1 = surface.cell_data['tangent1']
- tangent2 = surface.cell_data['tangent2']
-
- n_cells = len(normals)
-
- # Check orthogonality
- dot_n_t1 = np.array([np.dot(normals[i], tangent1[i]) for i in range(n_cells)])
- dot_n_t2 = np.array([np.dot(normals[i], tangent2[i]) for i in range(n_cells)])
- dot_t1_t2 = np.array([np.dot(tangent1[i], tangent2[i]) for i in range(n_cells)])
-
- print(f"Orthogonalité (doit être proche de 0):")
- print(f" Normal · Tangent1 : max={np.max(np.abs(dot_n_t1)):.2e}, mean={np.mean(np.abs(dot_n_t1)):.2e}")
- print(f" Normal · Tangent2 : max={np.max(np.abs(dot_n_t2)):.2e}, mean={np.mean(np.abs(dot_n_t2)):.2e}")
- print(f" Tangent1 · Tangent2: max={np.max(np.abs(dot_t1_t2)):.2e}, mean={np.mean(np.abs(dot_t1_t2)):.2e}")
-
- # Check unit vectors
- norm_n = np.array([np.linalg.norm(normals[i]) for i in range(n_cells)])
- norm_t1 = np.array([np.linalg.norm(tangent1[i]) for i in range(n_cells)])
- norm_t2 = np.array([np.linalg.norm(tangent2[i]) for i in range(n_cells)])
-
- print(f"\nNormes (doit être proche de 1):")
- print(f" Normals : min={np.min(norm_n):.6f}, max={np.max(norm_n):.6f}")
- print(f" Tangent1 : min={np.min(norm_t1):.6f}, max={np.max(norm_t1):.6f}")
- print(f" Tangent2 : min={np.min(norm_t2):.6f}, max={np.max(norm_t2):.6f}")
-
- # Check orientation consistency
- mean_normal = np.mean(normals, axis=0)
- mean_normal = mean_normal / np.linalg.norm(mean_normal)
-
- dots_with_mean = np.array([np.dot(normals[i], mean_normal) for i in range(n_cells)])
- n_reversed = np.sum(dots_with_mean < 0)
-
- print(f"\nCohérence d'orientation:")
- print(f" Normale moyenne: [{mean_normal[0]:.3f}, {mean_normal[1]:.3f}, {mean_normal[2]:.3f}]")
- print(f" Normales inversées: {n_reversed}/{n_cells} ({n_reversed/n_cells*100:.1f}%)")
-
- # Visual check
- if n_reversed > n_cells * 0.1:
- print(f" ⚠️ Plus de 10% des normales pointent dans la direction opposée!")
- else:
- print(f" ✅ Orientation cohérente")
-
- # Check for problematic cells
- bad_ortho = (np.abs(dot_n_t1) > 1e-3) | (np.abs(dot_n_t2) > 1e-3) | (np.abs(dot_t1_t2) > 1e-3)
- n_bad = np.sum(bad_ortho)
-
- if n_bad > 0:
- print(f"\n⚠️ {n_bad} cellules avec orthogonalité douteuse (|dot| > 1e-3)")
- surface.cell_data['orthogonality_error'] = np.maximum.reduce([
- np.abs(dot_n_t1), np.abs(dot_n_t2), np.abs(dot_t1_t2)
- ])
- else:
- print(f"\n✅ Toutes les cellules ont une bonne orthogonalité")
-
- print("=" * 60)
-
- # Visualization
- plotter = pv.Plotter(shape=(1, 2))
-
- # Plot 1: Surface with normals
- plotter.subplot(0, 0)
- plotter.add_mesh(surface, color='lightgray', show_edges=True, opacity=0.8)
-
- centers = surface.cell_centers()
- arrows_n = centers.glyph(orient='Normals', scale=False, factor=scale_factor)
- plotter.add_mesh(arrows_n, color='red', label='Normals')
-
- plotter.add_legend()
- plotter.add_axes()
- plotter.add_text("Normales (Rouge)", position='upper_edge')
- plotter.set_scale(zscale=z_scale)
-
- # Plot 2: All vectors (normal + tangents)
- plotter.subplot(0, 1)
- plotter.add_mesh(surface, color='lightgray', show_edges=True, opacity=0.5)
-
- arrows_n = centers.glyph(orient='Normals', scale=False, factor=scale_factor)
- arrows_t1 = centers.glyph(orient='tangent1', scale=False, factor=scale_factor)
- arrows_t2 = centers.glyph(orient='tangent2', scale=False, factor=scale_factor)
-
- plotter.add_mesh(arrows_n, color='red', label='Normal')
- plotter.add_mesh(arrows_t1, color='green', label='Tangent1')
- plotter.add_mesh(arrows_t2, color='blue', label='Tangent2')
-
- plotter.add_legend()
- plotter.add_axes()
- plotter.add_text("Système complet (R,G,B)", position='upper_edge')
- plotter.set_scale(zscale=z_scale)
-
- plotter.link_views()
- plotter.show()
-
- return surface
-
-
-# ============================================================================
-# STRESS PROJECTION
-# ============================================================================
-class StressProjector:
- """Projects volume stress onto fault surfaces and tracks principal stresses in VTU"""
-
- # -------------------------------------------------------------------
- def __init__(self, config, adjacency_mapping, geometric_properties):
- """
- Initialize with pre-computed adjacency mapping and geometric properties
-
- Parameters
- ----------
- config : Configuration object
- adjacency_mapping : dict
- Pre-computed dict mapping fault cells to volume cells
- geometric_properties : dict
- Pre-computed geometric properties from FaultGeometry:
- - 'volumes': cell volumes
- - 'centers': cell centers
- - 'distances': distances to fault
- - 'fault_tree': KDTree for fault
- """
- self.config = config
- self.adjacency_mapping = adjacency_mapping
-
- # Store pre-computed geometric properties
- self.volume_cell_volumes = geometric_properties['volumes']
- self.volume_centers = geometric_properties['centers']
- self.distance_to_fault = geometric_properties['distances']
- self.fault_tree = geometric_properties['fault_tree']
-
- # Storage for time series metadata
- self.timestep_info = []
-
- # Track which cells to monitor (optional)
- self.monitored_cells = None
-
- # Output directory for VTU files
- self.vtu_output_dir = None
-
- # -------------------------------------------------------------------
- def set_monitored_cells(self, cell_indices):
- """
- Set specific cells to monitor (optional)
-
- Parameters:
- cell_indices: list of volume cell indices to track
- If None, all contributing cells are tracked
- """
- self.monitored_cells = set(cell_indices) if cell_indices is not None else None
-
- # -------------------------------------------------------------------
- def project_stress_to_fault(self, volume_data, volume_initial, fault_surface,
- time=None, timestep=None, weighting_scheme="arithmetic"):
- """
- Project stress and save principal stresses to VTU
-
- Now uses pre-computed geometric properties for efficiency
- """
- stress_name = self.config.STRESS_NAME
- biot_name = self.config.BIOT_NAME
-
- if stress_name not in volume_data.array_names:
- raise ValueError(f"No stress data '{stress_name}' in dataset")
-
- # =====================================================================
- # 1. EXTRACT STRESS DATA
- # =====================================================================
- pressure = volume_data["pressure"] / 1e5
- p_fault = volume_initial["pressure"] / 1e5
- p_init = volume_initial["pressure"] / 1e5
- biot = volume_data[biot_name]
-
- stress_eff = StressTensor.build_from_array(volume_data[stress_name] / 1e5)
- stress_eff_init = StressTensor.build_from_array(volume_initial[stress_name] / 1e5)
-
- # Convert effective stress to total stress
- I = np.eye(3)[None, :, :]
- stress_total = stress_eff - biot[:, None, None] * pressure[:, None, None] * I
- stress_total_init = stress_eff_init - biot[:, None, None] * p_init[:, None, None] * I
-
- # =====================================================================
- # 2. USE PRE-COMPUTED ADJACENCY
- # =====================================================================
- mapping = self.adjacency_mapping
-
- # =====================================================================
- # 3. PREPARE FAULT GEOMETRY
- # =====================================================================
- normals = fault_surface.cell_data["Normals"]
- tangent1 = fault_surface.cell_data["tangent1"]
- tangent2 = fault_surface.cell_data["tangent2"]
-
- fault_centers = fault_surface.cell_centers().points
- fault_surface.cell_data['elementCenter'] = fault_centers
-
- n_fault = fault_surface.n_cells
- n_volume = volume_data.n_cells
-
- # =====================================================================
- # 4. COMPUTE PRINCIPAL STRESSES FOR CONTRIBUTING CELLS
- # =====================================================================
- if self.config.COMPUTE_PRINCIPAL_STRESS and timestep is not None:
-
- # Collect all unique contributing cells
- all_contributing_cells = set()
- for fault_idx, neighbors in mapping.items():
- all_contributing_cells.update(neighbors['plus'])
- all_contributing_cells.update(neighbors['minus'])
-
- # Filter by monitored cells if specified
- if self.monitored_cells is not None:
- cells_to_track = all_contributing_cells.intersection(self.monitored_cells)
- else:
- cells_to_track = all_contributing_cells
-
- print(f" 📊 Computing principal stresses for {len(cells_to_track)} contributing cells...")
-
- # Create mesh with only contributing cells
- contributing_mesh = self._create_volumic_contrib_mesh(
- volume_data, fault_surface, cells_to_track, mapping
- )
-
- # Save to VTU
- if self.vtu_output_dir is None:
- self.vtu_output_dir = Path(self.config.OUTPUT_DIR) / "principal_stresses"
-
- self._save_principal_stress_vtu(contributing_mesh, time, timestep)
-
- else:
- contributing_mesh = None
-
- # =====================================================================
- # 6. PROJECT STRESS FOR EACH FAULT CELL
- # =====================================================================
- sigma_n_arr = np.zeros(n_fault)
- tau_arr = np.zeros(n_fault)
- tau_dip_arr = np.zeros(n_fault)
- tau_strike_arr = np.zeros(n_fault)
- delta_sigma_n_arr = np.zeros(n_fault)
- delta_tau_arr = np.zeros(n_fault)
- n_contributors = np.zeros(n_fault, dtype=int)
-
- print(f" 🔄 Projecting stress to {n_fault} fault cells...")
- print(f" Weighting scheme: {weighting_scheme}")
-
- for fault_idx in range(n_fault):
- if fault_idx not in mapping:
- continue
-
- vol_plus = mapping[fault_idx]['plus']
- vol_minus = mapping[fault_idx]['minus']
- all_vol = vol_plus + vol_minus
-
- if len(all_vol) == 0:
- continue
-
- # ===================================================================
- # CALCULATE WEIGHTS (using pre-computed properties)
- # ===================================================================
-
- if weighting_scheme == 'arithmetic':
- weights = np.ones(len(all_vol)) / len(all_vol)
-
- elif weighting_scheme == 'harmonic':
- weights = np.ones(len(all_vol)) / len(all_vol)
-
- elif weighting_scheme == 'distance':
- # Use pre-computed distances
- dists = np.array([self.distance_to_fault[v] for v in all_vol])
- dists = np.maximum(dists, 1e-6)
- inv_dists = 1.0 / dists
- weights = inv_dists / np.sum(inv_dists)
-
- elif weighting_scheme == 'volume':
- # Use pre-computed volumes
- vols = np.array([self.volume_cell_volumes[v] for v in all_vol])
- weights = vols / np.sum(vols)
-
- elif weighting_scheme == 'distance_volume':
- # Use pre-computed volumes and distances
- vols = np.array([self.volume_cell_volumes[v] for v in all_vol])
- dists = np.array([self.distance_to_fault[v] for v in all_vol])
- dists = np.maximum(dists, 1e-6)
-
- weights = vols / dists
- weights = weights / np.sum(weights)
-
- elif weighting_scheme == 'inverse_square_distance':
- # Use pre-computed distances
- dists = np.array([self.distance_to_fault[v] for v in all_vol])
- dists = np.maximum(dists, 1e-6)
- inv_sq_dists = 1.0 / (dists ** 2)
- weights = inv_sq_dists / np.sum(inv_sq_dists)
-
- else:
- raise ValueError(f"Unknown weighting scheme: {weighting_scheme}")
-
- # ===================================================================
- # ACCUMULATE WEIGHTED CONTRIBUTIONS
- # ===================================================================
-
- sigma_n = 0.0
- tau = 0.0
- tau_dip = 0.0
- tau_strike = 0.0
- delta_sigma_n = 0.0
- delta_tau = 0.0
-
- for vol_idx, w in zip(all_vol, weights):
-
- # Total stress (with pressure)
- sigma_final = stress_total[vol_idx] + p_fault[vol_idx] * np.eye(3)
- sigma_init = stress_total_init[vol_idx] + p_init[vol_idx] * np.eye(3)
-
- # Rotate to fault frame
- res_f = StressTensor.rotate_to_fault_frame(
- sigma_final, normals[fault_idx], tangent1[fault_idx], tangent2[fault_idx]
- )
-
- res_i = StressTensor.rotate_to_fault_frame(
- sigma_init, normals[fault_idx], tangent1[fault_idx], tangent2[fault_idx]
- )
-
- # Accumulate weighted contributions
- sigma_n += w * res_f['normal_stress']
- tau += w * res_f['shear_stress']
- tau_dip += w * res_f['shear_dip']
- tau_strike += w * res_f['shear_strike']
- delta_sigma_n += w * (res_f['normal_stress'] - res_i['normal_stress'])
- delta_tau += w * (res_f['shear_stress'] - res_i['shear_stress'])
-
- sigma_n_arr[fault_idx] = sigma_n
- tau_arr[fault_idx] = tau
- tau_dip_arr[fault_idx] = tau_dip
- tau_strike_arr[fault_idx] = tau_strike
- delta_sigma_n_arr[fault_idx] = delta_sigma_n
- delta_tau_arr[fault_idx] = delta_tau
- n_contributors[fault_idx] = len(all_vol)
-
- # =====================================================================
- # 7. STORE RESULTS ON FAULT SURFACE
- # =====================================================================
- fault_surface.cell_data["sigma_n_eff"] = sigma_n_arr
- fault_surface.cell_data["tau_eff"] = tau_dip_arr
- fault_surface.cell_data["tau_strike"] = tau_strike_arr
- fault_surface.cell_data["tau_dip"] = tau_dip_arr
- fault_surface.cell_data["delta_sigma_n_eff"] = delta_sigma_n_arr
- fault_surface.cell_data["delta_tau_eff"] = delta_tau_arr
-
- # =====================================================================
- # 8. STATISTICS
- # =====================================================================
- valid = n_contributors > 0
- n_valid = np.sum(valid)
-
- print(f" ✅ Stress projected: {n_valid}/{n_fault} fault cells ({n_valid/n_fault*100:.1f}%)")
-
- if np.sum(valid) > 0:
- print(f" Contributors per fault cell: min={np.min(n_contributors[valid])}, "
- f"max={np.max(n_contributors[valid])}, "
- f"mean={np.mean(n_contributors[valid]):.1f}")
-
- return fault_surface, volume_data, contributing_mesh
-
- # -------------------------------------------------------------------
- @staticmethod
- def compute_principal_stresses(stress_tensor):
- """
- Compute principal stresses and directions
-
- Convention: Compression is NEGATIVE
- - σ1 = most compressive (most negative)
- - σ3 = least compressive (least negative, or most tensile)
-
- Returns:
- dict with eigenvalues, eigenvectors, mean_stress, deviatoric_stress
- """
- eigenvalues, eigenvectors = np.linalg.eigh(stress_tensor)
-
- # Sort from MOST NEGATIVE to LEAST NEGATIVE (most compressive to least)
- # Example: -600 < -450 < -200, so -600 is σ1 (most compressive)
- idx = np.argsort(eigenvalues) # Ascending order (most negative first)
- eigenvalues_sorted = eigenvalues[idx]
- eigenvectors_sorted = eigenvectors[:, idx]
-
- return {
- 'sigma1': eigenvalues_sorted[0], # Most compressive (most negative)
- 'sigma2': eigenvalues_sorted[1], # Intermediate
- 'sigma3': eigenvalues_sorted[2], # Least compressive (least negative)
- 'mean_stress': np.mean(eigenvalues_sorted),
- 'deviatoric_stress': eigenvalues_sorted[0] - eigenvalues_sorted[2], # σ1 - σ3 (negative - more negative = positive or less negative)
- 'direction1': eigenvectors_sorted[:, 0], # Direction of σ1
- 'direction2': eigenvectors_sorted[:, 1], # Direction of σ2
- 'direction3': eigenvectors_sorted[:, 2] # Direction of σ3
- }
-
- # -------------------------------------------------------------------
- def _create_volumic_contrib_mesh(self, volume_data, fault_surface, cells_to_track, mapping):
- """
- Create a mesh containing only contributing cells with principal stress data
- and compute analytical normal/shear stresses based on fault dip angle
-
- Parameters
- ----------
- volume_data : pyvista.UnstructuredGrid
- Volume mesh with stress data (rock_stress or averageStress)
- fault_surface : pyvista.PolyData
- Fault surface with dip_angle and strike_angle per cell
- cells_to_track : set
- Set of volume cell indices to include
- mapping : dict
- Adjacency mapping {fault_idx: {'plus': [...], 'minus': [...]}}
- """
-
- # ===================================================================
- # EXTRACT STRESS DATA FROM VOLUME
- # ===================================================================
- stress_name = self.config.STRESS_NAME
- biot_name = self.config.BIOT_NAME
-
- if stress_name not in volume_data.array_names:
- raise ValueError(f"No stress data '{stress_name}' in volume dataset")
-
- print(f" 📊 Extracting stress from field: '{stress_name}'")
-
- # Extract effective stress and pressure
- pressure = volume_data["pressure"] / 1e5 # Convert to bar
- biot = volume_data[biot_name]
-
- stress_eff = StressTensor.build_from_array(volume_data[stress_name] / 1e5)
-
- # Convert effective stress to total stress
- I = np.eye(3)[None, :, :]
- stress_total = stress_eff - biot[:, None, None] * pressure[:, None, None] * I
-
- # ===================================================================
- # EXTRACT SUBSET OF CELLS
- # ===================================================================
- cell_indices = sorted(list(cells_to_track))
- cell_mask = np.zeros(volume_data.n_cells, dtype=bool)
- cell_mask[cell_indices] = True
-
- subset_mesh = volume_data.extract_cells(cell_mask)
-
- # ===================================================================
- # REBUILD MAPPING: subset_idx -> original_idx
- # ===================================================================
- original_centers = volume_data.cell_centers().points[cell_indices]
- subset_centers = subset_mesh.cell_centers().points
-
- from scipy.spatial import cKDTree
- tree = cKDTree(original_centers)
-
- subset_to_original = np.zeros(subset_mesh.n_cells, dtype=int)
- for subset_idx in range(subset_mesh.n_cells):
- dist, idx = tree.query(subset_centers[subset_idx])
- if dist > 1e-6:
- print(f" WARNING: Cell {subset_idx} not matched (dist={dist})")
- subset_to_original[subset_idx] = cell_indices[idx]
-
- # ===================================================================
- # MAP VOLUME CELLS TO FAULT DIP/STRIKE ANGLES
- # ===================================================================
- print(f" 📐 Mapping volume cells to fault dip/strike angles...")
-
- # Check if fault surface has required data
- if 'dip_angle' not in fault_surface.cell_data:
- print(f" ⚠️ WARNING: 'dip_angle' not found in fault_surface")
- print(f" Available fields: {list(fault_surface.cell_data.keys())}")
- return None
-
- if 'strike_angle' not in fault_surface.cell_data:
- print(f" ⚠️ WARNING: 'strike_angle' not found in fault_surface")
-
- # Create mapping: volume_cell_id -> [dip_angles, strike_angles]
- volume_to_dip = {}
- volume_to_strike = {}
-
- for fault_idx, neighbors in mapping.items():
- # Get dip and strike angle from fault cell
- fault_dip = fault_surface.cell_data['dip_angle'][fault_idx]
-
- # Strike is optional
- if 'strike_angle' in fault_surface.cell_data:
- fault_strike = fault_surface.cell_data['strike_angle'][fault_idx]
- else:
- fault_strike = np.nan
-
- # Assign to all contributing volume cells (plus and minus)
- for vol_idx in neighbors['plus'] + neighbors['minus']:
- if vol_idx not in volume_to_dip:
- volume_to_dip[vol_idx] = []
- volume_to_strike[vol_idx] = []
- volume_to_dip[vol_idx].append(fault_dip)
- volume_to_strike[vol_idx].append(fault_strike)
-
- # Average if a volume cell contributes to multiple fault cells
- volume_to_dip_avg = {vol_idx: np.mean(dips)
- for vol_idx, dips in volume_to_dip.items()}
- volume_to_strike_avg = {vol_idx: np.mean(strikes)
- for vol_idx, strikes in volume_to_strike.items()}
-
- print(f" ✅ Mapped {len(volume_to_dip_avg)} volume cells to fault angles")
-
- # Statistics
- all_dips = [np.mean(dips) for dips in volume_to_dip.values()]
- if len(all_dips) > 0:
- print(f" Dip angle range: [{np.min(all_dips):.1f}, {np.max(all_dips):.1f}]°")
-
- # ===================================================================
- # COMPUTE PRINCIPAL STRESSES AND ANALYTICAL FAULT STRESSES
- # ===================================================================
- n_cells = subset_mesh.n_cells
-
- sigma1_arr = np.zeros(n_cells)
- sigma2_arr = np.zeros(n_cells)
- sigma3_arr = np.zeros(n_cells)
- mean_stress_arr = np.zeros(n_cells)
- deviatoric_stress_arr = np.zeros(n_cells)
- pressure_arr = np.zeros(n_cells)
-
- direction1_arr = np.zeros((n_cells, 3))
- direction2_arr = np.zeros((n_cells, 3))
- direction3_arr = np.zeros((n_cells, 3))
-
- # NEW: Analytical fault stresses
- sigma_n_analytical_arr = np.zeros(n_cells)
- tau_analytical_arr = np.zeros(n_cells)
- dip_angle_arr = np.zeros(n_cells)
- strike_angle_arr = np.zeros(n_cells)
- delta_arr = np.zeros(n_cells)
-
- side_arr = np.zeros(n_cells, dtype=int)
- n_fault_cells_arr = np.zeros(n_cells, dtype=int)
-
- print(f" 🔢 Computing principal stresses and analytical projections...")
-
- for subset_idx in range(n_cells):
- orig_idx = subset_to_original[subset_idx]
-
- # ===============================================================
- # COMPUTE PRINCIPAL STRESSES
- # ===============================================================
- # Total stress = effective stress + pore pressure
- sigma_total_cell = stress_total[orig_idx] + pressure[orig_idx] * np.eye(3)
- principal = self.compute_principal_stresses(sigma_total_cell)
-
- sigma1_arr[subset_idx] = principal['sigma1']
- sigma2_arr[subset_idx] = principal['sigma2']
- sigma3_arr[subset_idx] = principal['sigma3']
- mean_stress_arr[subset_idx] = principal['mean_stress']
- deviatoric_stress_arr[subset_idx] = principal['deviatoric_stress']
- pressure_arr[subset_idx] = pressure[orig_idx]
-
- direction1_arr[subset_idx] = principal['direction1']
- direction2_arr[subset_idx] = principal['direction2']
- direction3_arr[subset_idx] = principal['direction3']
-
- # ===============================================================
- # COMPUTE ANALYTICAL FAULT STRESSES (Anderson formulas)
- # ===============================================================
- if orig_idx in volume_to_dip_avg:
- dip_deg = volume_to_dip_avg[orig_idx]
- dip_angle_arr[subset_idx] = dip_deg
-
- strike_deg = volume_to_strike_avg.get(orig_idx, np.nan)
- strike_angle_arr[subset_idx] = strike_deg
-
- # δ = 90° - dip (angle from horizontal)
- delta_deg = 90.0 - dip_deg
- delta_rad = np.radians(delta_deg)
- delta_arr[subset_idx] = delta_deg
-
- # Extract principal stresses (compression negative)
- sigma1 = principal['sigma1'] # Most compressive (most negative)
- sigma3 = principal['sigma3'] # Least compressive (least negative)
-
- # Anderson formulas (1951)
- # σ_n = (σ1 + σ3)/2 - (σ1 - σ3)/2 * cos(2δ)
- # τ = |(σ1 - σ3)/2 * sin(2δ)|
-
- sigma_mean = (sigma1 + sigma3) / 2.0
- sigma_diff = (sigma1 - sigma3) / 2.0
-
- sigma_n_analytical = sigma_mean - sigma_diff * np.cos(2 * delta_rad)
- tau_analytical = sigma_diff * np.sin(2 * delta_rad)
-
- sigma_n_analytical_arr[subset_idx] = sigma_n_analytical
- tau_analytical_arr[subset_idx] = np.abs(tau_analytical)
- else:
- # No fault association - set to NaN
- dip_angle_arr[subset_idx] = np.nan
- strike_angle_arr[subset_idx] = np.nan
- delta_arr[subset_idx] = np.nan
- sigma_n_analytical_arr[subset_idx] = np.nan
- tau_analytical_arr[subset_idx] = np.nan
-
- # ===============================================================
- # DETERMINE SIDE (plus/minus/both)
- # ===============================================================
- is_plus = False
- is_minus = False
- fault_cell_count = 0
-
- for fault_idx, neighbors in mapping.items():
- if orig_idx in neighbors['plus']:
- is_plus = True
- fault_cell_count += 1
- if orig_idx in neighbors['minus']:
- is_minus = True
- fault_cell_count += 1
-
- if is_plus and is_minus:
- side_arr[subset_idx] = 3 # both
- elif is_plus:
- side_arr[subset_idx] = 1 # plus
- elif is_minus:
- side_arr[subset_idx] = 2 # minus
- else:
- side_arr[subset_idx] = 0 # none (should not happen)
-
- n_fault_cells_arr[subset_idx] = fault_cell_count
-
- # ===================================================================
- # ADD DATA TO MESH
- # ===================================================================
- subset_mesh.cell_data['sigma1'] = sigma1_arr
- subset_mesh.cell_data['sigma2'] = sigma2_arr
- subset_mesh.cell_data['sigma3'] = sigma3_arr
- subset_mesh.cell_data['mean_stress'] = mean_stress_arr
- subset_mesh.cell_data['deviatoric_stress'] = deviatoric_stress_arr
- subset_mesh.cell_data['pressure_bar'] = pressure_arr
-
- subset_mesh.cell_data['sigma1_direction'] = direction1_arr
- subset_mesh.cell_data['sigma2_direction'] = direction2_arr
- subset_mesh.cell_data['sigma3_direction'] = direction3_arr
-
- # Analytical fault stresses
- subset_mesh.cell_data['sigma_n_analytical'] = sigma_n_analytical_arr
- subset_mesh.cell_data['tau_analytical'] = tau_analytical_arr
- subset_mesh.cell_data['dip_angle'] = dip_angle_arr
- subset_mesh.cell_data['strike_angle'] = strike_angle_arr
- subset_mesh.cell_data['delta_angle'] = delta_arr
-
- # ===================================================================
- # COMPUTE SCU ANALYTICALLY (Mohr-Coulomb)
- # ===================================================================
- if hasattr(self.config, 'FRICTION_ANGLE') and hasattr(self.config, 'COHESION'):
- mu = np.tan(np.radians(self.config.FRICTION_ANGLE))
- cohesion = self.config.COHESION
-
- # τ_crit = C - σ_n * μ
- # Note: σ_n is negative (compression), so -σ_n * μ is positive
- tau_crit_arr = cohesion - sigma_n_analytical_arr * mu
-
- # SCU = τ / τ_crit
- SCU_analytical_arr = np.divide(
- tau_analytical_arr,
- tau_crit_arr,
- out=np.zeros_like(tau_analytical_arr),
- where=tau_crit_arr != 0
- )
-
- subset_mesh.cell_data['tau_crit_analytical'] = tau_crit_arr
- subset_mesh.cell_data['SCU_analytical'] = SCU_analytical_arr
-
- # CFS (Coulomb Failure Stress)
- CFS_analytical_arr = tau_analytical_arr - mu * (-sigma_n_analytical_arr)
- subset_mesh.cell_data['CFS_analytical'] = CFS_analytical_arr
-
- subset_mesh.cell_data['side'] = side_arr
- subset_mesh.cell_data['n_fault_cells'] = n_fault_cells_arr
- subset_mesh.cell_data['original_cell_id'] = subset_to_original
-
- # ===================================================================
- # STATISTICS
- # ===================================================================
- valid_analytical = ~np.isnan(sigma_n_analytical_arr)
- n_valid = np.sum(valid_analytical)
-
- if n_valid > 0:
- print(f" 📊 Analytical fault stresses computed for {n_valid}/{n_cells} cells")
- print(f" σ_n range: [{np.nanmin(sigma_n_analytical_arr):.1f}, {np.nanmax(sigma_n_analytical_arr):.1f}] bar")
- print(f" τ range: [{np.nanmin(tau_analytical_arr):.1f}, {np.nanmax(tau_analytical_arr):.1f}] bar")
- print(f" Dip angle range: [{np.nanmin(dip_angle_arr):.1f}, {np.nanmax(dip_angle_arr):.1f}]°")
-
- if hasattr(self.config, 'FRICTION_ANGLE') and hasattr(self.config, 'COHESION'):
- print(f" SCU range: [{np.nanmin(SCU_analytical_arr[valid_analytical]):.2f}, {np.nanmax(SCU_analytical_arr[valid_analytical]):.2f}]")
- n_critical = np.sum((SCU_analytical_arr >= 0.8) & (SCU_analytical_arr < 1.0))
- n_unstable = np.sum(SCU_analytical_arr >= 1.0)
- print(f" Critical cells (SCU≥0.8): {n_critical} ({n_critical/n_valid*100:.1f}%)")
- print(f" Unstable cells (SCU≥1.0): {n_unstable} ({n_unstable/n_valid*100:.1f}%)")
- else:
- print(f" ⚠️ No analytical stresses computed (no fault mapping)")
-
- return subset_mesh
-
- # -------------------------------------------------------------------
- def _save_principal_stress_vtu(self, mesh, time, timestep):
- """
- Save principal stress mesh to VTU file
-
- Parameters:
- mesh: PyVista mesh with principal stress data
- time: Simulation time
- timestep: Timestep index
- """
- # Create output directory
- self.vtu_output_dir.mkdir(parents=True, exist_ok=True)
-
- # Generate filename
- vtu_filename = f"principal_stresses_{timestep:05d}.vtu"
- vtu_path = self.vtu_output_dir / vtu_filename
-
- # Save mesh
- mesh.save(str(vtu_path))
-
- # Store metadata for PVD
- self.timestep_info.append({
- 'time': time if time is not None else timestep,
- 'timestep': timestep,
- 'file': vtu_filename
- })
-
- print(f" 💾 Saved principal stresses: {vtu_filename}")
-
- # -------------------------------------------------------------------
- def save_pvd_collection(self, filename="principal_stresses.pvd"):
- """
- Create PVD file for time series visualization in ParaView
-
- Parameters:
- filename: Name of PVD file
- """
- if len(self.timestep_info) == 0:
- print("⚠️ No timestep data to save in PVD")
- return
-
- pvd_path = self.vtu_output_dir / filename
-
- print(f"\n💾 Creating PVD collection: {pvd_path}")
- print(f" Timesteps: {len(self.timestep_info)}")
-
- # Create XML structure
- root = Element('VTKFile')
- root.set('type', 'Collection')
- root.set('version', '0.1')
- root.set('byte_order', 'LittleEndian')
-
- collection = SubElement(root, 'Collection')
-
- for info in self.timestep_info:
- dataset = SubElement(collection, 'DataSet')
- dataset.set('timestep', str(info['time']))
- dataset.set('group', '')
- dataset.set('part', '0')
- dataset.set('file', info['file'])
-
- # Write to file
- tree = ElementTree(root)
- tree.write(str(pvd_path), encoding='utf-8', xml_declaration=True)
-
- print(f" ✅ PVD file created successfully")
- print(f" 📂 Output directory: {self.vtu_output_dir}")
- print(f"\n 🎨 To visualize in ParaView:")
- print(f" 1. Open: {pvd_path}")
- print(f" 2. Apply")
- print(f" 3. Color by: sigma1, sigma2, sigma3, mean_stress, etc.")
- print(f" 4. Use 'side' filter to show plus/minus/both")
-
-
-# ============================================================================
-# MOHR COULOMB
-# ============================================================================
-class MohrCoulomb:
- """Mohr-Coulomb failure criterion analysis"""
-
- @staticmethod
- def analyze(surface, cohesion, friction_angle_deg, time=0, verbose=True):
- """
- Perform Mohr-Coulomb stability analysis
-
- Parameters:
- surface: fault surface with stress data
- cohesion: cohesion in bar
- friction_angle_deg: friction angle in degrees
- time: simulation time
- verbose: print statistics
- """
- mu = np.tan(np.radians(friction_angle_deg))
-
- # Extract stress components
- sigma_n = surface.cell_data["sigma_n_eff"]
- tau = surface.cell_data["tau_eff"]
- d_sigma_n = surface.cell_data['delta_sigma_n_eff']
- d_tau = surface.cell_data['delta_tau_eff']
-
- # Mohr-Coulomb failure envelope
- tau_crit = cohesion - sigma_n * mu
-
- # Coulomb Failure Stress
- CFS = tau - mu * sigma_n
- # delta_CFS = d_tau - mu * d_sigma_n
-
- # Shear Capacity Utilization: SCU = τ / τ_crit
- SCU = np.divide(tau, tau_crit, out=np.zeros_like(tau), where=tau_crit != 0)
-
- if "SCU_initial" not in surface.cell_data:
- # First timestep: store as initial reference
- SCU_initial = SCU.copy()
- CFS_initial = CFS.copy()
- delta_SCU = np.zeros_like(SCU)
- delta_CFS = np.zeros_like(CFS)
-
- surface.cell_data["SCU_initial"] = SCU_initial
- surface.cell_data["CFS_initial"] = CFS_initial
-
- is_initial = True
- else:
- # Subsequent timesteps: calculate change from initial
- SCU_initial = surface.cell_data["SCU_initial"]
- CFS_initial = surface.cell_data['CFS_initial']
- delta_SCU = SCU - SCU_initial
- delta_CFS = CFS - CFS_initial
- is_initial = False
-
- # Stability classification
- stability = np.zeros_like(tau, dtype=int)
- stability[SCU >= 0.8] = 1 # Critical
- stability[SCU >= 1.0] = 2 # Unstable
-
- # Failure probability (sigmoid)
- k = 10.0
- failure_prob = 1.0 / (1.0 + np.exp(-k * (SCU - 1.0)))
-
- # Safety margin
- safety = tau_crit - tau
-
- # Store results
- surface.cell_data.update({
- "mohr_cohesion": np.full(surface.n_cells, cohesion),
- "mohr_friction_angle": np.full(surface.n_cells, friction_angle_deg),
- "mohr_friction_coefficient": np.full(surface.n_cells, mu),
- "mohr_critical_shear_stress": tau_crit,
- "SCU": SCU,
- "delta_SCU": delta_SCU,
- "CFS" : CFS,
- "delta_CFS": delta_CFS,
- "safety_margin": safety,
- "stability_state": stability,
- "failure_probability": failure_prob
- })
-
- if verbose:
- n_stable = np.sum(stability == 0)
- n_critical = np.sum(stability == 1)
- n_unstable = np.sum(stability == 2)
-
- # Additional info on delta_SCU
- if not is_initial:
- mean_delta = np.mean(np.abs(delta_SCU))
- max_increase = np.max(delta_SCU)
- max_decrease = np.min(delta_SCU)
- print(f" ✅ Mohr-Coulomb: {n_unstable} unstable, {n_critical} critical, "
- f"{n_stable} stable cells")
- print(f" ΔSCU: mean={mean_delta:.3f}, max_increase={max_increase:.3f}, "
- f"max_decrease={max_decrease:.3f}")
- else:
- print(f" ✅ Mohr-Coulomb (initial): {n_unstable} unstable, {n_critical} critical, "
- f"{n_stable} stable cells")
-
- return surface
-
-
-# ============================================================================
-# TIME SERIES PROCESSING
-# ============================================================================
-class TimeSeriesProcessor:
- """Process multiple time steps from PVD file"""
-
- # -------------------------------------------------------------------
- def __init__(self, config):
- self.config = config
- self.output_dir = Path(config.OUTPUT_DIR)
- self.output_dir.mkdir(exist_ok=True)
-
- # -------------------------------------------------------------------
- def process(self, path, fault_geometry, pvd_file):
- """
- Process all time steps using pre-computed fault geometry
-
- Parameters:
- path: base path for input files
- fault_geometry: FaultGeometry object with initialized topology
- pvd_file: PVD file name
- """
- pvd_reader = pv.PVDReader(path / pvd_file)
- time_values = np.array(pvd_reader.time_values)
-
- if self.config.TIME_INDEX:
- time_values = time_values[self.config.TIME_INDEX]
-
- output_files = []
- data_initial = None
- SCU_initial_reference = None
-
- # Get pre-computed data from fault_geometry
- surface = fault_geometry.fault_surface
- adjacency_mapping = fault_geometry.adjacency_mapping
- geometric_properties = fault_geometry.get_geometric_properties()
-
- # Initialize projector with pre-computed topology
- projector = StressProjector(self.config, adjacency_mapping, geometric_properties)
-
-
- print('\n')
- print("=" * 60)
- print("TIME SERIES PROCESSING")
- print("=" * 60)
-
- for i, time in enumerate(time_values):
- print(f"\n→ Step {i+1}/{len(time_values)}: {time/(365.25*24*3600):.2f} years")
-
- # Read time step
- idx = self.config.TIME_INDEX[i] if self.config.TIME_INDEX else i
- pvd_reader.set_active_time_point(idx)
- dataset = pvd_reader.read()
-
- # Merge blocks
- volume_data = self._merge_blocks(dataset)
-
- if data_initial is None:
- data_initial = volume_data
-
- # -----------------------------------
- # Projection using pre-computed topology
- # -----------------------------------
- # Projection
- surface_result, volume_marked, contributing_cells = projector.project_stress_to_fault(
- volume_data,
- data_initial,
- surface,
- time=time_values[i], # Simulation time
- timestep=i, # Timestep index
- weighting_scheme=self.config.WEIGHTING_SCHEME
- )
-
- # -----------------------------------
- # Mohr-Coulomb analysis
- # -----------------------------------
- cohesion = self.config.COHESION
- friction_angle = self.config.FRICTION_ANGLE
- surface_result = MohrCoulomb.analyze(surface_result, cohesion, friction_angle, time)
-
- # -----------------------------------
- # Visualize
- # -----------------------------------
- self._plot_results(surface_result, contributing_cells, time, self.output_dir)
-
- # -----------------------------------
- # Sensitivity analysis
- # -----------------------------------
- if self.config.RUN_SENSITIVITY:
- analyzer = SensitivityAnalyzer(self.config)
- sensitivity_results = analyzer.run_analysis(surface_result, time)
-
- # Save
- filename = f'fault_analysis_{i:04d}.vtu'
- surface_result.save(self.output_dir / filename)
- output_files.append((time, filename))
- print(f" 💾 Saved: {filename}")
-
- # Create master PVD
- self._create_pvd(output_files)
-
- return surface_result
-
- # -------------------------------------------------------------------
- def _merge_blocks(self, dataset):
- """Merge multi-block dataset - descente automatique jusqu'aux données"""
-
- # -----------------------------------------------
- def extract_leaf_blocks(block, path="", depth=0):
- """
- Descend récursivement dans la structure MultiBlock jusqu'aux feuilles avec données
-
- Returns:
- list of (block, path, bounds) tuples
- """
- leaves = []
-
- # Cas 1: C'est un MultiBlock avec des sous-blocs
- if hasattr(block, 'n_blocks') and block.n_blocks > 0:
- for i in range(block.n_blocks):
- sub_block = block.GetBlock(i)
- block_name = block.get_block_name(i) if hasattr(block, 'get_block_name') else f"Block{i}"
- new_path = f"{path}/{block_name}" if path else block_name
-
- if sub_block is not None:
- # Récursion
- leaves.extend(extract_leaf_blocks(sub_block, new_path, depth + 1))
-
- # Cas 2: C'est un dataset final (feuille)
- elif hasattr(block, 'n_cells') and block.n_cells > 0:
- bounds = block.bounds
- leaves.append((block, path, bounds))
-
- return leaves
-
- print(f" 📦 Extracting volume blocks")
-
- # Extraire toutes les feuilles
- all_blocks = extract_leaf_blocks(dataset)
-
- # Filtrer et afficher
- merged = []
- blocks_with_pressure = 0
- blocks_without_pressure = 0
-
- for block, path, bounds in all_blocks:
- has_pressure = 'pressure' in block.cell_data
-
- if has_pressure:
- blocks_with_pressure += 1
- merged.append(block)
- else:
- blocks_without_pressure += 1
-
- # Combiner
- combined = pv.MultiBlock(merged).combine()
-
- return combined
-
- # -------------------------------------------------------------------
- def _plot_results(self, surface, contributing_cells, time, path):
-
- Visualizer.plot_mohr_coulomb_diagram( surface, time, path,
- show=self.config.SHOW_PLOTS,
- save=self.config.SAVE_PLOTS )
-
- # Profils verticaux automatiques
- if self.config.SHOW_DEPTH_PROFILES:
- Visualizer.plot_depth_profiles(
- self,
- surface, time, path,
- show=self.config.SHOW_PLOTS,
- save=self.config.SAVE_PLOTS,
- profile_start_points=self.config.PROFILE_START_POINTS )
-
- visualizer = Visualizer(self.config)
-
- if self.config.COMPUTE_PRINCIPAL_STRESS:
-
- # Plot principal stress from volume cells
- visualizer.plot_volume_stress_profiles(
- volume_mesh=contributing_cells,
- fault_surface=surface,
- time=time,
- path=path,
- profile_start_points=self.config.PROFILE_START_POINTS )
-
- # Visualize comparison analytical/numerical
- visualizer.plot_analytical_vs_numerical_comparison(
- volume_mesh=contributing_cells,
- fault_surface=surface,
- time=time,
- path=path,
- show=self.config.SHOW_PLOTS,
- save=self.config.SAVE_PLOTS,
- profile_start_points=self.config.PROFILE_START_POINTS)
-
- # -------------------------------------------------------------------
- def _create_pvd(self, output_files):
- """Create PVD collection file"""
- pvd_path = self.output_dir / 'fault_analysis.pvd'
- with open(pvd_path, 'w') as f:
- f.write('\n')
- f.write(' \n')
- for t, fname in output_files:
- f.write(f' \n')
- f.write(' \n')
- f.write('\n')
- print(f"\n✅ PVD created: {pvd_path}")
-
-
-# ============================================================================
-# SENSITIVITY ANALYSIS
-# ============================================================================
-class SensitivityAnalyzer:
- """Performs sensitivity analysis on Mohr-Coulomb parameters"""
-
- # -------------------------------------------------------------------
- def __init__(self, config):
- self.config = config
- self.output_dir = Path(config.SENSITIVITY_OUTPUT_DIR)
- self.output_dir.mkdir(exist_ok=True)
- self.results = []
-
- # -------------------------------------------------------------------
- def run_analysis(self, surface_with_stress, time):
- """Run sensitivity analysis for multiple friction angles and cohesions"""
- friction_angles = self.config.SENSITIVITY_FRICTION_ANGLES
- cohesions = self.config.SENSITIVITY_COHESIONS
-
- print("\n" + "=" * 60)
- print("SENSITIVITY ANALYSIS")
- print("=" * 60)
- print(f"Friction angles: {friction_angles}")
- print(f"Cohesions: {cohesions}")
- print(f"Total combinations: {len(friction_angles) * len(cohesions)}")
-
- results = []
-
- for friction_angle in friction_angles:
- for cohesion in cohesions:
- print(f"\n→ Testing φ={friction_angle}°, C={cohesion} bar")
-
- surface_copy = surface_with_stress.copy()
-
- surface_analyzed = MohrCoulomb.analyze(
- surface_copy, cohesion, friction_angle, time, verbose=False)
-
- stats = self._extract_statistics(surface_analyzed, friction_angle, cohesion)
- results.append(stats)
-
- print(f" Unstable: {stats['n_unstable']}, "
- f"Critical: {stats['n_critical']}, "
- f"Stable: {stats['n_stable']}")
-
- self.results = results
-
- # Generate plots
- self._plot_sensitivity_results(results, time)
-
- # Plot SCU vs depth
- self._plot_scu_depth_profiles(results, time, surface_with_stress)
-
- return results
-
- # -------------------------------------------------------------------
- def _extract_statistics(self, surface, friction_angle, cohesion):
- """Extract statistical metrics from analyzed surface"""
- stability = surface.cell_data["stability_state"]
- SCU = surface.cell_data["SCU"]
- failure_prob = surface.cell_data["failure_probability"]
- safety_margin = surface.cell_data["safety_margin"]
-
- stats = {
- 'friction_angle': friction_angle,
- 'cohesion': cohesion,
- 'n_cells': surface.n_cells,
- 'n_stable': np.sum(stability == 0),
- 'n_critical': np.sum(stability == 1),
- 'n_unstable': np.sum(stability == 2),
- 'pct_unstable': np.sum(stability == 2) / surface.n_cells * 100,
- 'pct_critical': np.sum(stability == 1) / surface.n_cells * 100,
- 'pct_stable': np.sum(stability == 0) / surface.n_cells * 100,
- 'mean_SCU': np.mean(SCU),
- 'max_SCU': np.max(SCU),
- 'mean_failure_prob': np.mean(failure_prob),
- 'mean_safety_margin': np.mean(safety_margin),
- 'min_safety_margin': np.min(safety_margin)
- }
-
- return stats
-
- # -------------------------------------------------------------------
- def _plot_sensitivity_results(self, results, time):
- """Create comprehensive sensitivity analysis plots"""
- import pandas as pd
-
- df = pd.DataFrame(results)
-
- fig, axes = plt.subplots(2, 2, figsize=(16, 12))
-
- # Plot heatmaps
- self._plot_heatmap(df, 'pct_unstable', 'Unstable Cells [%]', axes[0, 0])
- self._plot_heatmap(df, 'pct_critical', 'Critical Cells [%]', axes[0, 1])
- self._plot_heatmap(df, 'mean_SCU', 'Mean SCU [-]', axes[1, 0])
- self._plot_heatmap(df, 'mean_safety_margin', 'Mean Safety Margin [bar]', axes[1, 1])
-
- plt.tight_layout()
-
- years = time / (365.25 * 24 * 3600)
- filename = f'sensitivity_analysis_{years:.0f}y.png'
- plt.savefig(self.output_dir / filename, dpi=300, bbox_inches='tight')
- print(f"\n📊 Sensitivity plot saved: {filename}")
-
- if self.config.SHOW_PLOTS:
- plt.show()
- else:
- plt.close()
-
- # -------------------------------------------------------------------
- def _plot_heatmap(self, df, column, title, ax):
- """Create a single heatmap for sensitivity analysis"""
- pivot = df.pivot(index='cohesion', columns='friction_angle', values=column)
-
- im = ax.imshow(pivot.values, cmap='RdYlGn_r', aspect='auto', origin='lower')
-
- ax.set_xticks(np.arange(len(pivot.columns)))
- ax.set_yticks(np.arange(len(pivot.index)))
- ax.set_xticklabels(pivot.columns)
- ax.set_yticklabels(pivot.index)
-
- ax.set_xlabel('Friction Angle [°]')
- ax.set_ylabel('Cohesion [bar]')
- ax.set_title(title)
-
- # Add values in cells
- for i in range(len(pivot.index)):
- for j in range(len(pivot.columns)):
- value = pivot.values[i, j]
- text_color = 'white' if value > pivot.values.max() * 0.5 else 'black'
- ax.text(j, i, f'{value:.1f}', ha='center', va='center',
- color=text_color, fontsize=9)
-
- plt.colorbar(im, ax=ax)
-
- # -------------------------------------------------------------------
- def _plot_scu_depth_profiles(self, results, time, surface_with_stress):
- """
- Plot SCU depth profiles for all parameter combinations
- Each (cohesion, friction) pair gets a unique color
- Uses profile points from config.PROFILE_START_POINTS
- """
- import pandas as pd
- from matplotlib.colors import Normalize
- from matplotlib.cm import ScalarMappable
-
- print("\n 📊 Creating SCU sensitivity depth profiles...")
-
- # Extract depth data
- centers = surface_with_stress.cell_data['elementCenter']
- depth = centers[:, 2]
-
- # Get profile points from config
- profile_start_points = self.config.PROFILE_START_POINTS
-
- # Auto-generate if not provided
- if profile_start_points is None:
- print(" ⚠️ No PROFILE_START_POINTS in config, auto-generating...")
- x_min, x_max = np.min(centers[:, 0]), np.max(centers[:, 0])
- y_min, y_max = np.min(centers[:, 1]), np.max(centers[:, 1])
-
- x_range = x_max - x_min
- y_range = y_max - y_min
-
- if x_range > y_range:
- # Fault oriented in X, sample at mid-Y
- x_pos = (x_min + x_max) / 2
- y_pos = (y_min + y_max) / 2
- else:
- # Fault oriented in Y, sample at mid-X
- x_pos = (x_min + x_max) / 2
- y_pos = (y_min + y_max) / 2
-
- profile_start_points = [(x_pos, y_pos)]
-
- # Get search radius from config or auto-compute
- search_radius = getattr(self.config, 'PROFILE_SEARCH_RADIUS', None)
- if search_radius is None:
- x_min, x_max = np.min(centers[:, 0]), np.max(centers[:, 0])
- y_min, y_max = np.min(centers[:, 1]), np.max(centers[:, 1])
- x_range = x_max - x_min
- y_range = y_max - y_min
- search_radius = min(x_range, y_range) * 0.15
-
- print(f" 📍 Using {len(profile_start_points)} profile point(s) from config")
- print(f" Search radius: {search_radius:.1f}m")
-
- # Create colormap for parameter combinations
- n_combinations = len(results)
- cmap = plt.cm.viridis
- norm = Normalize(vmin=0, vmax=n_combinations-1)
- sm = ScalarMappable(norm=norm, cmap=cmap)
-
- # Create figure with subplots for each profile point
- n_profiles = len(profile_start_points)
- fig, axes = plt.subplots(1, n_profiles, figsize=(8*n_profiles, 10))
-
- # Handle single subplot case
- if n_profiles == 1:
- axes = [axes]
-
- # Plot each profile point
- for profile_idx, (x_pos, y_pos, z_pos) in enumerate(profile_start_points):
- ax = axes[profile_idx]
-
- print(f"\n → Profile {profile_idx+1} at ({x_pos:.1f}, {y_pos:.1f}, {z_pos:.1f}):")
-
-
- # Plot each parameter combination
- for idx, params in enumerate(results):
- friction_angle = params['friction_angle']
- cohesion = params['cohesion']
-
- # Re-analyze surface with these parameters
- surface_copy = surface_with_stress.copy()
- surface_analyzed = MohrCoulomb.analyze(
- surface_copy, cohesion, friction_angle, time, verbose=False
- )
-
- # Extract SCU
- SCU = np.abs(surface_analyzed.cell_data["SCU"])
-
- # Extract profile using adaptive method
- # depths_SCU, profile_SCU, _, _ = ProfileExtractor.extract_vertical_profile_topology_based(
- # surface_analyzed, 'SCU', x_pos, y_pos, z_pos, verbose=False)
- depths_SCU, profile_SCU, _, _ = ProfileExtractor.extract_adaptive_profile(
- centers, SCU, x_pos, y_pos, search_radius)
-
- if len(depths_SCU) >= 3:
- color = cmap(norm(idx))
- label = f'φ={friction_angle}°, C={cohesion} bar'
- ax.plot(profile_SCU, depths_SCU,
- color=color, label=label,
- linewidth=2, alpha=0.8)
-
- if idx == 0: # Print info only once per profile
- print(f" ✅ {len(depths_SCU)} points extracted")
- else:
- if idx == 0:
- print(f" ⚠️ Insufficient points ({len(depths_SCU)})")
-
- # Add critical lines
- ax.axvline(x=0.8, color='forestgreen', linestyle='--',
- linewidth=2, label='Critical (SCU=0.8)', zorder=100)
- ax.axvline(x=1.0, color='red', linestyle='--',
- linewidth=2, label='Failure (SCU=1.0)', zorder=100)
-
- # Configure plot
- ax.set_xlabel('Shear Capacity Utilization (SCU) [-]', fontsize=14, weight='bold')
- ax.set_ylabel('Depth [m]', fontsize=14, weight='bold')
- ax.set_title(f'Profile {profile_idx+1} @ ({x_pos:.0f}, {y_pos:.0f})',
- fontsize=14, weight='bold')
- ax.grid(True, alpha=0.3, linestyle='--')
- ax.set_xlim(left=0)
-
- # Change verticale scale
- if hasattr(self.config, 'MAX_DEPTH_PROFILES') and self.config.MAX_DEPTH_PROFILES is not None:
- ax.set_ylim(bottom=self.config.MAX_DEPTH_PROFILES)
-
- # Légende en dehors à droite
- ax.legend(loc='center left', bbox_to_anchor=(1, 0.5), fontsize=9, ncol=1)
-
- ax.tick_params(labelsize=12)
-
- # Overall title
- years = time / (365.25 * 24 * 3600)
- fig.suptitle('SCU Depth Profiles - Sensitivity Analysis',
- fontsize=16, weight='bold', y=0.98)
-
- plt.tight_layout(rect=[0, 0, 1, 0.96])
-
- # Save
- filename = f'sensitivity_scu_profiles_{years:.0f}y.png'
- plt.savefig(self.output_dir / filename, dpi=300, bbox_inches='tight')
- print(f"\n 💾 SCU sensitivity profiles saved: {filename}")
-
- if self.config.SHOW_PLOTS:
- plt.show()
- else:
- plt.close()
-
-
-# ============================================================================
-# PROFILE EXTRACTOR
-# ============================================================================
-class ProfileExtractor:
- """Utility class for extracting profiles along fault surfaces"""
-
- # -------------------------------------------------------------------
- @staticmethod
- def extract_adaptive_profile(centers, values, x_start, y_start, z_start=None,
- search_radius=None, step_size=20.0, max_steps=500,
- verbose=True, fault_bounds=None, cell_data=None):
- """
- Extraction de profil vertical par COUCHES DE PROFONDEUR avec détection automatique de faille.
-
- Stratégie:
- 1. Trouver le point de départ le plus proche
- 2. Identifier automatiquement la faille via cell_data (attribute, FaultMask, etc.)
- 3. FILTRER pour ne garder QUE les cellules de cette faille
- 4. Diviser en tranches Z
- 5. Pour chaque tranche, prendre la cellule la plus proche en XY
-
- Parameters
- ----------
- centers : ndarray
- Cell centers (n_cells, 3)
- values : ndarray
- Values at cells (n_cells,)
- x_start, y_start : float
- Starting XY position
- z_start : float, optional
- Starting Z position (if None, uses highest point near XY)
- search_radius : float, optional
- Not used (kept for compatibility)
- cell_data : dict, optional
- Dictionary with cell data fields (e.g., {'attribute': array, 'FaultMask': array})
- Used to automatically detect and filter by fault ID
- verbose : bool
- Print detailed information
-
- Returns
- -------
- depths, profile_values, path_x, path_y : ndarrays
- Extracted profile data
- """
-
- from scipy.spatial import cKDTree
-
- # Convert to np arrays
- centers = np.asarray(centers)
- values = np.asarray(values)
-
- if len(centers) == 0:
- if verbose:
- print(f" ⚠️ No cells provided")
- return np.array([]), np.array([]), np.array([]), np.array([])
-
- # ===================================================================
- # ÉTAPE 1: TROUVER LE POINT DE DÉPART
- # ===================================================================
-
- if z_start is None:
- # Chercher en 2D (XY), prendre le plus haut
- if verbose:
- print(f" Searching near ({x_start:.1f}, {y_start:.1f})")
-
- d_xy = np.sqrt((centers[:, 0] - x_start)**2 + (centers[:, 1] - y_start)**2)
- closest_indices = np.argsort(d_xy)[:20]
-
- if len(closest_indices) == 0:
- print(f" ⚠️ No cells found near start point")
- return np.array([]), np.array([]), np.array([]), np.array([])
-
- # Prendre le plus haut (plus grand Z)
- closest_depths = centers[closest_indices, 2]
- start_idx = closest_indices[np.argmax(closest_depths)]
- else:
- # Chercher en 3D
- if verbose:
- print(f" Searching near ({x_start:.1f}, {y_start:.1f}, {z_start:.1f})")
-
- d_3d = np.sqrt((centers[:, 0] - x_start)**2 +
- (centers[:, 1] - y_start)**2 +
- (centers[:, 2] - z_start)**2)
- start_idx = np.argmin(d_3d)
-
- start_point = centers[start_idx]
-
- if verbose:
- print(f" Starting point: ({start_point[0]:.1f}, {start_point[1]:.1f}, {start_point[2]:.1f})")
- print(f" Starting cell index: {start_idx}")
-
- # ===================================================================
- # ÉTAPE 2: DÉTECTER AUTOMATIQUEMENT L'ID DE LA FAILLE
- # ===================================================================
-
- fault_ids = None
- target_fault_id = None
-
- if cell_data is not None:
- # Chercher dans l'ordre de priorité
- fault_field_names = ['attribute', 'FaultMask', 'fault_id', 'region']
-
- for field_name in fault_field_names:
- if field_name in cell_data:
- fault_ids = np.asarray(cell_data[field_name])
-
- if len(fault_ids) != len(centers):
- if verbose:
- print(f" ⚠️ Field '{field_name}' length mismatch, skipping")
- continue
-
- # Récupérer l'ID au point de départ
- target_fault_id = fault_ids[start_idx]
-
- if verbose:
- unique_ids = np.unique(fault_ids)
- print(f" Found fault field: '{field_name}'")
- print(f" Available fault IDs: {unique_ids}")
- print(f" Target fault ID at start point: {target_fault_id}")
-
- break
-
- # ===================================================================
- # ÉTAPE 3: FILTRER PAR FAILLE SI DÉTECTÉE
- # ===================================================================
-
- if target_fault_id is not None:
- # FILTRER: garder SEULEMENT cette faille
- mask_same_fault = (fault_ids == target_fault_id)
- n_total = len(centers)
- n_on_fault = np.sum(mask_same_fault)
-
- if verbose:
- print(f" Filtering to fault ID={target_fault_id}: {n_on_fault}/{n_total} cells ({n_on_fault/n_total*100:.1f}%)")
-
- if n_on_fault == 0:
- print(f" ⚠️ No cells found on target fault")
- return np.array([]), np.array([]), np.array([]), np.array([])
-
- # REMPLACER centers et values par le subset filtré
- centers = centers[mask_same_fault].copy()
- values = values[mask_same_fault].copy()
-
- # Trouver le nouvel index de départ dans le subset
- d_to_start = np.sqrt(np.sum((centers - start_point)**2, axis=1))
- start_idx = np.argmin(d_to_start)
-
- if verbose:
- print(f" ✅ Profile will stay on fault ID={target_fault_id}")
- else:
- if verbose:
- print(f" ⚠️ No fault identification field found")
- if cell_data is not None:
- print(f" Available fields: {list(cell_data.keys())}")
- else:
- print(f" cell_data not provided")
- print(f" Profile may jump between faults!")
-
- # À partir d'ici, centers/values ne contiennent QUE la faille cible
-
- # ===================================================================
- # ÉTAPE 4: POSITION DE RÉFÉRENCE
- # ===================================================================
-
- ref_x = centers[start_idx, 0]
- ref_y = centers[start_idx, 1]
-
- if verbose:
- print(f" Reference XY: ({ref_x:.1f}, {ref_y:.1f})")
-
- # ===================================================================
- # ÉTAPE 5: GÉOMÉTRIE DE LA FAILLE
- # ===================================================================
-
- x_range = np.max(centers[:, 0]) - np.min(centers[:, 0])
- y_range = np.max(centers[:, 1]) - np.min(centers[:, 1])
- z_range = np.max(centers[:, 2]) - np.min(centers[:, 2])
-
- if z_range <= 0:
- print(f" ⚠️ Invalid z_range: {z_range}")
- return np.array([]), np.array([]), np.array([]), np.array([])
-
- lateral_extent = max(x_range, y_range)
- xy_tolerance = max(lateral_extent * 0.3, 100.0)
-
- if verbose:
- print(f" Fault extent: X={x_range:.1f}m, Y={y_range:.1f}m, Z={z_range:.1f}m")
- print(f" XY tolerance: {xy_tolerance:.1f}m")
-
- # ===================================================================
- # ÉTAPE 6: CALCUL DES TRANCHES
- # ===================================================================
-
- z_coords_sorted = np.sort(centers[:, 2])
- z_diffs = np.diff(z_coords_sorted)
- z_diffs_positive = z_diffs[z_diffs > 1e-6]
-
- if len(z_diffs_positive) == 0:
- if verbose:
- print(f" ⚠️ All cells at same Z")
-
- d_xy = np.sqrt((centers[:, 0] - ref_x)**2 + (centers[:, 1] - ref_y)**2)
- sorted_indices = np.argsort(d_xy)
-
- return (centers[sorted_indices, 2],
- values[sorted_indices],
- centers[sorted_indices, 0],
- centers[sorted_indices, 1])
-
- median_z_spacing = np.median(z_diffs_positive)
-
- # Vérifier que median_z_spacing est raisonnable
- if median_z_spacing <= 0 or median_z_spacing > z_range:
- median_z_spacing = z_range / 100 # Fallback
-
- # Taille de tranche = espacement médian
- slice_thickness = median_z_spacing
-
- z_min = np.min(centers[:, 2])
- z_max = np.max(centers[:, 2])
-
- n_slices = int(np.ceil(z_range / slice_thickness))
- n_slices = min(n_slices, 10000) # Limiter à 10k tranches max
-
- if n_slices <= 0:
- print(f" ⚠️ Invalid n_slices: {n_slices}")
- return np.array([]), np.array([]), np.array([]), np.array([])
-
- if verbose:
- print(f" Median Z spacing: {median_z_spacing:.1f}m")
- print(f" Creating {n_slices} slices")
-
- try:
- z_slices = np.linspace(z_max, z_min, n_slices + 1)
- except (MemoryError, ValueError) as e:
- print(f" ⚠️ Error creating slices: {e}")
- return np.array([]), np.array([]), np.array([]), np.array([])
-
- # ===================================================================
- # ÉTAPE 7: EXTRACTION PAR TRANCHES
- # ===================================================================
-
- profile_indices = []
-
- for i in range(len(z_slices) - 1):
- z_top = z_slices[i]
- z_bottom = z_slices[i + 1]
-
- # Cellules dans cette tranche
- mask_in_slice = (centers[:, 2] <= z_top) & (centers[:, 2] >= z_bottom)
- indices_in_slice = np.where(mask_in_slice)[0]
-
- if len(indices_in_slice) == 0:
- continue
-
- # Distance XY à la référence
- d_xy_in_slice = np.sqrt(
- (centers[indices_in_slice, 0] - ref_x)**2 +
- (centers[indices_in_slice, 1] - ref_y)**2
- )
-
- # Ne garder que celles dans la tolérance XY
- valid_mask = d_xy_in_slice < xy_tolerance
-
- if not np.any(valid_mask):
- # Aucune dans la tolérance → prendre la plus proche
- closest_in_slice = indices_in_slice[np.argmin(d_xy_in_slice)]
- else:
- # Prendre la plus proche parmi celles dans la tolérance
- valid_indices = indices_in_slice[valid_mask]
- d_xy_valid = d_xy_in_slice[valid_mask]
- closest_in_slice = valid_indices[np.argmin(d_xy_valid)]
-
- profile_indices.append(closest_in_slice)
-
- # ===================================================================
- # ÉTAPE 8: SUPPRIMER DOUBLONS ET TRIER
- # ===================================================================
-
- # Supprimer doublons
- seen = set()
- unique_indices = []
- for idx in profile_indices:
- if idx not in seen:
- seen.add(idx)
- unique_indices.append(idx)
-
- if len(unique_indices) == 0:
- if verbose:
- print(f" ⚠️ No points extracted")
- return np.array([]), np.array([]), np.array([]), np.array([])
-
- profile_indices = np.array(unique_indices)
-
- # Trier par profondeur décroissante (haut → bas)
- sort_order = np.argsort(-centers[profile_indices, 2])
- profile_indices = profile_indices[sort_order]
-
- # Extraire résultats
- depths = centers[profile_indices, 2]
- profile_values = values[profile_indices]
- path_x = centers[profile_indices, 0]
- path_y = centers[profile_indices, 1]
-
- # ===================================================================
- # STATISTIQUES
- # ===================================================================
-
- if verbose:
- depth_coverage = (depths.max() - depths.min()) / z_range * 100 if z_range > 0 else 0
- xy_displacement = np.sqrt((path_x[-1] - path_x[0])**2 + (path_y[-1] - path_y[0])**2)
-
- print(f" ✅ Extracted {len(profile_indices)} points")
- print(f" Depth range: [{depths.max():.1f}, {depths.min():.1f}]m")
- print(f" Coverage: {depth_coverage:.1f}% of fault depth")
- print(f" XY displacement: {xy_displacement:.1f}m")
-
- return (depths, profile_values, path_x, path_y)
-
- # -------------------------------------------------------------------
- @staticmethod
- def extract_vertical_profile_topology_based(surface_mesh, field_name, x_start, y_start, z_start=None,
- max_steps=500, verbose=True):
- """
- Extraction de profil vertical en utilisant la TOPOLOGIE du maillage de surface.
- """
-
- import pyvista as pv
-
- if field_name not in surface_mesh.cell_data:
- print(f" ⚠️ Field '{field_name}' not found in mesh")
- return np.array([]), np.array([]), np.array([]), np.array([])
-
- centers = surface_mesh.cell_centers().points
- values = surface_mesh.cell_data[field_name]
-
- # ===================================================================
- # ÉTAPE 1: TROUVER LA CELLULE DE DÉPART
- # ===================================================================
-
- if z_start is None:
- if verbose:
- print(f" Searching near ({x_start:.1f}, {y_start:.1f})")
-
- d_xy = np.sqrt((centers[:, 0] - x_start)**2 + (centers[:, 1] - y_start)**2)
- closest_indices = np.argsort(d_xy)[:20]
-
- if len(closest_indices) == 0:
- print(f" ⚠️ No cells found")
- return np.array([]), np.array([]), np.array([]), np.array([])
-
- closest_depths = centers[closest_indices, 2]
- start_idx = closest_indices[np.argmax(closest_depths)]
- else:
- if verbose:
- print(f" Searching near ({x_start:.1f}, {y_start:.1f}, {z_start:.1f})")
-
- d_3d = np.sqrt((centers[:, 0] - x_start)**2 +
- (centers[:, 1] - y_start)**2 +
- (centers[:, 2] - z_start)**2)
- start_idx = np.argmin(d_3d)
-
- start_point = centers[start_idx]
-
- if verbose:
- print(f" Starting cell: {start_idx}")
- print(f" Starting point: ({start_point[0]:.1f}, {start_point[1]:.1f}, {start_point[2]:.1f})")
-
- # ===================================================================
- # ÉTAPE 2: IDENTIFIER LA FAILLE
- # ===================================================================
-
- target_fault_id = None
- fault_ids = None
- fault_field_names = ['attribute', 'FaultMask', 'fault_id', 'region']
-
- for field_name_check in fault_field_names:
- if field_name_check in surface_mesh.cell_data:
- fault_ids = surface_mesh.cell_data[field_name_check]
- target_fault_id = fault_ids[start_idx]
-
- if verbose:
- unique_ids = np.unique(fault_ids)
- print(f" Fault field: '{field_name_check}'")
- print(f" Target fault ID: {target_fault_id} (from {unique_ids})")
-
- break
-
- if target_fault_id is None and verbose:
- print(f" ⚠️ No fault ID found - will use all cells")
-
- # ===================================================================
- # ÉTAPE 3: CONSTRUIRE LA CONNECTIVITÉ (VOISINS TOPOLOGIQUES)
- # ===================================================================
-
- if verbose:
- print(f" Building cell connectivity...")
-
- n_cells = surface_mesh.n_cells
- connectivity = [[] for _ in range(n_cells)]
-
- # Construire un dictionnaire arête -> cellules
- edge_to_cells = {}
-
- for cell_id in range(n_cells):
- cell = surface_mesh.get_cell(cell_id)
- n_points = cell.n_points
-
- # Pour chaque arête de la cellule
- for i in range(n_points):
- p1 = cell.point_ids[i]
- p2 = cell.point_ids[(i + 1) % n_points]
-
- # Arête normalisée (ordre canonique)
- edge = tuple(sorted([p1, p2]))
-
- if edge not in edge_to_cells:
- edge_to_cells[edge] = []
- edge_to_cells[edge].append(cell_id)
-
- # Pour chaque cellule, trouver ses voisins via arêtes partagées
- for cell_id in range(n_cells):
- cell = surface_mesh.get_cell(cell_id)
- n_points = cell.n_points
-
- neighbors_set = set()
-
- for i in range(n_points):
- p1 = cell.point_ids[i]
- p2 = cell.point_ids[(i + 1) % n_points]
- edge = tuple(sorted([p1, p2]))
-
- # Toutes les cellules partageant cette arête sont voisines
- for neighbor_id in edge_to_cells[edge]:
- if neighbor_id != cell_id:
- neighbors_set.add(neighbor_id)
-
- connectivity[cell_id] = list(neighbors_set)
-
- if verbose:
- avg_neighbors = np.mean([len(c) for c in connectivity])
- max_neighbors = np.max([len(c) for c in connectivity])
- print(f" Connectivity built: avg={avg_neighbors:.1f} neighbors/cell, max={max_neighbors}")
-
- # ===================================================================
- # ÉTAPE 4: ALGORITHME DE DESCENTE PAR VOISINAGE TOPOLOGIQUE
- # ===================================================================
-
- profile_indices = [start_idx]
- visited = {start_idx}
- current_idx = start_idx
-
- ref_xy = start_point[:2] # Position XY de référence
-
- if verbose:
- print(f" Starting descent from Z={start_point[2]:.1f}m...")
-
- stuck_count = 0
- max_stuck = 3
-
- for step in range(max_steps):
- current_z = centers[current_idx, 2]
-
- # Obtenir les voisins topologiques
- neighbor_indices = connectivity[current_idx]
-
- # Filtrer les voisins:
- # 1. Non visités
- # 2. Même faille (si détectée)
- # 3. Plus bas en Z
- candidates = []
-
- for idx in neighbor_indices:
- if idx in visited:
- continue
-
- # Vérifier la faille
- if target_fault_id is not None and fault_ids is not None:
- if fault_ids[idx] != target_fault_id:
- continue
-
- # Vérifier qu'on descend
- if centers[idx, 2] >= current_z:
- continue
-
- candidates.append(idx)
-
- if len(candidates) == 0:
- # Si bloqué, essayer de regarder les voisins des voisins
- stuck_count += 1
-
- if stuck_count >= max_stuck:
- if verbose:
- print(f" Reached bottom at Z={current_z:.1f}m after {step+1} steps (no more neighbors)")
- break
-
- # Essayer niveau 2 (voisins des voisins)
- extended_candidates = []
- for neighbor_idx in neighbor_indices:
- if neighbor_idx in visited:
- continue
-
- for second_neighbor_idx in connectivity[neighbor_idx]:
- if second_neighbor_idx in visited:
- continue
-
- if target_fault_id is not None and fault_ids is not None:
- if fault_ids[second_neighbor_idx] != target_fault_id:
- continue
-
- if centers[second_neighbor_idx, 2] < current_z:
- extended_candidates.append(second_neighbor_idx)
-
- if len(extended_candidates) == 0:
- if verbose:
- print(f" Reached bottom at Z={current_z:.1f}m (extended search failed)")
- break
-
- candidates = extended_candidates
- if verbose:
- print(f" Used extended search at step {step+1}")
- else:
- stuck_count = 0
-
- # Parmi les candidats, choisir celui le plus proche en XY de la référence
- best_idx = None
- best_distance_xy = float('inf')
-
- for idx in candidates:
- pos = centers[idx]
- d_xy = np.sqrt((pos[0] - ref_xy[0])**2 + (pos[1] - ref_xy[1])**2)
-
- if d_xy < best_distance_xy:
- best_distance_xy = d_xy
- best_idx = idx
-
- if best_idx is None:
- if verbose:
- print(f" No valid neighbor at Z={current_z:.1f}m")
- break
-
- # Ajouter au profil
- profile_indices.append(best_idx)
- visited.add(best_idx)
- current_idx = best_idx
-
- # Debug
- if verbose and (step + 1) % 100 == 0:
- print(f" Step {step+1}: Z={centers[current_idx, 2]:.1f}m, XY=({centers[current_idx, 0]:.1f}, {centers[current_idx, 1]:.1f})")
-
- # ===================================================================
- # ÉTAPE 5: EXTRAIRE LES RÉSULTATS
- # ===================================================================
-
- if len(profile_indices) == 0:
- if verbose:
- print(f" ⚠️ No profile extracted")
- return np.array([]), np.array([]), np.array([]), np.array([])
-
- profile_indices = np.array(profile_indices)
-
- depths = centers[profile_indices, 2]
- profile_values = values[profile_indices]
- path_x = centers[profile_indices, 0]
- path_y = centers[profile_indices, 1]
-
- # ===================================================================
- # STATISTIQUES
- # ===================================================================
-
- if verbose:
- z_range = np.max(centers[:, 2]) - np.min(centers[:, 2])
- depth_coverage = (depths.max() - depths.min()) / z_range * 100 if z_range > 0 else 0
- xy_displacement = np.sqrt((path_x[-1] - path_x[0])**2 + (path_y[-1] - path_y[0])**2)
-
- print(f" ✅ {len(profile_indices)} points extracted")
- print(f" Depth range: [{depths.max():.1f}, {depths.min():.1f}]m")
- print(f" Coverage: {depth_coverage:.1f}% of fault depth")
- print(f" XY displacement: {xy_displacement:.1f}m")
-
- return (depths, profile_values, path_x, path_y)
-
- # -------------------------------------------------------------------
- @staticmethod
- def plot_profile_path_3d(surface, path_x, path_y, path_z, profile_values=None,
- scalar_name='SCU', save_path=None, show=True):
- """
- Visualize the extracted profile path on the fault surface in 3D using PyVista.
-
- Parameters
- ----------
- surface : pyvista.PolyData
- Fault surface mesh
- path_x, path_y, path_z : array-like
- Coordinates of the profile path
- profile_values : array-like, optional
- Values along the profile (for coloring the path)
- scalar_name : str
- Name of the scalar to display on the surface
- save_path : Path or str, optional
- Path to save the screenshot
- show : bool
- Whether to display the plot interactively
- """
- import pyvista as pv
-
- if len(path_x) == 0:
- print(" ⚠️ No path to plot (empty profile)")
- return
-
- print(f" 📊 Creating 3D visualization of profile path ({len(path_x)} points)")
-
- # Create plotter
- plotter = pv.Plotter(window_size=[1600, 1200])
-
- # Add fault surface with scalar field
- if scalar_name in surface.cell_data:
- plotter.add_mesh(
- surface,
- scalars=scalar_name,
- cmap='RdYlGn_r',
- opacity=0.7,
- show_edges=False,
- lighting=True,
- smooth_shading=True,
- scalar_bar_args={
- 'title': scalar_name,
- 'title_font_size': 20,
- 'label_font_size': 16,
- 'n_labels': 5,
- 'italic': False,
- 'fmt': '%.2f',
- 'font_family': 'arial',
- }
- )
- else:
- plotter.add_mesh(
- surface,
- color='lightgray',
- opacity=0.5,
- show_edges=True
- )
-
- # Create path as a polyline
- path_points = np.column_stack([path_x, path_y, path_z])
- path_polyline = pv.PolyData(path_points)
-
- # Add connectivity for line
- n_points = len(path_points)
- lines = np.full((n_points - 1, 3), 2, dtype=np.int_)
- lines[:, 1] = np.arange(n_points - 1)
- lines[:, 2] = np.arange(1, n_points)
- path_polyline.lines = lines.ravel()
-
- # Color the path by profile values or depth
- if profile_values is not None:
- path_polyline['profile_value'] = profile_values
- color_field = 'profile_value'
- cmap_path = 'viridis'
- else:
- path_polyline['depth'] = path_z
- color_field = 'depth'
- cmap_path = 'turbo_r'
-
- # Add path as thick tube
- path_tube = path_polyline.tube(radius=10.0) # Adjust radius as needed
- plotter.add_mesh(
- path_tube,
- scalars=color_field,
- cmap=cmap_path,
- line_width=8,
- render_lines_as_tubes=True,
- lighting=True,
- scalar_bar_args={
- 'title': 'Path ' + color_field,
- 'title_font_size': 20,
- 'label_font_size': 16,
- 'position_x': 0.85,
- 'position_y': 0.05,
- }
- )
-
- # Add start and end markers
- start_point = pv.Sphere(radius=30, center=path_points[0])
- end_point = pv.Sphere(radius=30, center=path_points[-1])
-
- plotter.add_mesh(start_point, color='lime', label='Start (Top)')
- plotter.add_mesh(end_point, color='red', label='End (Bottom)')
-
- # Add axes and labels
- plotter.add_axes(
- xlabel='X [m]',
- ylabel='Y [m]',
- zlabel='Z [m]',
- line_width=3,
- labels_off=False
- )
-
- # Add legend
- plotter.add_legend(
- labels=[('Start (Top)', 'lime'), ('End (Bottom)', 'red')],
- bcolor='white',
- border=True,
- size=(0.15, 0.1),
- loc='upper left'
- )
-
- # Set camera and lighting
- plotter.camera_position = 'iso'
- plotter.add_light(pv.Light(position=(1, 1, 1), intensity=0.8))
-
- # Add title
- path_length = np.sum(np.sqrt(np.sum(np.diff(path_points, axis=0)**2, axis=1)))
- depth_range = path_z.max() - path_z.min()
- title = f'Profile Path Extraction\n'
- title += f'Points: {len(path_x)} | Length: {path_length:.1f}m | Depth range: {depth_range:.1f}m'
- plotter.add_text(title, position='upper_edge', font_size=14, color='black')
-
- # Save screenshot
- # if save_path is not None:
- # screenshot_path = save_path / 'profile_path_3d.png'
- # plotter.screenshot(str(screenshot_path))
- # print(f" 💾 Screenshot saved: {screenshot_path}")
-
- # Show
- if show:
- plotter.show()
- else:
- plotter.close()
-
-
-# ============================================================================
-# VISUALIZATION
-# ============================================================================
-class Visualizer:
- """Visualization utilities"""
-
- # -------------------------------------------------------------------
- def __init__(self, config):
- self.config = config
-
- # -------------------------------------------------------------------
- @staticmethod
- def plot_mohr_coulomb_diagram(surface, time, path, show=True, save=True):
- """Create Mohr-Coulomb diagram with depth coloring"""
-
- sigma_n = -surface.cell_data["sigma_n_eff"]
- tau = np.abs(surface.cell_data["tau_eff"])
- SCU = np.abs(surface.cell_data["SCU"])
- depth = surface.cell_data['elementCenter'][:, 2]
-
- cohesion = surface.cell_data["mohr_cohesion"][0]
- mu = surface.cell_data["mohr_friction_coefficient"][0]
- phi = surface.cell_data['mohr_friction_angle'][0]
-
- fig, axes = plt.subplots(1, 2, figsize=(16, 8))
-
- # Plot 1: τ vs σ_n
- ax1 = axes[0]
- sc1 = ax1.scatter(sigma_n, tau, c=depth, cmap='turbo_r', s=20, alpha=0.8)
- sigma_range = np.linspace(0, np.max(sigma_n), 100)
- tau_crit = cohesion + mu * sigma_range
- ax1.plot(sigma_range, tau_crit, 'k--', linewidth=2,
- label=f'M-C (C={cohesion} bar, φ={phi}°)')
- ax1.set_xlabel('Normal Stress [bar]')
- ax1.set_ylabel('Shear Stress [bar]')
- ax1.legend()
- ax1.grid(True, alpha=0.3)
- ax1.set_title('Mohr-Coulomb Diagram')
-
- # Plot 2: SCU vs σ_n
- ax2 = axes[1]
- sc2 = ax2.scatter(sigma_n, SCU, c=depth, cmap='turbo_r', s=20, alpha=0.8)
- ax2.axhline(y=1.0, color='r', linestyle='--', label='Failure (SCU=1)')
- ax2.set_xlabel('Normal Stress [bar]')
- ax2.set_ylabel('SCU [-]')
- ax2.legend()
- ax2.grid(True, alpha=0.3)
- ax2.set_title('Shear Capacity Utilization')
- ax2.set_ylim(bottom=0)
-
- plt.colorbar(sc2, ax=ax2, label='Depth [m]')
- plt.tight_layout()
-
- if save:
- years = time / (365.25 * 24 * 3600)
- filename = f'mohr_coulomb_phi{phi}_c{cohesion}_{years:.0f}y.png'
- plt.savefig(path / filename, dpi=300, bbox_inches='tight')
- print(f" 📊 Plot saved: {filename}")
-
- if show:
- plt.show()
- else:
- plt.close()
-
- # -------------------------------------------------------------------
- @staticmethod
- def load_reference_data(time, script_dir=None, profile_id=1):
- """
- Load GEOS and analytical reference data for comparison
-
- Parameters
- ----------
- time : float
- Current simulation time in seconds
- script_dir : str or Path, optional
- Directory containing reference data files. If None, uses current directory.
- profile_id : int, optional
- Profile ID to extract from Excel (default: 1)
-
- Returns
- -------
- dict
- Dictionary with keys 'geos' and 'analytical', each containing numpy arrays or None
- Format: {'geos': array or None, 'analytical': array or None}
-
- For GEOS data from Excel, the array has columns:
- [Depth_m, Normal_Stress_bar, Shear_Stress_bar, SCU, X_coordinate_m, Y_coordinate_m]
- """
- import pandas as pd
-
- if script_dir is None:
- script_dir = os.path.dirname(os.path.abspath(__file__))
-
- result = {'geos': None, 'analytical': None}
-
- # ===================================================================
- # LOAD GEOS DATA - Try Excel first, then CSV
- # ===================================================================
-
- geos_file_xlsx = 'geos_data_numerical.xlsx'
- geos_file_csv = 'geos_data_numerical.csv'
-
- # Try Excel format with time-based sheets
- geos_xlsx_path = os.path.join(script_dir, geos_file_xlsx)
-
- if os.path.exists(geos_xlsx_path):
- try:
- # Generate sheet name based on current time
- # Format: t_1.00e+02s
- sheet_name = f"t_{time:.2e}s"
-
- print(f" 📂 Loading GEOS data from Excel sheet: '{sheet_name}'")
-
- # Try to read the specific sheet
- try:
- df = pd.read_excel(geos_xlsx_path, sheet_name=sheet_name)
-
- # Filter by Profile_ID if column exists
- if 'Profile_ID' in df.columns:
- df_profile = df[df['Profile_ID'] == profile_id]
-
- if len(df_profile) == 0:
- print(f" ⚠️ Profile_ID {profile_id} not found in sheet '{sheet_name}'")
- print(f" Available Profile_IDs: {sorted(df['Profile_ID'].unique())}")
- # Take first profile as fallback
- available_ids = sorted(df['Profile_ID'].unique())
- if len(available_ids) > 0:
- fallback_id = available_ids[0]
- print(f" → Using Profile_ID {fallback_id} instead")
- df_profile = df[df['Profile_ID'] == fallback_id]
- else:
- print(f" ✅ Loaded Profile_ID {profile_id}: {len(df_profile)} points")
-
- # Extract relevant columns in the expected order
- # Expected: [Depth, Normal_Stress, Shear_Stress, SCU, ...]
- columns_to_extract = ['Depth_m', 'Normal_Stress_bar', 'Shear_Stress_bar', 'SCU']
-
- # Check which columns exist
- available_columns = [col for col in columns_to_extract if col in df_profile.columns]
-
- if len(available_columns) > 0:
- result['geos'] = df_profile[available_columns].values
- print(f" Extracted columns: {available_columns}")
- else:
- print(f" ⚠️ No expected columns found in DataFrame")
- print(f" Available columns: {list(df_profile.columns)}")
- else:
- # No Profile_ID column, use all data
- print(f" ℹ️ No Profile_ID column, using all data")
- columns_to_extract = ['Depth_m', 'Normal_Stress_bar', 'Shear_Stress_bar', 'SCU']
- available_columns = [col for col in columns_to_extract if col in df.columns]
-
- if len(available_columns) > 0:
- result['geos'] = df[available_columns].values
- print(f" ✅ Loaded {len(result['geos'])} points")
-
- except ValueError:
- # Sheet not found, try to find closest time
- print(f" ⚠️ Sheet '{sheet_name}' not found, searching for closest time...")
-
- # Read all sheet names
- xl_file = pd.ExcelFile(geos_xlsx_path)
- sheet_names = xl_file.sheet_names
-
- # Extract times from sheet names
- sheet_times = []
- for sname in sheet_names:
- if sname.startswith('t_') and sname.endswith('s'):
- try:
- # Extract time: t_1.00e+02s -> 100.0
- time_str = sname[2:-1] # Remove 't_' and 's'
- sheet_time = float(time_str)
- sheet_times.append((sheet_time, sname))
- except:
- continue
-
- if sheet_times:
- # Find closest time
- sheet_times.sort(key=lambda x: abs(x[0] - time))
- closest_time, closest_sheet = sheet_times[0]
- time_diff = abs(closest_time - time)
-
- print(f" → Using closest sheet: '{closest_sheet}' (Δt={time_diff:.2e}s)")
- df = pd.read_excel(geos_xlsx_path, sheet_name=closest_sheet)
-
- # Filter by Profile_ID
- if 'Profile_ID' in df.columns:
- df_profile = df[df['Profile_ID'] == profile_id]
-
- if len(df_profile) == 0:
- # Fallback to first profile
- available_ids = sorted(df['Profile_ID'].unique())
- if len(available_ids) > 0:
- df_profile = df[df['Profile_ID'] == available_ids[0]]
- print(f" → Using Profile_ID {available_ids[0]}")
-
- columns_to_extract = ['Depth_m', 'Normal_Stress_bar', 'Shear_Stress_bar', 'SCU']
- available_columns = [col for col in columns_to_extract if col in df_profile.columns]
-
- if len(available_columns) > 0:
- result['geos'] = df_profile[available_columns].values
- print(f" ✅ Loaded {len(result['geos'])} points")
- else:
- # Use all data
- columns_to_extract = ['Depth_m', 'Normal_Stress_bar', 'Shear_Stress_bar', 'SCU']
- available_columns = [col for col in columns_to_extract if col in df.columns]
-
- if len(available_columns) > 0:
- result['geos'] = df[available_columns].values
- print(f" ✅ Loaded {len(result['geos'])} points")
- else:
- print(f" ⚠️ No valid time sheets found in Excel file")
-
- except ImportError:
- print(f" ⚠️ pandas not available, cannot read Excel file")
- except Exception as e:
- print(f" ⚠️ Error reading Excel: {e}")
- import traceback
- traceback.print_exc()
-
- # Fallback to CSV if Excel not found or failed
- if result['geos'] is None:
- geos_csv_path = os.path.join(script_dir, geos_file_csv)
- if os.path.exists(geos_csv_path):
- try:
- result['geos'] = np.loadtxt(geos_csv_path, delimiter=',', skiprows=1)
- print(f" ✅ GEOS data loaded from CSV: {len(result['geos'])} points")
- except Exception as e:
- print(f" ⚠️ Error reading CSV: {e}")
-
- # ===================================================================
- # LOAD ANALYTICAL DATA
- # ===================================================================
-
- analytical_file = 'analytical_data.csv'
- analytical_path = os.path.join(script_dir, analytical_file)
-
- if os.path.exists(analytical_path):
- try:
- result['analytical'] = np.loadtxt(analytical_path, delimiter=',', skiprows=1)
- print(f" ✅ Analytical data loaded: {len(result['analytical'])} points")
- except Exception as e:
- print(f" ⚠️ Error loading analytical data: {e}")
-
- return result
-
- # -------------------------------------------------------------------
- @staticmethod
- def plot_depth_profiles(self, surface, time, path, show=True, save=True,
- profile_start_points=None,
- max_profile_points=1000,
- reference_profile_id=1
- ):
-
- """
- Plot vertical profiles along the fault showing stress and SCU vs depth
- """
-
- print(" 📊 Creating depth profiles ")
-
- # Extract data
- centers = surface.cell_data['elementCenter']
- depth = centers[:, 2]
- sigma_n = surface.cell_data['sigma_n_eff']
- tau = surface.cell_data['tau_eff']
- SCU = surface.cell_data['SCU']
- SCU = np.sqrt(SCU**2)
- delta_SCU = surface.cell_data['delta_SCU']
-
- # Extraire les IDs de faille
- fault_ids = None
- if 'FaultMask' in surface.cell_data:
- fault_ids = surface.cell_data['FaultMask']
- print(f" 📋 Detected {len(np.unique(fault_ids[fault_ids > 0]))} distinct faults")
- elif 'attribute' in surface.cell_data:
- fault_ids = surface.cell_data['attribute']
- print(f" 📋 Using 'attribute' field for fault identification")
- else:
- print(f" ⚠️ No fault IDs found - profiles may jump between faults")
-
- # ===================================================================
- # LOAD REFERENCE DATA (GEOS + Analytical)
- # ===================================================================
- script_dir = os.path.dirname(os.path.abspath(__file__))
- reference_data = Visualizer.load_reference_data(
- time,
- script_dir,
- profile_id=reference_profile_id
- )
-
- geos_data = reference_data['geos']
- analytical_data = reference_data['analytical']
-
- # ===================================================================
- # PROFILE EXTRACTION SETUP
- # ===================================================================
-
- # Get fault bounds
- x_min, x_max = np.min(centers[:, 0]), np.max(centers[:, 0])
- y_min, y_max = np.min(centers[:, 1]), np.max(centers[:, 1])
- z_min, z_max = np.min(depth), np.max(depth)
-
- # Auto-compute search radius if not provided
- x_range = x_max - x_min
- y_range = y_max - y_min
- z_range = z_max - z_min
-
- if self.config.PROFILE_SEARCH_RADIUS is not None:
- search_radius = self.config.PROFILE_SEARCH_RADIUS
- else:
- search_radius = min(x_range, y_range) * 0.15
-
-
- # Auto-generate profile points if not provided
- if profile_start_points is None:
- print(" ⚠️ No profile_start_points provided, auto-generating 5 profiles...")
- n_profiles = 5
-
- # Determine dominant fault direction
- if x_range > y_range:
- coord_name = 'X'
- fixed_value = (y_min + y_max) / 2
- sample_positions = np.linspace(x_min, x_max, n_profiles)
- profile_start_points = [(x, fixed_value) for x in sample_positions]
- else:
- coord_name = 'Y'
- fixed_value = (x_min + x_max) / 2
- sample_positions = np.linspace(y_min, y_max, n_profiles)
- profile_start_points = [(fixed_value, y) for y in sample_positions]
-
- print(f" Auto-generated {n_profiles} profiles along {coord_name} direction")
-
- n_profiles = len(profile_start_points)
-
- # ===================================================================
- # CREATE FIGURE
- # ===================================================================
-
- fig, axes = plt.subplots(1, 4, figsize=(24, 12))
- colors = plt.cm.RdYlGn(np.linspace(0, 1, n_profiles))
-
- print(f" 📍 Processing {n_profiles} profiles:")
- print(f" Depth range: [{z_min:.1f}, {z_max:.1f}]m")
-
- successful_profiles = 0
-
- # ===================================================================
- # EXTRACT AND PLOT PROFILES
- # ===================================================================
-
- for i, (x_pos, y_pos, z_pos) in enumerate(profile_start_points):
- print(f" → Profile {i+1}: starting at ({x_pos:.1f}, {y_pos:.1f}, {z_pos:.1f})")
-
- # depths_sigma, profile_sigma_n, path_x_s, path_y_s = ProfileExtractor.extract_vertical_profile_topology_based(
- # surface, 'sigma_n_eff', x_pos, y_pos, z_pos, verbose=True)
-
- # depths_tau, profile_tau, _, _ = ProfileExtractor.extract_vertical_profile_topology_based(
- # surface, 'tau_eff', x_pos, y_pos, z_pos, verbose=False)
-
- # depths_SCU, profile_SCU, _, _ = ProfileExtractor.extract_vertical_profile_topology_based(
- # surface, 'SCU', x_pos, y_pos, z_pos, verbose=False)
-
- # depths_deltaSCU, profile_deltaSCU, _, _ = ProfileExtractor.extract_vertical_profile_topology_based(
- # surface, 'delta_SCU', x_pos, y_pos, z_pos, verbose=False)
-
- depths_sigma, profile_sigma_n, path_x_s, path_y_s = ProfileExtractor.extract_adaptive_profile(
- centers, sigma_n, x_pos, y_pos, search_radius)
-
- depths_tau, profile_tau, _, _ = ProfileExtractor.extract_adaptive_profile(
- centers, tau, x_pos, y_pos, search_radius)
-
- depths_SCU, profile_SCU, _, _ = ProfileExtractor.extract_adaptive_profile(
- centers, SCU, x_pos, y_pos, search_radius)
-
- depths_deltaSCU, profile_deltaSCU, _, _ = ProfileExtractor.extract_adaptive_profile(
- centers, SCU, x_pos, y_pos, search_radius)
-
- # Calculate path length
- if len(path_x_s) > 1:
- path_length = np.sum(np.sqrt(
- np.diff(path_x_s)**2 +
- np.diff(path_y_s)**2 +
- np.diff(depths_sigma)**2
- ))
- print(f" Path length: {path_length:.1f}m (horizontal displacement: {np.abs(path_x_s[-1] - path_x_s[0]):.1f}m)")
-
- if self.config.SHOW_PROFILE_EXTRACTOR:
- ProfileExtractor.plot_profile_path_3d(
- surface=surface,
- path_x=path_x_s,
- path_y=path_y_s,
- path_z=depths_sigma,
- profile_values=profile_sigma_n,
- scalar_name='SCU',
- save_path=path,
- show=show
- )
-
- # Check if we have enough points
- min_points = 3
- n_points = len(depths_sigma)
-
- if n_points >= min_points:
- label = f'Profile {i+1} → ({x_pos:.0f}, {y_pos:.0f})'
-
- # Plot 1: Normal stress vs depth
- axes[0].plot(profile_sigma_n, depths_sigma,
- color=colors[i], label=label, linewidth=2.5, alpha=0.8,
- marker='o', markersize=3, markevery=2)
-
- # Plot 2: Shear stress vs depth
- axes[1].plot(profile_tau, depths_tau,
- color=colors[i], label=label, linewidth=2.5, alpha=0.8,
- marker='o', markersize=3, markevery=2)
-
- # Plot 3: SCU vs depth
- axes[2].plot(profile_SCU, depths_SCU,
- color=colors[i], label=label, linewidth=2.5, alpha=0.8,
- marker='o', markersize=3, markevery=2)
-
- # Plot 4: Detla SCU vs depth
- axes[3].plot(profile_deltaSCU, depths_deltaSCU,
- color=colors[i], label=label, linewidth=2.5, alpha=0.8,
- marker='o', markersize=3, markevery=2)
-
- successful_profiles += 1
- print(f" ✅ {n_points} points found")
- else:
- print(f" ⚠️ Insufficient points ({n_points}), skipping")
-
- if successful_profiles == 0:
- print(" ❌ No valid profiles found!")
- plt.close()
- return
-
- # ===================================================================
- # ADD REFERENCE DATA (GEOS + Analytical) - Only once
- # ===================================================================
-
- if geos_data is not None:
- # Colonnes: [Depth_m, Normal_Stress_bar, Shear_Stress_bar, SCU]
- # Index: [0, 1, 2, 3]
-
- axes[0].plot(geos_data[:, 1] *10, geos_data[:, 0], 'o',
- color='blue', markersize=6, label='GEOS Contact Solver',
- alpha=0.7, mec='k', mew=1, fillstyle='none')
-
- axes[1].plot(geos_data[:, 2] *10, geos_data[:, 0], 'o',
- color='blue', markersize=6, label='GEOS Contact Solver',
- alpha=0.7, mec='k', mew=1, fillstyle='none')
-
- if geos_data.shape[1] > 3: # SCU column exists
- axes[2].plot(geos_data[:, 3], geos_data[:, 0], 'o',
- color='blue', markersize=6, label='GEOS Contact Solver',
- alpha=0.7, mec='k', mew=1, fillstyle='none')
-
- if analytical_data is not None:
- # Format analytique (peut varier)
- axes[0].plot(analytical_data[:, 1] * 10, analytical_data[:, 0], '--',
- color='darkorange', linewidth=2, label='Analytical', alpha=0.8)
- if analytical_data.shape[1] > 2:
- axes[1].plot(analytical_data[:, 2] * 10, analytical_data[:, 0], '--',
- color='darkorange', linewidth=2, label='Analytical', alpha=0.8)
-
- # ===================================================================
- # CONFIGURE PLOTS
- # ===================================================================
-
- fsize = 14
-
- # Plot 1: Normal Stress
- axes[0].set_xlabel('Normal Stress σₙ [bar]', fontsize=fsize, weight="bold")
- axes[0].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
- axes[0].set_title('Normal Stress Profile', fontsize=fsize+2, weight="bold")
- axes[0].grid(True, alpha=0.3, linestyle='--')
- axes[0].legend(loc='upper left', fontsize=fsize-2)
- axes[0].tick_params(labelsize=fsize-2)
-
- # Plot 2: Shear Stress
- axes[1].set_xlabel('Shear Stress τ [bar]', fontsize=fsize, weight="bold")
- axes[1].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
- axes[1].set_title('Shear Stress Profile', fontsize=fsize+2, weight="bold")
- axes[1].grid(True, alpha=0.3, linestyle='--')
- axes[1].legend(loc='upper left', fontsize=fsize-2)
- axes[1].tick_params(labelsize=fsize-2)
-
- # Plot 3: SCU
- axes[2].set_xlabel('SCU [-]', fontsize=fsize, weight="bold")
- axes[2].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
- axes[2].set_title('Shear Capacity Utilization', fontsize=fsize+2, weight="bold")
- axes[2].axvline(x=0.8, color='forestgreen', linestyle='--', linewidth=2, label='Critical (0.8)')
- axes[2].axvline(x=1.0, color='red', linestyle='--', linewidth=2, label='Failure (1.0)')
- axes[2].grid(True, alpha=0.3, linestyle='--')
- axes[2].legend(loc='upper right', fontsize=fsize-2)
- axes[2].tick_params(labelsize=fsize-2)
- axes[2].set_xlim(left=0)
-
- # Plot 4: Delta SCU
- axes[3].set_xlabel('Δ SCU [-]', fontsize=fsize, weight="bold")
- axes[3].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
- axes[3].set_title('Delta SCU', fontsize=fsize+2, weight="bold")
- axes[3].grid(True, alpha=0.3, linestyle='--')
- axes[3].legend(loc='upper right', fontsize=fsize-2)
- axes[3].tick_params(labelsize=fsize-2)
- axes[3].set_xlim(left=0, right=2)
-
- # Change verticale scale
- if self.config.MAX_DEPTH_PROFILES != None :
- for i in range(len(axes)):
- axes[i].set_ylim(bottom=self.config.MAX_DEPTH_PROFILES)
-
- if self.config.MIN_DEPTH_PROFILES != None :
- for i in range(len(axes)):
- axes[i].set_ylim(top=self.config.MIN_DEPTH_PROFILES)
-
- # Overall title
- years = time / (365.25 * 24 * 3600)
- fig.suptitle(f'Fault Depth Profiles - t={years:.1f} years',
- fontsize=fsize+2, fontweight='bold', y=0.98)
-
- plt.tight_layout(rect=[0, 0, 1, 0.96])
-
- # Save
- if save:
- filename = f'depth_profiles_{years:.0f}y.png'
- plt.savefig(path / filename, dpi=300, bbox_inches='tight')
- print(f" 💾 Depth profiles saved: {filename}")
-
- # Show
- if show:
- plt.show()
- else:
- plt.close()
-
- # -------------------------------------------------------------------
- def plot_volume_stress_profiles(self, volume_mesh, fault_surface, time, path,
- show=True, save=True,
- profile_start_points=None,
- max_profile_points=1000):
- """
- Plot stress profiles in volume cells adjacent to the fault
- Extracts profiles through contributing cells on BOTH sides of the fault
- Shows plus side and minus side on the same plots for comparison
-
- NOTE: Cette fonction utilise extract_adaptive_profile pour les VOLUMES
- car volume_mesh n'est PAS un maillage surfacique.
- La méthode topologique (extract_vertical_profile_topology_based)
- est réservée aux maillages SURFACIQUES (fault_surface).
- """
-
- print(" 📊 Creating volume stress profiles (both sides)")
-
- # ===================================================================
- # CHECK IF REQUIRED DATA EXISTS
- # ===================================================================
-
- required_fields = ['sigma1', 'sigma2', 'sigma3', 'side', 'elementCenter']
-
- for field in required_fields:
- if field not in volume_mesh.cell_data:
- print(f" ⚠️ Missing required field: {field}")
- return
-
- # Check for pressure
- if 'pressure_bar' in volume_mesh.cell_data:
- pressure_field = 'pressure_bar'
- pressure = volume_mesh.cell_data[pressure_field]
- elif 'pressure' in volume_mesh.cell_data:
- pressure_field = 'pressure'
- pressure = volume_mesh.cell_data[pressure_field] / 1e5
- print(" ℹ️ Converting pressure from Pa to bar")
- else:
- print(" ⚠️ No pressure field found")
- pressure = None
-
- # Extract volume data
- centers = volume_mesh.cell_data['elementCenter']
- sigma1 = volume_mesh.cell_data['sigma1']
- sigma2 = volume_mesh.cell_data['sigma2']
- sigma3 = volume_mesh.cell_data['sigma3']
- side_data = volume_mesh.cell_data['side']
-
- # ===================================================================
- # FILTER CELLS BY SIDE (BOTH PLUS AND MINUS)
- # ===================================================================
-
- # Plus side (side = 1 or 3)
- mask_plus = (side_data == 1) | (side_data == 3)
- centers_plus = centers[mask_plus]
- sigma1_plus = sigma1[mask_plus]
- sigma2_plus = sigma2[mask_plus]
- sigma3_plus = sigma3[mask_plus]
- if pressure is not None:
- pressure_plus = pressure[mask_plus]
-
- # Créer subset de cell_data pour le côté plus
- cell_data_plus = {}
- for key in volume_mesh.cell_data.keys():
- cell_data_plus[key] = volume_mesh.cell_data[key][mask_plus]
-
- # Minus side (side = 2 or 3)
- mask_minus = (side_data == 2) | (side_data == 3)
- centers_minus = centers[mask_minus]
- sigma1_minus = sigma1[mask_minus]
- sigma2_minus = sigma2[mask_minus]
- sigma3_minus = sigma3[mask_minus]
- if pressure is not None:
- pressure_minus = pressure[mask_minus]
-
- # Créer subset de cell_data pour le côté minus
- cell_data_minus = {}
- for key in volume_mesh.cell_data.keys():
- cell_data_minus[key] = volume_mesh.cell_data[key][mask_minus]
-
- print(f" 📍 Plus side: {len(centers_plus):,} cells")
- print(f" 📍 Minus side: {len(centers_minus):,} cells")
-
- if len(centers_plus) == 0 and len(centers_minus) == 0:
- print(" ⚠️ No contributing cells found!")
- return
-
- # ===================================================================
- # GET FAULT BOUNDS
- # ===================================================================
-
- fault_centers = fault_surface.cell_data['elementCenter']
-
- x_min, x_max = np.min(fault_centers[:, 0]), np.max(fault_centers[:, 0])
- y_min, y_max = np.min(fault_centers[:, 1]), np.max(fault_centers[:, 1])
- z_min, z_max = np.min(fault_centers[:, 2]), np.max(fault_centers[:, 2])
-
- x_range = x_max - x_min
- y_range = y_max - y_min
- z_range = z_max - z_min
-
- # Search radius (pour extract_adaptive_profile sur volumes)
- if self.config.PROFILE_SEARCH_RADIUS is not None:
- search_radius = self.config.PROFILE_SEARCH_RADIUS
- else:
- search_radius = min(x_range, y_range) * 0.2
-
- # ===================================================================
- # AUTO-GENERATE PROFILE POINTS IF NOT PROVIDED
- # ===================================================================
-
- if profile_start_points is None:
- print(" ⚠️ No profile_start_points provided, auto-generating...")
- n_profiles = 3
-
- if x_range > y_range:
- coord_name = 'X'
- fixed_value = (y_min + y_max) / 2
- sample_positions = np.linspace(x_min, x_max, n_profiles)
- profile_start_points = [(x, fixed_value, z_max) for x in sample_positions]
- else:
- coord_name = 'Y'
- fixed_value = (x_min + x_max) / 2
- sample_positions = np.linspace(y_min, y_max, n_profiles)
- profile_start_points = [(fixed_value, y, z_max) for y in sample_positions]
-
- print(f" Auto-generated {n_profiles} profiles along {coord_name}")
-
- n_profiles = len(profile_start_points)
-
- # ===================================================================
- # CREATE FIGURE WITH 5 SUBPLOTS
- # ===================================================================
-
- fig, axes = plt.subplots(1, 5, figsize=(22, 10))
-
- # Colors: different for plus and minus sides
- colors_plus = plt.cm.Reds(np.linspace(0.4, 0.9, n_profiles))
- colors_minus = plt.cm.Blues(np.linspace(0.4, 0.9, n_profiles))
-
- print(f" 📍 Processing {n_profiles} volume profiles:")
- print(f" Depth range: [{z_min:.1f}, {z_max:.1f}]m")
-
- successful_profiles = 0
-
- # ===================================================================
- # EXTRACT AND PLOT PROFILES FOR BOTH SIDES
- # ===================================================================
-
- for i, (x_pos, y_pos, z_pos) in enumerate(profile_start_points):
- print(f"\n → Profile {i+1}: starting at ({x_pos:.1f}, {y_pos:.1f}, {z_pos:.1f})")
-
- # ================================================================
- # PLUS SIDE
- # ================================================================
- if len(centers_plus) > 0:
- print(f" Processing PLUS side...")
-
- # Pour VOLUMES, utiliser extract_adaptive_profile avec cell_data
- depths_s1_p, profile_s1_p, _, _ = ProfileExtractor.extract_adaptive_profile(
- centers_plus, sigma1_plus, x_pos, y_pos, z_pos,
- search_radius, verbose=True, cell_data=cell_data_plus)
-
- depths_s2_p, profile_s2_p, _, _ = ProfileExtractor.extract_adaptive_profile(
- centers_plus, sigma2_plus, x_pos, y_pos, z_pos,
- search_radius, verbose=False, cell_data=cell_data_plus)
-
- depths_s3_p, profile_s3_p, _, _ = ProfileExtractor.extract_adaptive_profile(
- centers_plus, sigma3_plus, x_pos, y_pos, z_pos,
- search_radius, verbose=False, cell_data=cell_data_plus)
-
- if pressure is not None:
- depths_p_p, profile_p_p, _, _ = ProfileExtractor.extract_adaptive_profile(
- centers_plus, pressure_plus, x_pos, y_pos, z_pos,
- search_radius, verbose=False, cell_data=cell_data_plus)
-
- if len(depths_s1_p) >= 3:
- label_plus = f'Plus side'
-
- # Plot Pressure
- if pressure is not None:
- axes[0].plot(profile_p_p, depths_p_p,
- color=colors_plus[i], label=label_plus if i == 0 else '',
- linewidth=2.5, alpha=0.8, marker='o', markersize=3, markevery=2)
-
- # Plot σ1
- axes[1].plot(profile_s1_p, depths_s1_p,
- color=colors_plus[i], label=label_plus if i == 0 else '',
- linewidth=2.5, alpha=0.8, marker='o', markersize=3, markevery=2)
-
- # Plot σ2
- axes[2].plot(profile_s2_p, depths_s2_p,
- color=colors_plus[i], label=label_plus if i == 0 else '',
- linewidth=2.5, alpha=0.8, marker='o', markersize=3, markevery=2)
-
- # Plot σ3
- axes[3].plot(profile_s3_p, depths_s3_p,
- color=colors_plus[i], label=label_plus if i == 0 else '',
- linewidth=2.5, alpha=0.8, marker='o', markersize=3, markevery=2)
-
- # Plot All stresses
- axes[4].plot(profile_s1_p, depths_s1_p,
- color=colors_plus[i], linewidth=2.5, alpha=0.8,
- linestyle='-', marker="o", markersize=2, markevery=2)
- axes[4].plot(profile_s2_p, depths_s2_p,
- color=colors_plus[i], linewidth=2.0, alpha=0.6,
- linestyle='-', marker="s", markersize=2, markevery=2)
- axes[4].plot(profile_s3_p, depths_s3_p,
- color=colors_plus[i], linewidth=2.5, alpha=0.8,
- linestyle='-', marker="v", markersize=2, markevery=2)
-
- print(f" ✅ PLUS: {len(depths_s1_p)} points")
- successful_profiles += 1
-
- # ================================================================
- # MINUS SIDE
- # ================================================================
- if len(centers_minus) > 0:
- print(f" Processing MINUS side...")
-
- # Pour VOLUMES, utiliser extract_adaptive_profile avec cell_data
- depths_s1_m, profile_s1_m, _, _ = ProfileExtractor.extract_adaptive_profile(
- centers_minus, sigma1_minus, x_pos, y_pos, z_pos,
- search_radius, verbose=True, cell_data=cell_data_minus)
-
- depths_s2_m, profile_s2_m, _, _ = ProfileExtractor.extract_adaptive_profile(
- centers_minus, sigma2_minus, x_pos, y_pos, z_pos,
- search_radius, verbose=False, cell_data=cell_data_minus)
-
- depths_s3_m, profile_s3_m, _, _ = ProfileExtractor.extract_adaptive_profile(
- centers_minus, sigma3_minus, x_pos, y_pos, z_pos,
- search_radius, verbose=False, cell_data=cell_data_minus)
-
- if pressure is not None:
- depths_p_m, profile_p_m, _, _ = ProfileExtractor.extract_adaptive_profile(
- centers_minus, pressure_minus, x_pos, y_pos, z_pos,
- search_radius, verbose=False, cell_data=cell_data_minus)
-
- if len(depths_s1_m) >= 3:
- label_minus = f'Minus side'
-
- # Plot Pressure
- if pressure is not None:
- axes[0].plot(profile_p_m, depths_p_m,
- color=colors_minus[i], label=label_minus if i == 0 else '',
- linewidth=2.5, alpha=0.8, marker='s', markersize=3, markevery=2)
-
- # Plot σ1
- axes[1].plot(profile_s1_m, depths_s1_m,
- color=colors_minus[i], label=label_minus if i == 0 else '',
- linewidth=2.5, alpha=0.8, marker='s', markersize=3, markevery=2)
-
- # Plot σ2
- axes[2].plot(profile_s2_m, depths_s2_m,
- color=colors_minus[i], label=label_minus if i == 0 else '',
- linewidth=2.5, alpha=0.8, marker='s', markersize=3, markevery=2)
-
- # Plot σ3
- axes[3].plot(profile_s3_m, depths_s3_m,
- color=colors_minus[i], label=label_minus if i == 0 else '',
- linewidth=2.5, alpha=0.8, marker='s', markersize=3, markevery=2)
-
- # Plot All stresses
- axes[4].plot(profile_s1_m, depths_s1_m,
- color=colors_minus[i], linewidth=2.5, alpha=0.8,
- linestyle='-', marker="o", markersize=2, markevery=2)
- axes[4].plot(profile_s2_m, depths_s2_m,
- color=colors_minus[i], linewidth=2.0, alpha=0.6,
- linestyle='-', marker="s", markersize=2, markevery=2)
- axes[4].plot(profile_s3_m, depths_s3_m,
- color=colors_minus[i], linewidth=2.5, alpha=0.8,
- linestyle='-', marker='v', markersize=2, markevery=2)
-
- print(f" ✅ MINUS: {len(depths_s1_m)} points")
- successful_profiles += 1
-
- if successful_profiles == 0:
- print(" ❌ No valid profiles found!")
- plt.close()
- return
-
- # ===================================================================
- # CONFIGURE PLOTS
- # ===================================================================
-
- fsize = 14
-
- # Plot 0: Pressure
- axes[0].set_xlabel('Pressure [bar]', fontsize=fsize, weight="bold")
- axes[0].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
- axes[0].grid(True, alpha=0.3, linestyle='--')
- axes[0].legend(loc='best', fontsize=fsize-2)
- axes[0].tick_params(labelsize=fsize-2)
-
- if pressure is None:
- axes[0].text(0.5, 0.5, 'No pressure data available',
- ha='center', va='center', transform=axes[0].transAxes,
- fontsize=fsize, style='italic', color='gray')
-
- # Plot 1: σ1 (Maximum principal stress)
- axes[1].set_xlabel('σ₁ (Max Principal) [bar]', fontsize=fsize, weight="bold")
- axes[1].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
- axes[1].grid(True, alpha=0.3, linestyle='--')
- axes[1].legend(loc='best', fontsize=fsize-2)
- axes[1].tick_params(labelsize=fsize-2)
-
- # Plot 2: σ2 (Intermediate principal stress)
- axes[2].set_xlabel('σ₂ (Inter Principal) [bar]', fontsize=fsize, weight="bold")
- axes[2].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
- axes[2].grid(True, alpha=0.3, linestyle='--')
- axes[2].legend(loc='best', fontsize=fsize-2)
- axes[2].tick_params(labelsize=fsize-2)
-
- # Plot 3: σ3 (Min principal stress)
- axes[3].set_xlabel('σ₃ (Min Principal) [bar]', fontsize=fsize, weight="bold")
- axes[3].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
- axes[3].grid(True, alpha=0.3, linestyle='--')
- axes[3].legend(loc='best', fontsize=fsize-2)
- axes[3].tick_params(labelsize=fsize-2)
-
- # Plot 4: All stresses together
- axes[4].set_xlabel('Principal Stresses [bar]', fontsize=fsize, weight="bold")
- axes[4].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
- axes[4].grid(True, alpha=0.3, linestyle='--')
- axes[4].tick_params(labelsize=fsize-2)
-
- # Add legend for line styles
- from matplotlib.lines import Line2D
- custom_lines = [
- Line2D([0], [0], color='red', linewidth=2.5, marker=None, label='Plus side', alpha=0.5),
- Line2D([0], [0], color='blue', linewidth=2.5, marker=None, label='Minus side', alpha=0.5),
- Line2D([0], [0], color='gray', linewidth=2.5, linestyle='-', marker='o', label='σ₁ (max)'),
- Line2D([0], [0], color='gray', linewidth=2.0, linestyle='-', marker='s', label='σ₂ (inter)'),
- Line2D([0], [0], color='gray', linewidth=2.5, linestyle='-', marker='v', label='σ₃ (min)')
- ]
- axes[4].legend(handles=custom_lines, loc='best', fontsize=fsize-3, ncol=1)
-
- # Change verticale scale
- if self.config.MAX_DEPTH_PROFILES != None :
- for i in range(len(axes)):
- axes[i].set_ylim(bottom=self.config.MAX_DEPTH_PROFILES)
-
- if self.config.MIN_DEPTH_PROFILES != None :
- for i in range(len(axes)):
- axes[i].set_ylim(top=self.config.MIN_DEPTH_PROFILES)
-
- # Overall title
- years = time / (365.25 * 24 * 3600)
- fig.suptitle(f'Volume Stress Profiles - Both Sides Comparison - t={years:.1f} years',
- fontsize=fsize+2, fontweight='bold', y=0.98)
-
- plt.tight_layout(rect=[0, 0, 1, 0.96])
-
- # Save
- if save:
- filename = f'volume_stress_profiles_both_sides_{years:.0f}y.png'
- plt.savefig(path / filename, dpi=300, bbox_inches='tight')
- print(f" 💾 Volume profiles saved: {filename}")
-
- # Show
- if show:
- plt.show()
- else:
- plt.close()
-
- # -------------------------------------------------------------------
- def plot_analytical_vs_numerical_comparison(self, volume_mesh, fault_surface, time, path,
- show=True, save=True,
- profile_start_points=None,
- reference_profile_id=1):
- """
- Plot comparison between analytical fault stresses (Anderson formulas)
- and numerical tensor projection - COMBINED PLOTS ONLY
-
- Parameters
- ----------
- volume_mesh : pyvista.UnstructuredGrid
- Volume mesh with principal stresses AND analytical stresses
- fault_surface : pyvista.PolyData
- Fault surface mesh with projected stresses
- time : float
- Simulation time
- path : Path
- Output directory
- show : bool
- Show plot interactively
- save : bool
- Save plot to file
- profile_start_points : list of tuples
- Starting points (x, y, z) for profiles
- reference_profile_id : int
- Which profile ID to load from Excel reference data
- """
-
- print("\n 📊 Creating Analytical vs Numerical Comparison")
-
- # ===================================================================
- # CHECK IF ANALYTICAL DATA EXISTS
- # ===================================================================
-
- required_analytical = ['sigma_n_analytical', 'tau_analytical', 'side', 'elementCenter']
-
- for field in required_analytical:
- if field not in volume_mesh.cell_data:
- print(f" ⚠️ Missing analytical field: {field}")
- print(f" Analytical stresses not computed in volume mesh")
- return
-
- # Check numerical data on fault surface
- if 'sigma_n_eff' not in fault_surface.cell_data:
- print(f" ⚠️ Missing numerical stress data on fault surface")
- return
-
- # ===================================================================
- # LOAD REFERENCE DATA (GEOS Contact Solver)
- # ===================================================================
-
- print(" 📂 Loading GEOS Contact Solver reference data...")
- script_dir = os.path.dirname(os.path.abspath(__file__))
- reference_data = Visualizer.load_reference_data(
- time,
- script_dir,
- profile_id=reference_profile_id
- )
-
- geos_contact_data = reference_data.get('geos', None)
-
- if geos_contact_data is not None:
- print(f" ✅ Loaded {len(geos_contact_data)} reference points from GEOS Contact Solver")
- else:
- print(f" ⚠️ No GEOS Contact Solver reference data found")
-
- # Extraire les IDs de faille
- fault_ids_volume = None
- fault_ids_surface = None
-
- if 'fault_id' in volume_mesh.cell_data:
- fault_ids_volume = volume_mesh.cell_data['fault_id']
-
- if 'FaultMask' in fault_surface.cell_data:
- fault_ids_surface = fault_surface.cell_data['FaultMask']
- elif 'attribute' in fault_surface.cell_data:
- fault_ids_surface = fault_surface.cell_data['attribute']
-
- # ===================================================================
- # EXTRACT DATA
- # ===================================================================
-
- # Volume analytical data
- centers_volume = volume_mesh.cell_data['elementCenter']
- side_data = volume_mesh.cell_data['side']
- sigma_n_analytical = volume_mesh.cell_data['sigma_n_analytical']
- tau_analytical = volume_mesh.cell_data['tau_analytical']
-
- # Optional: SCU if available
- has_SCU_analytical = 'SCU_analytical' in volume_mesh.cell_data
- if has_SCU_analytical:
- SCU_analytical = volume_mesh.cell_data['SCU_analytical']
-
- # Fault numerical data
- centers_fault = fault_surface.cell_data['elementCenter']
- sigma_n_numerical = fault_surface.cell_data['sigma_n_eff']
- tau_numerical = fault_surface.cell_data['tau_eff']
-
- # Optional: SCU numerical
- has_SCU_numerical = 'SCU' in fault_surface.cell_data
- if has_SCU_numerical:
- SCU_numerical = fault_surface.cell_data['SCU']
-
- # Filter volume by side
- mask_plus = (side_data == 1) | (side_data == 3)
- mask_minus = (side_data == 2) | (side_data == 3)
-
- centers_plus = centers_volume[mask_plus]
- sigma_n_analytical_plus = sigma_n_analytical[mask_plus]
- tau_analytical_plus = tau_analytical[mask_plus]
- if has_SCU_analytical:
- SCU_analytical_plus = SCU_analytical[mask_plus]
-
- centers_minus = centers_volume[mask_minus]
- sigma_n_analytical_minus = sigma_n_analytical[mask_minus]
- tau_analytical_minus = tau_analytical[mask_minus]
- if has_SCU_analytical:
- SCU_analytical_minus = SCU_analytical[mask_minus]
-
- print(f" 📍 Plus side: {len(centers_plus):,} cells with analytical data")
- print(f" 📍 Minus side: {len(centers_minus):,} cells with analytical data")
- print(f" 📍 Fault surface: {len(centers_fault):,} cells with numerical data")
-
- # ===================================================================
- # GET FAULT BOUNDS AND PROFILE SETUP
- # ===================================================================
-
- x_min, x_max = np.min(centers_fault[:, 0]), np.max(centers_fault[:, 0])
- y_min, y_max = np.min(centers_fault[:, 1]), np.max(centers_fault[:, 1])
- z_min, z_max = np.min(centers_fault[:, 2]), np.max(centers_fault[:, 2])
-
- x_range = x_max - x_min
- y_range = y_max - y_min
-
- # Search radius
- if self.config.PROFILE_SEARCH_RADIUS is not None:
- search_radius = self.config.PROFILE_SEARCH_RADIUS
- else:
- search_radius = min(x_range, y_range) * 0.2
-
- # Auto-generate profile points if not provided
- if profile_start_points is None:
- print(" ⚠️ No profile_start_points provided, auto-generating...")
- n_profiles = 3
-
- if x_range > y_range:
- coord_name = 'X'
- fixed_value = (y_min + y_max) / 2
- sample_positions = np.linspace(x_min, x_max, n_profiles)
- profile_start_points = [(x, fixed_value, z_max) for x in sample_positions]
- else:
- coord_name = 'Y'
- fixed_value = (x_min + x_max) / 2
- sample_positions = np.linspace(y_min, y_max, n_profiles)
- profile_start_points = [(fixed_value, y, z_max) for y in sample_positions]
-
- print(f" Auto-generated {n_profiles} profiles along {coord_name}")
-
- n_profiles = len(profile_start_points)
-
- # ===================================================================
- # CREATE FIGURE: COMBINED PLOTS ONLY
- # 3 columns (σ_n, τ, SCU) x 1 row
- # ===================================================================
-
- fig, axes = plt.subplots(1, 3, figsize=(18, 10))
-
- print(f" 📍 Processing {n_profiles} profiles for comparison:")
-
- successful_profiles = 0
-
- # ===================================================================
- # EXTRACT AND PLOT PROFILES
- # ===================================================================
-
- for i, (x_pos, y_pos, z_pos) in enumerate(profile_start_points):
- print(f"\n → Profile {i+1}: starting at ({x_pos:.1f}, {y_pos:.1f}, {z_pos:.1f})")
-
- # ================================================================
- # PLUS SIDE - ANALYTICAL
- # ================================================================
- if len(centers_plus) > 0:
- depths_sn_ana_p, profile_sn_ana_p, _, _ = ProfileExtractor.extract_adaptive_profile(
- centers_plus, sigma_n_analytical_plus, x_pos, y_pos, z_pos,
- search_radius, verbose=False)
-
- depths_tau_ana_p, profile_tau_ana_p, _, _ = ProfileExtractor.extract_adaptive_profile(
- centers_plus, tau_analytical_plus, x_pos, y_pos, z_pos,
- search_radius, verbose=False)
-
- if has_SCU_analytical:
- depths_scu_ana_p, profile_scu_ana_p, _, _ = ProfileExtractor.extract_adaptive_profile(
- centers_plus, SCU_analytical_plus, x_pos, y_pos, z_pos,
- search_radius, verbose=False, )
-
- if len(depths_sn_ana_p) >= 3:
- # Plot σ_n
- axes[0].plot(profile_sn_ana_p, depths_sn_ana_p,
- color='red', linestyle='-', linewidth=2,
- alpha=0.3, label='Analytical Side +' if i == 0 else '', marker=None)
-
- # Plot τ
- axes[1].plot(profile_tau_ana_p, depths_tau_ana_p,
- color='red', linestyle='-', linewidth=2,
- alpha=0.3, label='Analytical Side +' if i == 0 else '', marker=None)
-
- # Plot SCU if available
- if has_SCU_analytical and len(depths_scu_ana_p) >= 3:
- axes[2].plot(profile_scu_ana_p, depths_scu_ana_p,
- color='red', linestyle='-', linewidth=2,
- alpha=0.3, label='Analytical Side +' if i == 0 else '', marker=None)
-
- # ================================================================
- # MINUS SIDE - ANALYTICAL
- # ================================================================
- if len(centers_minus) > 0:
- depths_sn_ana_m, profile_sn_ana_m, _, _ = ProfileExtractor.extract_adaptive_profile(
- centers_minus, sigma_n_analytical_minus, x_pos, y_pos, z_pos,
- search_radius, verbose=False)
-
- depths_tau_ana_m, profile_tau_ana_m, _, _ = ProfileExtractor.extract_adaptive_profile(
- centers_minus, tau_analytical_minus, x_pos, y_pos, z_pos,
- search_radius, verbose=False)
-
- if has_SCU_analytical:
- depths_scu_ana_m, profile_scu_ana_m, _, _ = ProfileExtractor.extract_adaptive_profile(
- centers_minus, SCU_analytical_minus, x_pos, y_pos, z_pos,
- search_radius, verbose=False)
-
- if len(depths_sn_ana_m) >= 3:
- # Plot σ_n
- axes[0].plot(profile_sn_ana_m, depths_sn_ana_m,
- color='blue', linestyle='-', linewidth=2,
- alpha=0.3, label='Analytical Side -' if i == 0 else '', marker=None)
-
- # Plot τ
- axes[1].plot(profile_tau_ana_m, depths_tau_ana_m,
- color='blue', linestyle='-', linewidth=2,
- alpha=0.3, label='Analytical Side -' if i == 0 else '', marker=None)
-
- # Plot SCU if available
- if has_SCU_analytical and len(depths_scu_ana_m) >= 3:
- axes[2].plot(profile_scu_ana_m, depths_scu_ana_m,
- color='blue', linestyle='-', linewidth=2,
- alpha=0.3, label='Analytical Side -' if i == 0 else '', marker=None)
-
- # ================================================================
- # AVERAGES - ANALYTICAL (only for first profile to avoid clutter)
- # ================================================================
- if i == 0 and len(depths_sn_ana_m) >= 3 and len(depths_sn_ana_p) >= 3:
- # Arithmetic average
- avg_sn_arith = (profile_sn_ana_m + profile_sn_ana_p) / 2
- avg_tau_arith = (profile_tau_ana_m + profile_tau_ana_p) / 2
-
- axes[0].plot(avg_sn_arith, depths_sn_ana_m,
- color='darkorange', linestyle='-', linewidth=2,
- alpha=0.6, label='Arithmetic average')
-
- axes[1].plot(avg_tau_arith, depths_sn_ana_m,
- color='darkorange', linestyle='-', linewidth=2,
- alpha=0.6, label='Arithmetic average')
-
- # Geometric average
- avg_tau_geom = np.sqrt(profile_tau_ana_m * profile_tau_ana_p)
-
- axes[1].plot(avg_tau_geom, depths_sn_ana_m,
- color='purple', linestyle='-', linewidth=2,
- alpha=0.6, label='Geometric average')
-
- # Harmonic average
- avg_sn_harm = 2 / (1/profile_sn_ana_m + 1/profile_sn_ana_p)
- avg_tau_harm = 2 / (1/profile_tau_ana_m + 1/profile_tau_ana_p)
-
- axes[0].plot(avg_sn_harm, depths_sn_ana_m,
- color='green', linestyle='-', linewidth=2,
- alpha=0.6, label='Harmonic average')
-
- axes[1].plot(avg_tau_harm, depths_sn_ana_m,
- color='green', linestyle='-', linewidth=2,
- alpha=0.6, label='Harmonic average')
-
- # ================================================================
- # NUMERICAL DATA FROM FAULT SURFACE (Continuum)
- # ================================================================
- print(f" Extracting numerical data from fault surface...")
-
- depths_sn_num, profile_sn_num, _, _ = ProfileExtractor.extract_adaptive_profile(
- centers_fault, sigma_n_numerical, x_pos, y_pos, z_pos,
- search_radius, verbose=False)
-
- depths_tau_num, profile_tau_num, _, _ = ProfileExtractor.extract_adaptive_profile(
- centers_fault, tau_numerical, x_pos, y_pos, z_pos,
- search_radius, verbose=False)
-
- if has_SCU_numerical:
- depths_scu_num, profile_scu_num, _, _ = ProfileExtractor.extract_adaptive_profile(
- centers_fault, SCU_numerical, x_pos, y_pos, z_pos,
- search_radius, verbose=False)
-
- if len(depths_sn_num) >= 3:
- # Plot numerical with distinct style
- axes[0].plot(profile_sn_num, depths_sn_num,
- color='black', linestyle='-', linewidth=2,
- alpha=0.7, label='GEOS Continuum' if i == 0 else '',
- marker='x', markersize=5, markevery=3)
-
- axes[1].plot(profile_tau_num, depths_tau_num,
- color='black', linestyle='-', linewidth=2,
- alpha=0.7, label='GEOS Continuum' if i == 0 else '',
- marker='x', markersize=5, markevery=3)
-
- if has_SCU_numerical and len(depths_scu_num) >= 3:
- axes[2].plot(profile_scu_num, depths_scu_num,
- color='black', linestyle='-', linewidth=2,
- alpha=0.7, label='GEOS Continuum' if i == 0 else '',
- marker='x', markersize=5, markevery=3)
-
- successful_profiles += 1
-
- if successful_profiles == 0:
- print(" ❌ No valid profiles found!")
- plt.close()
- return
-
- # ===================================================================
- # ADD GEOS CONTACT SOLVER REFERENCE DATA (only once)
- # ===================================================================
-
- if geos_contact_data is not None:
- # Format: [Depth_m, Normal_Stress_bar, Shear_Stress_bar, SCU]
- # Index: [0, 1, 2, 3]
-
- print(" 📊 Adding GEOS Contact Solver reference data...")
-
- # Normal stress
- axes[0].plot(geos_contact_data[:, 1], geos_contact_data[:, 0],
- marker='o', color='black', markersize=7,
- label='GEOS Contact Solver', linestyle='none',
- alpha=0.8, mec='black', mew=1.5, fillstyle='none')
-
- # Shear stress
- axes[1].plot(geos_contact_data[:, 2], geos_contact_data[:, 0],
- marker='o', color='black', markersize=7,
- label='GEOS Contact Solver', linestyle='none',
- alpha=0.8, mec='black', mew=1.5, fillstyle='none')
-
- # SCU (if available)
- if geos_contact_data.shape[1] > 3:
- axes[2].plot(geos_contact_data[:, 3], geos_contact_data[:, 0],
- marker='o', color='black', markersize=7,
- label='GEOS Contact Solver', linestyle='none',
- alpha=0.8, mec='black', mew=1.5, fillstyle='none')
-
- # ===================================================================
- # CONFIGURE PLOTS
- # ===================================================================
-
- fsize = 14
-
- # Plot 0: Normal Stress
- axes[0].set_xlabel('Normal Stress σₙ [bar]', fontsize=fsize, weight="bold")
- axes[0].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
- axes[0].grid(True, alpha=0.3, linestyle='--')
- axes[0].legend(loc='best', fontsize=fsize-2)
- axes[0].tick_params(labelsize=fsize-1)
-
- # Plot 1: Shear Stress
- axes[1].set_xlabel('Shear Stress τ [bar]', fontsize=fsize, weight="bold")
- axes[1].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
- axes[1].grid(True, alpha=0.3, linestyle='--')
- axes[1].legend(loc='best', fontsize=fsize-2)
- axes[1].tick_params(labelsize=fsize-1)
-
- # Plot 2: SCU
- axes[2].set_xlabel('SCU [-]', fontsize=fsize, weight="bold")
- axes[2].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
- axes[2].axvline(x=0.8, color='forestgreen', linestyle='--', linewidth=2,
- alpha=0.5, label='Critical (0.8)')
- axes[2].axvline(x=1.0, color='red', linestyle='--', linewidth=2,
- alpha=0.5, label='Failure (1.0)')
- axes[2].grid(True, alpha=0.3, linestyle='--')
- axes[2].legend(loc='upper right', fontsize=fsize-2, ncol=1)
- axes[2].tick_params(labelsize=fsize-1)
- axes[2].set_xlim(left=0)
-
- # Overall title
- years = time / (365.25 * 24 * 3600)
- fig.suptitle(f'Analytical (Anderson) vs Numerical (GEOS Continuum & Contact) - t={years:.1f} years',
- fontsize=fsize+2, fontweight='bold', y=0.995)
-
- # Change verticale scale
- if self.config.MAX_DEPTH_PROFILES != None :
- for i in range(len(axes)):
- axes[i].set_ylim(bottom=self.config.MAX_DEPTH_PROFILES)
-
- if self.config.MIN_DEPTH_PROFILES != None :
- for i in range(len(axes)):
- axes[i].set_ylim(top=self.config.MIN_DEPTH_PROFILES)
-
- plt.tight_layout(rect=[0, 0, 1, 0.99])
-
- # Save
- if save:
- filename = f'analytical_vs_numerical_comparison_{years:.0f}y.png'
- plt.savefig(path / filename, dpi=300, bbox_inches='tight')
- print(f"\n 💾 Comparison plot saved: {filename}")
-
- # Show
- if show:
- plt.show()
- else:
- plt.close()
+ def _createPVD(self, outputFiles):
+ """Create PVD collection file"""
+ pvdPath = self.outputDir / 'fault_analysis.pvd'
+ with open(pvdPath, 'w') as f:
+ f.write('\n')
+ f.write(' \n')
+ for t, fname in outputFiles:
+ f.write(f' \n')
+ f.write(' \n')
+ f.write('\n')
+ print(f"\n✅ PVD created: {pvdPath}")
# ============================================================================
@@ -4375,34 +408,34 @@ def main():
print(f"✅ Mesh loaded: {config.GRID_FILE} | {mesh.n_cells} cells")
# Read first volume dataset
- pvd_reader = pv.PVDReader(path / config.PVD_FILE)
- pvd_reader.set_active_time_point(0)
- dataset = pvd_reader.read()
+ pvdReader = pv.PVDReader(path / config.PVD_FILE)
+ pvdReader.set_active_time_point(0)
+ dataset = pvdReader.read()
# IMPORTANT : Utiliser le même merge que dans la boucle
processor = TimeSeriesProcessor(config)
- volume_mesh = processor._merge_blocks(dataset)
- print(f"✅ Volume mesh extracted: {volume_mesh.n_cells} cells")
+ volumeMesh = processor._mergeBlocks(dataset)
+ print(f"✅ Volume mesh extracted: {volumeMesh.n_cells} cells")
# Initialize fault geometry with topology pre-computation
print("\n📐 Initialize fault geometry")
- fault_geometry = FaultGeometry(
+ faultGeometry = FaultGeometry(
config = config,
mesh=mesh,
- fault_values=config.FAULT_VALUES,
- fault_attribute=config.FAULT_ATTRIBUTE,
- volume_mesh=volume_mesh)
+ faultValues=config.FAULT_VALUES,
+ faultAttribute=config.FAULT_ATTRIBUTE,
+ volumeMesh=volumeMesh)
# Compute normals and adjacency topology (done once!)
print("🔧 Computing normals and adjacency topology")
- fault_surface, adjacency_mapping = fault_geometry.initialize( scale_factor=50.0 )
+ faultSurface, adjacencyMapping = faultGeometry.initialize( scaleFactor=50.0 )
# Process time series
processor = TimeSeriesProcessor(config)
- processor.process(path, fault_geometry, config.PVD_FILE)
+ processor.process(path, faultGeometry, config.PVD_FILE)
print("\n" + "=" * 60)
print("✅ ANALYSIS COMPLETE")
diff --git a/geos-processing/src/geos/processing/post_processing/ProfileExtractor.py b/geos-processing/src/geos/processing/post_processing/ProfileExtractor.py
new file mode 100644
index 00000000..e3692cf5
--- /dev/null
+++ b/geos-processing/src/geos/processing/post_processing/ProfileExtractor.py
@@ -0,0 +1,731 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright 2023-2026 TotalEnergies.
+# SPDX-FileContributor: Nicolas Pillardou, Paloma Martinez
+import numpy as np
+from scipy.spatial import cKDTree
+import pyvista as pv
+# ============================================================================
+# PROFILE EXTRACTOR
+# ============================================================================
+class ProfileExtractor:
+ """Utility class for extracting profiles along fault surfaces"""
+
+ # -------------------------------------------------------------------
+ @staticmethod
+ def extractAdaptiveProfile(centers, values, xStart, yStart, zStart=None,
+ searchRadius=None, stepSize=20.0, maxSteps=500,
+ verbose=True, faultBounds=None, cellData=None):
+ """
+ Extraction de profil vertical par COUCHES DE PROFONDEUR avec détection automatique de faille.
+
+ Stratégie:
+ 1. Trouver le point de départ le plus proche
+ 2. Identifier automatiquement la faille via cellData (attribute, FaultMask, etc.)
+ 3. FILTRER pour ne garder QUE les cellules de cette faille
+ 4. Diviser en tranches Z
+ 5. Pour chaque tranche, prendre la cellule la plus proche en XY
+
+ Parameters
+ ----------
+ centers : ndarray
+ Cell centers (nCells, 3)
+ values : ndarray
+ Values at cells (nCells,)
+ xStart, yStart : float
+ Starting XY position
+ zStart : float, optional
+ Starting Z position (if None, uses highest point near XY)
+ searchRadius : float, optional
+ Not used (kept for compatibility)
+ cellData : dict, optional
+ Dictionary with cell data fields (e.g., {'attribute': array, 'FaultMask': array})
+ Used to automatically detect and filter by fault ID
+ verbose : bool
+ Print detailed information
+
+ Returns
+ -------
+ depths, profileValues, pathX, pathY : ndarrays
+ Extracted profile data
+ """
+ # Convert to np arrays
+ centers = np.asarray(centers)
+ values = np.asarray(values)
+
+ if len(centers) == 0:
+ if verbose:
+ print(f" ⚠️ No cells provided")
+ return np.array([]), np.array([]), np.array([]), np.array([])
+
+ # ===================================================================
+ # ÉTAPE 1: TROUVER LE POINT DE DÉPART
+ # ===================================================================
+
+ if zStart is None:
+ # Chercher en 2D (XY), prendre le plus haut
+ if verbose:
+ print(f" Searching near ({xStart:.1f}, {yStart:.1f})")
+
+ dXY = np.sqrt((centers[:, 0] - xStart)**2 + (centers[:, 1] - yStart)**2)
+ closestIndices = np.argsort(dXY)[:20]
+
+ if len(closestIndices) == 0:
+ print(f" ⚠️ No cells found near start point")
+ return np.array([]), np.array([]), np.array([]), np.array([])
+
+ # Prendre le plus haut (plus grand Z)
+ closestDepths = centers[closestIndices, 2]
+ startIdx = closestIndices[np.argmax(closestDepths)]
+ else:
+ # Chercher en 3D
+ if verbose:
+ print(f" Searching near ({xStart:.1f}, {yStart:.1f}, {zStart:.1f})")
+
+ d3D = np.sqrt((centers[:, 0] - xStart)**2 +
+ (centers[:, 1] - yStart)**2 +
+ (centers[:, 2] - zStart)**2)
+ startIdx = np.argmin(d3D)
+
+ startPoint = centers[startIdx]
+
+ if verbose:
+ print(f" Starting point: ({startPoint[0]:.1f}, {startPoint[1]:.1f}, {startPoint[2]:.1f})")
+ print(f" Starting cell index: {startIdx}")
+
+ # ===================================================================
+ # ÉTAPE 2: DÉTECTER AUTOMATIQUEMENT L'ID DE LA FAILLE
+ # ===================================================================
+
+ faultIds = None
+ targetFaultId = None
+
+ if cellData is not None:
+ # Chercher dans l'ordre de priorité
+ faultFieldNames = ['attribute', 'FaultMask', 'faultId', 'region']
+
+ for fieldName in faultFieldNames:
+ if fieldName in cellData:
+ faultIds = np.asarray(cellData[fieldName])
+
+ if len(faultIds) != len(centers):
+ if verbose:
+ print(f" ⚠️ Field '{fieldName}' length mismatch, skipping")
+ continue
+
+ # Récupérer l'ID au point de départ
+ targetFaultId = faultIds[startIdx]
+
+ if verbose:
+ uniqueIds = np.unique(faultIds)
+ print(f" Found fault field: '{fieldName}'")
+ print(f" Available fault IDs: {uniqueIds}")
+ print(f" Target fault ID at start point: {targetFaultId}")
+
+ break
+
+ # ===================================================================
+ # ÉTAPE 3: FILTRER PAR FAILLE SI DÉTECTÉE
+ # ===================================================================
+
+ if targetFaultId is not None:
+ # FILTRER: garder SEULEMENT cette faille
+ maskSameFault = (faultIds == targetFaultId)
+ nTotal = len(centers)
+ nOnFault = np.sum(maskSameFault)
+
+ if verbose:
+ print(f" Filtering to fault ID={targetFaultId}: {nOnFault}/{nTotal} cells ({nOnFault/nTotal*100:.1f}%)")
+
+ if nOnFault == 0:
+ print(f" ⚠️ No cells found on target fault")
+ return np.array([]), np.array([]), np.array([]), np.array([])
+
+ # REMPLACER centers et values par le subset filtré
+ centers = centers[maskSameFault].copy()
+ values = values[maskSameFault].copy()
+
+ # Trouver le nouvel index de départ dans le subset
+ dToStart = np.sqrt(np.sum((centers - startPoint)**2, axis=1))
+ startIdx = np.argmin(dToStart)
+
+ if verbose:
+ print(f" ✅ Profile will stay on fault ID={targetFaultId}")
+ else:
+ if verbose:
+ print(f" ⚠️ No fault identification field found")
+ if cellData is not None:
+ print(f" Available fields: {list(cellData.keys())}")
+ else:
+ print(f" cellData not provided")
+ print(f" Profile may jump between faults!")
+
+ # À partir d'ici, centers/values ne contiennent QUE la faille cible
+
+ # ===================================================================
+ # ÉTAPE 4: POSITION DE RÉFÉRENCE
+ # ===================================================================
+
+ refX = centers[startIdx, 0]
+ refY = centers[startIdx, 1]
+
+ if verbose:
+ print(f" Reference XY: ({refX:.1f}, {refY:.1f})")
+
+ # ===================================================================
+ # ÉTAPE 5: GÉOMÉTRIE DE LA FAILLE
+ # ===================================================================
+
+ xRange = np.max(centers[:, 0]) - np.min(centers[:, 0])
+ yRange = np.max(centers[:, 1]) - np.min(centers[:, 1])
+ zRange = np.max(centers[:, 2]) - np.min(centers[:, 2])
+
+ if zRange <= 0:
+ print(f" ⚠️ Invalid zRange: {zRange}")
+ return np.array([]), np.array([]), np.array([]), np.array([])
+
+ lateralExtent = max(xRange, yRange)
+ xyTolerance = max(lateralExtent * 0.3, 100.0)
+
+ if verbose:
+ print(f" Fault extent: X={xRange:.1f}m, Y={yRange:.1f}m, Z={zRange:.1f}m")
+ print(f" XY tolerance: {xyTolerance:.1f}m")
+
+ # ===================================================================
+ # ÉTAPE 6: CALCUL DES TRANCHES
+ # ===================================================================
+
+ zCoordsSorted = np.sort(centers[:, 2])
+ zDiffs = np.diff(zCoordsSorted)
+ zDiffsPositive = zDiffs[zDiffs > 1e-6]
+
+ if len(zDiffsPositive) == 0:
+ if verbose:
+ print(f" ⚠️ All cells at same Z")
+
+ dXY = np.sqrt((centers[:, 0] - refX)**2 + (centers[:, 1] - refY)**2)
+ sortedIndices = np.argsort(dXY)
+
+ return (centers[sortedIndices, 2],
+ values[sortedIndices],
+ centers[sortedIndices, 0],
+ centers[sortedIndices, 1])
+
+ medianZSpacing = np.median(zDiffsPositive)
+
+ # Vérifier que medianZSpacing est raisonnable
+ if medianZSpacing <= 0 or medianZSpacing > zRange:
+ medianZSpacing = zRange / 100 # Fallback
+
+ # Taille de tranche = espacement médian
+ sliceThickness = medianZSpacing
+
+ zMin = np.min(centers[:, 2])
+ zMax = np.max(centers[:, 2])
+
+ nSlices = int(np.ceil(zRange / sliceThickness))
+ nSlices = min(nSlices, 10000) # Limiter à 10k tranches max
+
+ if nSlices <= 0:
+ print(f" ⚠️ Invalid nSlices: {nSlices}")
+ return np.array([]), np.array([]), np.array([]), np.array([])
+
+ if verbose:
+ print(f" Median Z spacing: {medianZSpacing:.1f}m")
+ print(f" Creating {nSlices} slices")
+
+ try:
+ zSlices = np.linspace(zMax, zMin, nSlices + 1)
+ except (MemoryError, ValueError) as e:
+ print(f" ⚠️ Error creating slices: {e}")
+ return np.array([]), np.array([]), np.array([]), np.array([])
+
+ # ===================================================================
+ # ÉTAPE 7: EXTRACTION PAR TRANCHES
+ # ===================================================================
+
+ profileIndices = []
+
+ for i in range(len(zSlices) - 1):
+ zTop = zSlices[i]
+ zBottom = zSlices[i + 1]
+
+ # Cellules dans cette tranche
+ maskInSlice = (centers[:, 2] <= zTop) & (centers[:, 2] >= zBottom)
+ indicesInSlice = np.where(maskInSlice)[0]
+
+ if len(indicesInSlice) == 0:
+ continue
+
+ # Distance XY à la référence
+ dXYInSlice = np.sqrt(
+ (centers[indicesInSlice, 0] - refX)**2 +
+ (centers[indicesInSlice, 1] - refY)**2
+ )
+
+ # Ne garder que celles dans la tolérance XY
+ validMask = dXYInSlice < xyTolerance
+
+ if not np.any(validMask):
+ # Aucune dans la tolérance → prendre la plus proche
+ closestInSlice = indicesInSlice[np.argmin(dXYInSlice)]
+ else:
+ # Prendre la plus proche parmi celles dans la tolérance
+ validIndices = indicesInSlice[validMask]
+ dXYValid = dXYInSlice[validMask]
+ closestInSlice = validIndices[np.argmin(dXYValid)]
+
+ profileIndices.append(closestInSlice)
+
+ # ===================================================================
+ # ÉTAPE 8: SUPPRIMER DOUBLONS ET TRIER
+ # ===================================================================
+
+ # Supprimer doublons
+ seen = set()
+ uniqueIndices = []
+ for idx in profileIndices:
+ if idx not in seen:
+ seen.add(idx)
+ uniqueIndices.append(idx)
+
+ if len(uniqueIndices) == 0:
+ if verbose:
+ print(f" ⚠️ No points extracted")
+ return np.array([]), np.array([]), np.array([]), np.array([])
+
+ profileIndices = np.array(uniqueIndices)
+
+ # Trier par profondeur décroissante (haut → bas)
+ sortOrder = np.argsort(-centers[profileIndices, 2])
+ profileIndices = profileIndices[sortOrder]
+
+ # Extraire résultats
+ depths = centers[profileIndices, 2]
+ profileValues = values[profileIndices]
+ pathX = centers[profileIndices, 0]
+ pathY = centers[profileIndices, 1]
+
+ # ===================================================================
+ # STATISTIQUES
+ # ===================================================================
+
+ if verbose:
+ depthCoverage = (depths.max() - depths.min()) / zRange * 100 if zRange > 0 else 0
+ xyDisplacement = np.sqrt((pathX[-1] - pathX[0])**2 + (pathY[-1] - pathY[0])**2)
+
+ print(f" ✅ Extracted {len(profileIndices)} points")
+ print(f" Depth range: [{depths.max():.1f}, {depths.min():.1f}]m")
+ print(f" Coverage: {depthCoverage:.1f}% of fault depth")
+ print(f" XY displacement: {xyDisplacement:.1f}m")
+
+ return (depths, profileValues, pathX, pathY)
+
+ # -------------------------------------------------------------------
+ @staticmethod
+ def extractVerticalProfileTopologyBased(surfaceMesh, fieldName, xStart, yStart, zStart=None,
+ maxSteps=500, verbose=True):
+ """
+ Extraction de profil vertical en utilisant la TOPOLOGIE du maillage de surface.
+ """
+
+ import pyvista as pv
+
+ if fieldName not in surfaceMesh.cell_data:
+ print(f" ⚠️ Field '{fieldName}' not found in mesh")
+ return np.array([]), np.array([]), np.array([]), np.array([])
+
+ centers = surfaceMesh.cell_centers().points
+ values = surfaceMesh.cell_data[fieldName]
+
+ # ===================================================================
+ # ÉTAPE 1: TROUVER LA CELLULE DE DÉPART
+ # ===================================================================
+
+ if zStart is None:
+ if verbose:
+ print(f" Searching near ({xStart:.1f}, {yStart:.1f})")
+
+ dXY = np.sqrt((centers[:, 0] - xStart)**2 + (centers[:, 1] - yStart)**2)
+ closestIndices = np.argsort(dXY)[:20]
+
+ if len(closestIndices) == 0:
+ print(f" ⚠️ No cells found")
+ return np.array([]), np.array([]), np.array([]), np.array([])
+
+ closestDepths = centers[closestIndices, 2]
+ startIdx = closestIndices[np.argmax(closestDepths)]
+ else:
+ if verbose:
+ print(f" Searching near ({xStart:.1f}, {yStart:.1f}, {zStart:.1f})")
+
+ d3D = np.sqrt((centers[:, 0] - xStart)**2 +
+ (centers[:, 1] - yStart)**2 +
+ (centers[:, 2] - zStart)**2)
+ startIdx = np.argmin(d3D)
+
+ startPoint = centers[startIdx]
+
+ if verbose:
+ print(f" Starting cell: {startIdx}")
+ print(f" Starting point: ({startPoint[0]:.1f}, {startPoint[1]:.1f}, {startPoint[2]:.1f})")
+
+ # ===================================================================
+ # ÉTAPE 2: IDENTIFIER LA FAILLE
+ # ===================================================================
+
+ targetFaultId = None
+ faultIds = None
+ faultFieldNames = ['attribute', 'FaultMask', 'faultId', 'region']
+
+ for fieldNameCheck in faultFieldNames:
+ if fieldNameCheck in surfaceMesh.cell_data:
+ faultIds = surfaceMesh.cell_data[fieldNameCheck]
+ targetFaultId = faultIds[startIdx]
+
+ if verbose:
+ uniqueIds = np.unique(faultIds)
+ print(f" Fault field: '{fieldNameCheck}'")
+ print(f" Target fault ID: {targetFaultId} (from {uniqueIds})")
+
+ break
+
+ if targetFaultId is None and verbose:
+ print(f" ⚠️ No fault ID found - will use all cells")
+
+ # ===================================================================
+ # ÉTAPE 3: CONSTRUIRE LA CONNECTIVITÉ (VOISINS TOPOLOGIQUES)
+ # ===================================================================
+
+ if verbose:
+ print(f" Building cell connectivity...")
+
+ nCells = surfaceMesh.n_cells
+ connectivity = [[] for _ in range(nCells)]
+
+ # Construire un dictionnaire arête -> cellules
+ edgeToCells = {}
+
+ for cellId in range(nCells):
+ cell = surfaceMesh.get_cell(cellId)
+ nPoints = cell.n_points
+
+ # Pour chaque arête de la cellule
+ for i in range(nPoints):
+ p1 = cell.point_ids[i]
+ p2 = cell.point_ids[(i + 1) % nPoints]
+
+ # Arête normalisée (ordre canonique)
+ edge = tuple(sorted([p1, p2]))
+
+ if edge not in edgeToCells:
+ edgeToCells[edge] = []
+ edgeToCells[edge].append(cellId)
+
+ # Pour chaque cellule, trouver ses voisins via arêtes partagées
+ for cellId in range(nCells):
+ cell = surfaceMesh.get_cell(cellId)
+ nPoints = cell.n_points
+
+ neighborsSet = set()
+
+ for i in range(nPoints):
+ p1 = cell.point_ids[i]
+ p2 = cell.point_ids[(i + 1) % nPoints]
+ edge = tuple(sorted([p1, p2]))
+
+ # Toutes les cellules partageant cette arête sont voisines
+ for neighborId in edgeToCells[edge]:
+ if neighborId != cellId:
+ neighborsSet.add(neighborId)
+
+ connectivity[cellId] = list(neighborsSet)
+
+ if verbose:
+ avgNeighbors = np.mean([len(c) for c in connectivity])
+ maxNeighbors = np.max([len(c) for c in connectivity])
+ print(f" Connectivity built: avg={avgNeighbors:.1f} neighbors/cell, max={maxNeighbors}")
+
+ # ===================================================================
+ # ÉTAPE 4: ALGORITHME DE DESCENTE PAR VOISINAGE TOPOLOGIQUE
+ # ===================================================================
+
+ profileIndices = [startIdx]
+ visited = {startIdx}
+ currentIdx = startIdx
+
+ refXY = startPoint[:2] # Position XY de référence
+
+ if verbose:
+ print(f" Starting descent from Z={startPoint[2]:.1f}m...")
+
+ stuckCount = 0
+ maxStuck = 3
+
+ for step in range(maxSteps):
+ currentZ = centers[currentIdx, 2]
+
+ # Obtenir les voisins topologiques
+ neighborIndices = connectivity[currentIdx]
+
+ # Filtrer les voisins:
+ # 1. Non visités
+ # 2. Même faille (si détectée)
+ # 3. Plus bas en Z
+ candidates = []
+
+ for idx in neighborIndices:
+ if idx in visited:
+ continue
+
+ # Vérifier la faille
+ if targetFaultId is not None and faultIds is not None:
+ if faultIds[idx] != targetFaultId:
+ continue
+
+ # Vérifier qu'on descend
+ if centers[idx, 2] >= currentZ:
+ continue
+
+ candidates.append(idx)
+
+ if len(candidates) == 0:
+ # Si bloqué, essayer de regarder les voisins des voisins
+ stuckCount += 1
+
+ if stuckCount >= maxStuck:
+ if verbose:
+ print(f" Reached bottom at Z={currentZ:.1f}m after {step+1} steps (no more neighbors)")
+ break
+
+ # Essayer niveau 2 (voisins des voisins)
+ extendedCandidates = []
+ for neighborIdx in neighborIndices:
+ if neighborIdx in visited:
+ continue
+
+ for secondNeighborIdx in connectivity[neighborIdx]:
+ if secondNeighborIdx in visited:
+ continue
+
+ if targetFaultId is not None and faultIds is not None:
+ if faultIds[secondNeighborIdx] != targetFaultId:
+ continue
+
+ if centers[secondNeighborIdx, 2] < currentZ:
+ extendedCandidates.append(secondNeighborIdx)
+
+ if len(extendedCandidates) == 0:
+ if verbose:
+ print(f" Reached bottom at Z={currentZ:.1f}m (extended search failed)")
+ break
+
+ candidates = extendedCandidates
+ if verbose:
+ print(f" Used extended search at step {step+1}")
+ else:
+ stuckCount = 0
+
+ # Parmi les candidats, choisir celui le plus proche en XY de la référence
+ bestIdx = None
+ bestDistanceXY = float('inf')
+
+ for idx in candidates:
+ pos = centers[idx]
+ dXY = np.sqrt((pos[0] - refXY[0])**2 + (pos[1] - refXY[1])**2)
+
+ if dXY < bestDistanceXY:
+ bestDistanceXY = dXY
+ bestIdx = idx
+
+ if bestIdx is None:
+ if verbose:
+ print(f" No valid neighbor at Z={currentZ:.1f}m")
+ break
+
+ # Ajouter au profil
+ profileIndices.append(bestIdx)
+ visited.add(bestIdx)
+ currentIdx = bestIdx
+
+ # Debug
+ if verbose and (step + 1) % 100 == 0:
+ print(f" Step {step+1}: Z={centers[currentIdx, 2]:.1f}m, XY=({centers[currentIdx, 0]:.1f}, {centers[currentIdx, 1]:.1f})")
+
+ # ===================================================================
+ # ÉTAPE 5: EXTRAIRE LES RÉSULTATS
+ # ===================================================================
+
+ if len(profileIndices) == 0:
+ if verbose:
+ print(f" ⚠️ No profile extracted")
+ return np.array([]), np.array([]), np.array([]), np.array([])
+
+ profileIndices = np.array(profileIndices)
+
+ depths = centers[profileIndices, 2]
+ profileValues = values[profileIndices]
+ pathX = centers[profileIndices, 0]
+ pathY = centers[profileIndices, 1]
+
+ # ===================================================================
+ # STATISTIQUES
+ # ===================================================================
+
+ if verbose:
+ zRange = np.max(centers[:, 2]) - np.min(centers[:, 2])
+ depthCoverage = (depths.max() - depths.min()) / zRange * 100 if zRange > 0 else 0
+ xyDisplacement = np.sqrt((pathX[-1] - pathX[0])**2 + (pathY[-1] - pathY[0])**2)
+
+ print(f" ✅ {len(profileIndices)} points extracted")
+ print(f" Depth range: [{depths.max():.1f}, {depths.min():.1f}]m")
+ print(f" Coverage: {depthCoverage:.1f}% of fault depth")
+ print(f" XY displacement: {xyDisplacement:.1f}m")
+
+ return (depths, profileValues, pathX, pathY)
+
+ # -------------------------------------------------------------------
+ @staticmethod
+ def plotProfilePath3D(surface, pathX, pathY, pathZ, profileValues=None,
+ scalarName='SCU', savePath=None, show=True):
+ """
+ Visualize the extracted profile path on the fault surface in 3D using PyVista.
+
+ Parameters
+ ----------
+ surface : pyvista.PolyData
+ Fault surface mesh
+ pathX, pathY, pathZ : array-like
+ Coordinates of the profile path
+ profileValues : array-like, optional
+ Values along the profile (for coloring the path)
+ scalarName : str
+ Name of the scalar to display on the surface
+ savePath : Path or str, optional
+ Path to save the screenshot
+ show : bool
+ Whether to display the plot interactively
+ """
+ if len(pathX) == 0:
+ print(" ⚠️ No path to plot (empty profile)")
+ return
+
+ print(f" 📊 Creating 3D visualization of profile path ({len(pathX)} points)")
+
+ # Create plotter
+ plotter = pv.Plotter(window_size=[1600, 1200])
+
+ # Add fault surface with scalar field
+ if scalarName in surface.cell_data:
+ plotter.add_mesh(
+ surface,
+ scalars=scalarName,
+ cmap='RdYlGn_r',
+ opacity=0.7,
+ show_edges=False,
+ lighting=True,
+ smooth_shading=True,
+ scalar_bar_args={
+ 'title': scalarName,
+ 'title_font_size': 20,
+ 'label_font_size': 16,
+ 'n_labels': 5,
+ 'italic': False,
+ 'fmt': '%.2f',
+ 'font_family': 'arial',
+ }
+ )
+ else:
+ plotter.add_mesh(
+ surface,
+ color='lightgray',
+ opacity=0.5,
+ show_edges=True
+ )
+
+ # Create path as a polyline
+ pathPoints = np.column_stack([pathX, pathY, pathZ])
+ pathPolyline = pv.PolyData(pathPoints)
+
+ # Add connectivity for line
+ nPoints = len(pathPoints)
+ lines = np.full((nPoints - 1, 3), 2, dtype=np.int_)
+ lines[:, 1] = np.arange(nPoints - 1)
+ lines[:, 2] = np.arange(1, nPoints)
+ pathPolyline.lines = lines.ravel()
+
+ # Color the path by profile values or depth
+ if profileValues is not None:
+ pathPolyline['profile_value'] = profileValues
+ colorField = 'profile_value'
+ cmapPath = 'viridis'
+ else:
+ pathPolyline['depth'] = pathZ
+ colorField = 'depth'
+ cmapPath = 'turbo_r'
+
+ # Add path as thick tube
+ pathTube = pathPolyline.tube(radius=10.0) # Adjust radius as needed
+ plotter.add_mesh(
+ pathTube,
+ scalars=colorField,
+ cmap=cmapPath,
+ line_width=8,
+ render_lines_as_tubes=True,
+ lighting=True,
+ scalar_bar_args={
+ 'title': 'Path ' + colorField,
+ 'title_font_size': 20,
+ 'label_font_size': 16,
+ 'position_x': 0.85,
+ 'position_y': 0.05,
+ }
+ )
+
+ # Add start and end markers
+ startPoint = pv.Sphere(radius=30, center=pathPoints[0])
+ endPoint = pv.Sphere(radius=30, center=pathPoints[-1])
+
+ plotter.add_mesh(startPoint, color='lime', label='Start (Top)')
+ plotter.add_mesh(endPoint, color='red', label='End (Bottom)')
+
+ # Add axes and labels
+ plotter.add_axes(
+ xlabel='X [m]',
+ ylabel='Y [m]',
+ zlabel='Z [m]',
+ line_width=3,
+ labels_off=False
+ )
+
+ # Add legend
+ plotter.add_legend(
+ labels=[('Start (Top)', 'lime'), ('End (Bottom)', 'red')],
+ bcolor='white',
+ border=True,
+ size=(0.15, 0.1),
+ loc='upper left'
+ )
+
+ # Set camera and lighting
+ plotter.camera_position = 'iso'
+ plotter.add_light(pv.Light(position=(1, 1, 1), intensity=0.8))
+
+ # Add title
+ pathLength = np.sum(np.sqrt(np.sum(np.diff(pathPoints, axis=0)**2, axis=1)))
+ depthRange = pathZ.max() - pathZ.min()
+ title = f'Profile Path Extraction\n'
+ title += f'Points: {len(pathX)} | Length: {pathLength:.1f}m | Depth range: {depthRange:.1f}m'
+ plotter.add_text(title, position='upper_edge', font_size=14, color='black')
+
+ # Save screenshot
+ # if savePath is not None:
+ # screenshot_path = savePath / 'profile_path_3d.png'
+ # plotter.screenshot(str(screenshot_path))
+ # print(f" 💾 Screenshot saved: {screenshot_path}")
+
+ # Show
+ if show:
+ plotter.show()
+ else:
+ plotter.close()
+
diff --git a/geos-processing/src/geos/processing/post_processing/SensitivityAnalyzer.py b/geos-processing/src/geos/processing/post_processing/SensitivityAnalyzer.py
new file mode 100644
index 00000000..6541d11d
--- /dev/null
+++ b/geos-processing/src/geos/processing/post_processing/SensitivityAnalyzer.py
@@ -0,0 +1,283 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright 2023-2026 TotalEnergies.
+# SPDX-FileContributor: Nicolas Pillardou, Paloma Martinez
+# ============================================================================
+# SENSITIVITY ANALYSIS
+# ============================================================================
+import pandas as pd
+from matplotlib.colors import Normalize
+from matplotlib.cm import ScalarMappable
+class SensitivityAnalyzer:
+ """Performs sensitivity analysis on Mohr-Coulomb parameters"""
+
+ # -------------------------------------------------------------------
+ def __init__(self, config):
+ self.config = config
+ self.outputDir = Path(config.SENSITIVITY_OUTPUT_DIR)
+ self.outputDir.mkdir(exist_ok=True)
+ self.results = []
+
+ # -------------------------------------------------------------------
+ def runAnalysis(self, surfaceWithStress, time):
+ """Run sensitivity analysis for multiple friction angles and cohesions"""
+ frictionAngles = self.config.SENSITIVITY_FRICTION_ANGLES
+ cohesions = self.config.SENSITIVITY_COHESIONS
+
+ print("\n" + "=" * 60)
+ print("SENSITIVITY ANALYSIS")
+ print("=" * 60)
+ print(f"Friction angles: {frictionAngles}")
+ print(f"Cohesions: {cohesions}")
+ print(f"Total combinations: {len(frictionAngles) * len(cohesions)}")
+
+ results = []
+
+ for frictionAngle in frictionAngles:
+ for cohesion in cohesions:
+ print(f"\n→ Testing φ={frictionAngle}°, C={cohesion} bar")
+
+ surfaceCopy = surfaceWithStress.copy()
+
+ surfaceAnalyzed = MohrCoulomb.analyze(
+ surfaceCopy, cohesion, frictionAngle, time, verbose=False)
+
+ stats = self._extractStatistics(surfaceAnalyzed, frictionAngle, cohesion)
+ results.append(stats)
+
+ print(f" Unstable: {stats['nUnstable']}, "
+ f"Critical: {stats['nCritical']}, "
+ f"Stable: {stats['nStable']}")
+
+ self.results = results
+
+ # Generate plots
+ self._plotSensitivityResults(results, time)
+
+ # Plot SCU vs depth
+ self._plotSCUDepthProfiles(results, time, surfaceWithStress)
+
+ return results
+
+ # -------------------------------------------------------------------
+ def _extractStatistics(self, surface, frictionAngle, cohesion):
+ """Extract statistical metrics from analyzed surface"""
+ stability = surface.cell_data["stabilityState"]
+ SCU = surface.cell_data["SCU"]
+ failureProba = surface.cell_data["failureProbability"]
+ safetyMargin = surface.cell_data["safetyMargin"]
+
+ stats = {
+ 'frictionAngle': frictionAngle,
+ 'cohesion': cohesion,
+ 'nCells': surface.n_cells,
+ 'nStable': np.sum(stability == 0),
+ 'nCritical': np.sum(stability == 1),
+ 'nUnstable': np.sum(stability == 2),
+ 'pctUnstable': np.sum(stability == 2) / surface.n_cells * 100,
+ 'pctCritical': np.sum(stability == 1) / surface.n_cells * 100,
+ 'pctStable': np.sum(stability == 0) / surface.n_cells * 100,
+ 'meanSCU': np.mean(SCU),
+ 'maxSCU': np.max(SCU),
+ 'meanFailureProb': np.mean(failureProba),
+ 'meanSafetyMargin': np.mean(safetyMargin),
+ 'minSafetyMargin': np.min(safetyMargin)
+ }
+
+ return stats
+
+ # -------------------------------------------------------------------
+ def _plotSensitivityResults(self, results, time):
+ """Create comprehensive sensitivity analysis plots"""
+ import pandas as pd
+
+ df = pd.DataFrame(results)
+
+ fig, axes = plt.subplots(2, 2, figsize=(16, 12))
+
+ # Plot heatmaps
+ self._plotHeatMap(df, 'pctUnstable', 'Unstable Cells [%]', axes[0, 0])
+ self._plotHeatMap(df, 'pctCritical', 'Critical Cells [%]', axes[0, 1])
+ self._plotHeatMap(df, 'meanSCU', 'Mean SCU [-]', axes[1, 0])
+ self._plotHeatMap(df, 'meanSafetyMargin', 'Mean Safety Margin [bar]', axes[1, 1])
+
+ plt.tight_layout()
+
+ years = time / (365.25 * 24 * 3600)
+ filename = f'sensitivity_analysis_{years:.0f}y.png'
+ plt.savefig(self.outputDir / filename, dpi=300, bbox_inches='tight')
+ print(f"\n📊 Sensitivity plot saved: {filename}")
+
+ if self.config.SHOW_PLOTS:
+ plt.show()
+ else:
+ plt.close()
+
+ # -------------------------------------------------------------------
+ def _plotHeatMap(self, df, column, title, ax):
+ """Create a single heatmap for sensitivity analysis"""
+ pivot = df.pivot(index='cohesion', columns='frictionAngle', values=column)
+
+ im = ax.imshow(pivot.values, cmap='RdYlGn_r', aspect='auto', origin='lower')
+
+ ax.set_xticks(np.arange(len(pivot.columns)))
+ ax.set_yticks(np.arange(len(pivot.index)))
+ ax.set_xticklabels(pivot.columns)
+ ax.set_yticklabels(pivot.index)
+
+ ax.set_xlabel('Friction Angle [°]')
+ ax.set_ylabel('Cohesion [bar]')
+ ax.set_title(title)
+
+ # Add values in cells
+ for i in range(len(pivot.index)):
+ for j in range(len(pivot.columns)):
+ value = pivot.values[i, j]
+ textColor = 'white' if value > pivot.values.max() * 0.5 else 'black'
+ ax.text(j, i, f'{value:.1f}', ha='center', va='center',
+ color=textColor, fontsize=9)
+
+ plt.colorbar(im, ax=ax)
+
+ # -------------------------------------------------------------------
+ def _plotSCUDepthProfiles(self, results, time, surfaceWithStress):
+ """
+ Plot SCU depth profiles for all parameter combinations
+ Each (cohesion, friction) pair gets a unique color
+ Uses profile points from config.PROFILE_START_POINTS
+ """
+
+ print("\n 📊 Creating SCU sensitivity depth profiles...")
+
+ # Extract depth data
+ centers = surfaceWithStress.cell_data['elementCenter']
+ depth = centers[:, 2]
+
+ # Get profile points from config
+ profileStartPoints = self.config.PROFILE_START_POINTS
+
+ # Auto-generate if not provided
+ if profileStartPoints is None:
+ print(" ⚠️ No PROFILE_START_POINTS in config, auto-generating...")
+ xMin, xMax = np.min(centers[:, 0]), np.max(centers[:, 0])
+ yMin, yMax = np.min(centers[:, 1]), np.max(centers[:, 1])
+
+ xRange = xMax - xMin
+ yRange = yMax - yMin
+
+ if xRange > yRange:
+ # Fault oriented in X, sample at mid-Y
+ xPos = (xMin + xMax) / 2
+ yPos = (yMin + yMax) / 2
+ else:
+ # Fault oriented in Y, sample at mid-X
+ xPos = (xMin + xMax) / 2
+ yPos = (yMin + yMax) / 2
+
+ profileStartPoints = [(xPos, yPos)]
+
+ # Get search radius from config or auto-compute
+ searchRadius = getattr(self.config, 'PROFILE_SEARCH_RADIUS', None)
+ if searchRadius is None:
+ xMin, xMax = np.min(centers[:, 0]), np.max(centers[:, 0])
+ yMin, yMax = np.min(centers[:, 1]), np.max(centers[:, 1])
+ xRange = xMax - xMin
+ yRange = yMax - yMin
+ searchRadius = min(xRange, yRange) * 0.15
+
+ print(f" 📍 Using {len(profileStartPoints)} profile point(s) from config")
+ print(f" Search radius: {searchRadius:.1f}m")
+
+ # Create colormap for parameter combinations
+ nCombinations = len(results)
+ cmap = plt.cm.viridis
+ norm = Normalize(vmin=0, vmax=nCombinations-1)
+ sm = ScalarMappable(norm=norm, cmap=cmap)
+
+ # Create figure with subplots for each profile point
+ nProfiles = len(profileStartPoints)
+ fig, axes = plt.subplots(1, nProfiles, figsize=(8*nProfiles, 10))
+
+ # Handle single subplot case
+ if nProfiles == 1:
+ axes = [axes]
+
+ # Plot each profile point
+ for profileIdx, (xPos, yPos, zPos) in enumerate(profileStartPoints):
+ ax = axes[profileIdx]
+
+ print(f"\n → Profile {profileIdx+1} at ({xPos:.1f}, {yPos:.1f}, {zPos:.1f}):")
+
+
+ # Plot each parameter combination
+ for idx, params in enumerate(results):
+ frictionAngle = params['frictionAngle']
+ cohesion = params['cohesion']
+
+ # Re-analyze surface with these parameters
+ surfaceCopy = surfaceWithStress.copy()
+ surfaceAnalyzed = MohrCoulomb.analyze(
+ surfaceCopy, cohesion, frictionAngle, time, verbose=False
+ )
+
+ # Extract SCU
+ SCU = np.abs(surfaceAnalyzed.cell_data["SCU"])
+
+ # Extract profile using adaptive method
+ # depthsSCU, profileSCU, _, _ = ProfileExtractor.extractVerticalProfileTopologyBased(
+ # surfaceAnalyzed, 'SCU', xPos, yPos, zPos, verbose=False)
+ depthsSCU, profileSCU, _, _ = ProfileExtractor.extractAdaptiveProfile(
+ centers, SCU, xPos, yPos, searchRadius)
+
+ if len(depthsSCU) >= 3:
+ color = cmap(norm(idx))
+ label = f'φ={frictionAngle}°, C={cohesion} bar'
+ ax.plot(profileSCU, depthsSCU,
+ color=color, label=label,
+ linewidth=2, alpha=0.8)
+
+ if idx == 0: # Print info only once per profile
+ print(f" ✅ {len(depthsSCU)} points extracted")
+ else:
+ if idx == 0:
+ print(f" ⚠️ Insufficient points ({len(depthsSCU)})")
+
+ # Add critical lines
+ ax.axvline(x=0.8, color='forestgreen', linestyle='--',
+ linewidth=2, label='Critical (SCU=0.8)', zorder=100)
+ ax.axvline(x=1.0, color='red', linestyle='--',
+ linewidth=2, label='Failure (SCU=1.0)', zorder=100)
+
+ # Configure plot
+ ax.set_xlabel('Shear Capacity Utilization (SCU) [-]', fontsize=14, weight='bold')
+ ax.set_ylabel('Depth [m]', fontsize=14, weight='bold')
+ ax.set_title(f'Profile {profileIdx+1} @ ({xPos:.0f}, {yPos:.0f})',
+ fontsize=14, weight='bold')
+ ax.grid(True, alpha=0.3, linestyle='--')
+ ax.set_xlim(left=0)
+
+ # Change verticale scale
+ if hasattr(self.config, 'MAX_DEPTH_PROFILES') and self.config.MAX_DEPTH_PROFILES is not None:
+ ax.set_ylim(bottom=self.config.MAX_DEPTH_PROFILES)
+
+ # Légende en dehors à droite
+ ax.legend(loc='center left', bbox_to_anchor=(1, 0.5), fontsize=9, ncol=1)
+
+ ax.tick_params(labelsize=12)
+
+ # Overall title
+ years = time / (365.25 * 24 * 3600)
+ fig.suptitle('SCU Depth Profiles - Sensitivity Analysis',
+ fontsize=16, weight='bold', y=0.98)
+
+ plt.tight_layout(rect=[0, 0, 1, 0.96])
+
+ # Save
+ filename = f'sensitivity_scu_profiles_{years:.0f}y.png'
+ plt.savefig(self.outputDir / filename, dpi=300, bbox_inches='tight')
+ print(f"\n 💾 SCU sensitivity profiles saved: {filename}")
+
+ if self.config.SHOW_PLOTS:
+ plt.show()
+ else:
+ plt.close()
+
diff --git a/geos-processing/src/geos/processing/post_processing/StressProjector.py b/geos-processing/src/geos/processing/post_processing/StressProjector.py
new file mode 100644
index 00000000..1f9d4034
--- /dev/null
+++ b/geos-processing/src/geos/processing/post_processing/StressProjector.py
@@ -0,0 +1,678 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright 2023-2026 TotalEnergies.
+# SPDX-FileContributor: Nicolas Pillardou, Paloma Martinez
+import numpy as np
+from geos.geomechanics.model.StressTensor import StressTensor
+from scipy.spatial import cKDTree
+
+# ============================================================================
+# STRESS PROJECTION
+# ============================================================================
+class StressProjector:
+ """Projects volume stress onto fault surfaces and tracks principal stresses in VTU"""
+
+ # -------------------------------------------------------------------
+ def __init__(self, config, adjacencyMapping, geometricProperties):
+ """
+ Initialize with pre-computed adjacency mapping and geometric properties
+
+ Parameters
+ ----------
+ config : Configuration object
+ adjacencyMapping : dict
+ Pre-computed dict mapping fault cells to volume cells
+ geometricProperties : dict
+ Pre-computed geometric properties from FaultGeometry:
+ - 'volumes': cell volumes
+ - 'centers': cell centers
+ - 'distances': distances to fault
+ - 'faultTree': KDTree for fault
+ """
+ self.config = config
+ self.adjacencyMapping = adjacencyMapping
+
+ # Store pre-computed geometric properties
+ self.volumeCellVolumes = geometricProperties['volumes']
+ self.volumeCenters = geometricProperties['centers']
+ self.distanceToFault = geometricProperties['distances']
+ self.faultTree = geometricProperties['faultTree']
+
+ # Storage for time series metadata
+ self.timestepInfo = []
+
+ # Track which cells to monitor (optional)
+ self.monitoredCells = None
+
+ # Output directory for VTU files
+ self.vtuOutputDir = None
+
+ # -------------------------------------------------------------------
+ def setMonitoredCells(self, cellIndices):
+ """
+ Set specific cells to monitor (optional)
+
+ Parameters:
+ cellIndices: list of volume cell indices to track
+ If None, all contributing cells are tracked
+ """
+ self.monitoredCells = set(cellIndices) if cellIndices is not None else None
+
+ # -------------------------------------------------------------------
+ def projectStressToFault(self, volumeData, volumeInitial, faultSurface,
+ time=None, timestep=None, weightingScheme="arithmetic"):
+ """
+ Project stress and save principal stresses to VTU
+
+ Now uses pre-computed geometric properties for efficiency
+ """
+ stressName = self.config.STRESS_NAME
+ biotName = self.config.BIOT_NAME
+
+ if stressName not in volumeData.array_names:
+ raise ValueError(f"No stress data '{stressName}' in dataset")
+
+ # =====================================================================
+ # 1. EXTRACT STRESS DATA
+ # =====================================================================
+ pressure = volumeData["pressure"] / 1e5
+ pressureFault = volumeInitial["pressure"] / 1e5
+ pressureInitial = volumeInitial["pressure"] / 1e5
+ biot = volumeData[biotName]
+
+ stressEffective = StressTensor.buildFromArray(volumeData[stressName] / 1e5)
+ stressEffectiveInitial = StressTensor.buildFromArray(volumeInitial[stressName] / 1e5)
+
+ # Convert effective stress to total stress
+ I = np.eye(3)[None, :, :]
+ stressTotal = stressEffective - biot[:, None, None] * pressure[:, None, None] * I
+ stressTotalInitial = stressEffectiveInitial - biot[:, None, None] * pressureInitial[:, None, None] * I
+
+ # =====================================================================
+ # 2. USE PRE-COMPUTED ADJACENCY
+ # =====================================================================
+ mapping = self.adjacencyMapping
+
+ # =====================================================================
+ # 3. PREPARE FAULT GEOMETRY
+ # =====================================================================
+ normals = faultSurface.cell_data["Normals"]
+ tangent1 = faultSurface.cell_data["tangent1"]
+ tangent2 = faultSurface.cell_data["tangent2"]
+
+ faultCenters = faultSurface.cell_centers().points
+ faultSurface.cell_data['elementCenter'] = faultCenters
+
+ nFault = faultSurface.n_cells
+ nVolume = volumeData.n_cells
+
+ # =====================================================================
+ # 4. COMPUTE PRINCIPAL STRESSES FOR CONTRIBUTING CELLS
+ # =====================================================================
+ if self.config.COMPUTE_PRINCIPAL_STRESS and timestep is not None:
+
+ # Collect all unique contributing cells
+ allContributingCells = set()
+ for faultIdx, neighbors in mapping.items():
+ allContributingCells.update(neighbors['plus'])
+ allContributingCells.update(neighbors['minus'])
+
+ # Filter by monitored cells if specified
+ if self.monitoredCells is not None:
+ cellsToTrack = allContributingCells.intersection(self.monitoredCells)
+ else:
+ cellsToTrack = allContributingCells
+
+ print(f" 📊 Computing principal stresses for {len(cellsToTrack)} contributing cells...")
+
+ # Create mesh with only contributing cells
+ contributingMesh = self._createVolumicContribMesh(
+ volumeData, faultSurface, cellsToTrack, mapping
+ )
+
+ # Save to VTU
+ if self.vtuOutputDir is None:
+ self.vtuOutputDir = Path(self.config.OUTPUT_DIR) / "principal_stresses"
+
+ self._savePrincipalStressVTU(contributingMesh, time, timestep)
+
+ else:
+ contributingMesh = None
+
+ # =====================================================================
+ # 6. PROJECT STRESS FOR EACH FAULT CELL
+ # =====================================================================
+ sigmaNArr = np.zeros(nFault)
+ tauArr = np.zeros(nFault)
+ tauDipArr = np.zeros(nFault)
+ tauStrikeArr = np.zeros(nFault)
+ deltaSigmaNArr = np.zeros(nFault)
+ deltaTauArr = np.zeros(nFault)
+ nContributors = np.zeros(nFault, dtype=int)
+
+ print(f" 🔄 Projecting stress to {nFault} fault cells...")
+ print(f" Weighting scheme: {weightingScheme}")
+
+ for faultIdx in range(nFault):
+ if faultIdx not in mapping:
+ continue
+
+ volPlus = mapping[faultIdx]['plus']
+ volMinus = mapping[faultIdx]['minus']
+ allVol = volPlus + volMinus
+
+ if len(allVol) == 0:
+ continue
+
+ # ===================================================================
+ # CALCULATE WEIGHTS (using pre-computed properties)
+ # ===================================================================
+
+ if weightingScheme == 'arithmetic':
+ weights = np.ones(len(allVol)) / len(allVol)
+
+ elif weightingScheme == 'harmonic':
+ weights = np.ones(len(allVol)) / len(allVol)
+
+ elif weightingScheme == 'distance':
+ # Use pre-computed distances
+ dists = np.array([self.distanceToFault[v] for v in allVol])
+ dists = np.maximum(dists, 1e-6)
+ invDists = 1.0 / dists
+ weights = invDists / np.sum(invDists)
+
+ elif weightingScheme == 'volume':
+ # Use pre-computed volumes
+ vols = np.array([self.volumeCellVolumes[v] for v in allVol])
+ weights = vols / np.sum(vols)
+
+ elif weightingScheme == 'distanceVolume':
+ # Use pre-computed volumes and distances
+ vols = np.array([self.volumeCellVolumes[v] for v in allVol])
+ dists = np.array([self.distanceToFault[v] for v in allVol])
+ dists = np.maximum(dists, 1e-6)
+
+ weights = vols / dists
+ weights = weights / np.sum(weights)
+
+ elif weightingScheme == 'inverseSquareDistance':
+ # Use pre-computed distances
+ dists = np.array([self.distanceToFault[v] for v in allVol])
+ dists = np.maximum(dists, 1e-6)
+ invSquareDistance = 1.0 / (dists ** 2)
+ weights = invSquareDistance / np.sum(invSquareDistance)
+
+ else:
+ raise ValueError(f"Unknown weighting scheme: {weightingScheme}")
+
+ # ===================================================================
+ # ACCUMULATE WEIGHTED CONTRIBUTIONS
+ # ===================================================================
+
+ sigmaN = 0.0
+ tau = 0.0
+ tauDip = 0.0
+ tauStrike = 0.0
+ deltaSigmaN = 0.0
+ deltaTau = 0.0
+
+ for volIdx, w in zip(allVol, weights):
+
+ # Total stress (with pressure)
+ sigmaFinal = stressTotal[volIdx] + pressureFault[volIdx] * np.eye(3)
+ sigmaInit = stressTotalInitial[volIdx] + pressureInitial[volIdx] * np.eye(3)
+
+ # Rotate to fault frame
+ resFinal = StressTensor.rotateToFaultFrame(
+ sigmaFinal, normals[faultIdx], tangent1[faultIdx], tangent2[faultIdx]
+ )
+
+ resInitial = StressTensor.rotateToFaultFrame(
+ sigmaInit, normals[faultIdx], tangent1[faultIdx], tangent2[faultIdx]
+ )
+
+ # Accumulate weighted contributions
+ sigmaN += w * resFinal['normalStress']
+ tau += w * resFinal['shearStress']
+ tauDip += w * resFinal['shearDip']
+ tauStrike += w * resFinal['shearStrike']
+ deltaSigmaN += w * (resFinal['normalStress'] - resInitial['normalStress'])
+ deltaTau += w * (resFinal['shearStress'] - resInitial['shearStress'])
+
+ sigmaNArr[faultIdx] = sigmaN
+ tauArr[faultIdx] = tau
+ tauDipArr[faultIdx] = tauDip
+ tauStrikeArr[faultIdx] = tauStrike
+ deltaSigmaNArr[faultIdx] = deltaSigmaN
+ deltaTauArr[faultIdx] = deltaTau
+ nContributors[faultIdx] = len(allVol)
+
+ # =====================================================================
+ # 7. STORE RESULTS ON FAULT SURFACE
+ # =====================================================================
+ faultSurface.cell_data["sigmaNEffective"] = sigmaNArr
+ faultSurface.cell_data["tauEffective"] = tauDipArr
+ faultSurface.cell_data["tauStrike"] = tauStrikeArr
+ faultSurface.cell_data["tauDip"] = tauDipArr
+ faultSurface.cell_data["deltaSigmaNEffective"] = deltaSigmaNArr
+ faultSurface.cell_data["deltaTauEffective"] = deltaTauArr
+
+ # =====================================================================
+ # 8. STATISTICS
+ # =====================================================================
+ valid = nContributors > 0
+ nValid = np.sum(valid)
+
+ print(f" ✅ Stress projected: {nValid}/{nFault} fault cells ({nValid/nFault*100:.1f}%)")
+
+ if np.sum(valid) > 0:
+ print(f" Contributors per fault cell: min={np.min(nContributors[valid])}, "
+ f"max={np.max(nContributors[valid])}, "
+ f"mean={np.mean(nContributors[valid]):.1f}")
+
+ return faultSurface, volumeData, contributingMesh
+
+ # -------------------------------------------------------------------
+ @staticmethod
+ def computePrincipalStresses(stressTensor):
+ """
+ Compute principal stresses and directions
+
+ Convention: Compression is NEGATIVE
+ - σ1 = most compressive (most negative)
+ - σ3 = least compressive (least negative, or most tensile)
+
+ Returns:
+ dict with eigenvalues, eigenvectors, meanStress, deviatoricStress
+ """
+ eigenvalues, eigenvectors = np.linalg.eigh(stressTensor)
+
+ # Sort from MOST NEGATIVE to LEAST NEGATIVE (most compressive to least)
+ # Example: -600 < -450 < -200, so -600 is σ1 (most compressive)
+ idx = np.argsort(eigenvalues) # Ascending order (most negative first)
+ eigenvaluesSorted = eigenvalues[idx]
+ eigenVectorsSorted = eigenvectors[:, idx]
+
+ return {
+ 'sigma1': eigenvaluesSorted[0], # Most compressive (most negative)
+ 'sigma2': eigenvaluesSorted[1], # Intermediate
+ 'sigma3': eigenvaluesSorted[2], # Least compressive (least negative)
+ 'meanStress': np.mean(eigenvaluesSorted),
+ 'deviatoricStress': eigenvaluesSorted[0] - eigenvaluesSorted[2], # σ1 - σ3 (negative - more negative = positive or less negative)
+ 'direction1': eigenVectorsSorted[:, 0], # Direction of σ1
+ 'direction2': eigenVectorsSorted[:, 1], # Direction of σ2
+ 'direction3': eigenVectorsSorted[:, 2] # Direction of σ3
+ }
+
+ # -------------------------------------------------------------------
+ def _createVolumicContribMesh(self, volumeData, faultSurface, cellsToTrack, mapping):
+ """
+ Create a mesh containing only contributing cells with principal stress data
+ and compute analytical normal/shear stresses based on fault dip angle
+
+ Parameters
+ ----------
+ volumeData : pyvista.UnstructuredGrid
+ Volume mesh with stress data (rock_stress or averageStress)
+ faultSurface : pyvista.PolyData
+ Fault surface with dipAngle and strikeAngle per cell
+ cellsToTrack : set
+ Set of volume cell indices to include
+ mapping : dict
+ Adjacency mapping {faultIdx: {'plus': [...], 'minus': [...]}}
+ """
+
+ # ===================================================================
+ # EXTRACT STRESS DATA FROM VOLUME
+ # ===================================================================
+ stressName = self.config.STRESS_NAME
+ biotName = self.config.BIOT_NAME
+
+ if stressName not in volumeData.array_names:
+ raise ValueError(f"No stress data '{stressName}' in volume dataset")
+
+ print(f" 📊 Extracting stress from field: '{stressName}'")
+
+ # Extract effective stress and pressure
+ pressure = volumeData["pressure"] / 1e5 # Convert to bar
+ biot = volumeData[biotName]
+
+ stressEffective = StressTensor.buildFromArray(volumeData[stressName] / 1e5)
+
+ # Convert effective stress to total stress
+ I = np.eye(3)[None, :, :]
+ stressTotal = stressEffective - biot[:, None, None] * pressure[:, None, None] * I
+
+ # ===================================================================
+ # EXTRACT SUBSET OF CELLS
+ # ===================================================================
+ cellIndices = sorted(list(cellsToTrack))
+ cellMask = np.zeros(volumeData.n_cells, dtype=bool)
+ cellMask[cellIndices] = True
+
+ subsetMesh = volumeData.extract_cells(cellMask)
+
+ # ===================================================================
+ # REBUILD MAPPING: subsetIdx -> originalIdx
+ # ===================================================================
+ originalCenters = volumeData.cell_centers().points[cellIndices]
+ subsetCenters = subsetMesh.cell_centers().points
+
+ tree = cKDTree(originalCenters)
+
+ subsetToOriginal = np.zeros(subsetMesh.n_cells, dtype=int)
+ for subsetIdx in range(subsetMesh.n_cells):
+ dist, idx = tree.query(subsetCenters[subsetIdx])
+ if dist > 1e-6:
+ print(f" WARNING: Cell {subsetIdx} not matched (dist={dist})")
+ subsetToOriginal[subsetIdx] = cellIndices[idx]
+
+ # ===================================================================
+ # MAP VOLUME CELLS TO FAULT DIP/STRIKE ANGLES
+ # ===================================================================
+ print(f" 📐 Mapping volume cells to fault dip/strike angles...")
+
+ # Check if fault surface has required data
+ if 'dipAngle' not in faultSurface.cell_data:
+ print(f" ⚠️ WARNING: 'dipAngle' not found in faultSurface")
+ print(f" Available fields: {list(faultSurface.cell_data.keys())}")
+ return None
+
+ if 'strikeAngle' not in faultSurface.cell_data:
+ print(f" ⚠️ WARNING: 'strikeAngle' not found in faultSurface")
+
+ # Create mapping: volume_cell_id -> [dip_angles, strike_angles]
+ volumeToDip = {}
+ volumeToStrike = {}
+
+ for faultIdx, neighbors in mapping.items():
+ # Get dip and strike angle from fault cell
+ faultDip = faultSurface.cell_data['dipAngle'][faultIdx]
+
+ # Strike is optional
+ if 'strikeAngle' in faultSurface.cell_data:
+ faultStrike = faultSurface.cell_data['strikeAngle'][faultIdx]
+ else:
+ faultStrike = np.nan
+
+ # Assign to all contributing volume cells (plus and minus)
+ for volIdx in neighbors['plus'] + neighbors['minus']:
+ if volIdx not in volumeToDip:
+ volumeToDip[volIdx] = []
+ volumeToStrike[volIdx] = []
+ volumeToDip[volIdx].append(faultDip)
+ volumeToStrike[volIdx].append(faultStrike)
+
+ # Average if a volume cell contributes to multiple fault cells
+ volumeToDipAvg = {volIdx: np.mean(dips)
+ for volIdx, dips in volumeToDip.items()}
+ volumeToStrikeAvg = {volIdx: np.mean(strikes)
+ for volIdx, strikes in volumeToStrike.items()}
+
+ print(f" ✅ Mapped {len(volumeToDipAvg)} volume cells to fault angles")
+
+ # Statistics
+ allDips = [np.mean(dips) for dips in volumeToDip.values()]
+ if len(allDips) > 0:
+ print(f" Dip angle range: [{np.min(allDips):.1f}, {np.max(allDips):.1f}]°")
+
+ # ===================================================================
+ # COMPUTE PRINCIPAL STRESSES AND ANALYTICAL FAULT STRESSES
+ # ===================================================================
+ n_cells = subsetMesh.n_cells
+
+ sigma1Arr = np.zeros(nCells)
+ sigma2Arr = np.zeros(nCells)
+ sigma3Arr = np.zeros(nCells)
+ meanStressArr = np.zeros(nCells)
+ deviatoricStressArr = np.zeros(nCells)
+ pressureArr = np.zeros(nCells)
+
+ direction1Arr = np.zeros((nCells, 3))
+ direction2Arr = np.zeros((nCells, 3))
+ direction3Arr = np.zeros((nCells, 3))
+
+ # NEW: Analytical fault stresses
+ sigmaNAnalyticalArr = np.zeros(nCells)
+ tauAnalyticalArr = np.zeros(nCells)
+ dipAngleArr = np.zeros(nCells)
+ strikeAngleArr = np.zeros(nCells)
+ deltaArr = np.zeros(nCells)
+
+ sideArr = np.zeros(nCells, dtype=int)
+ nFaultCellsArr = np.zeros(nCells, dtype=int)
+
+ print(f" 🔢 Computing principal stresses and analytical projections...")
+
+ for subsetIdx in range(nCells):
+ origIdx = subsetToOriginal[subsetIdx]
+
+ # ===============================================================
+ # COMPUTE PRINCIPAL STRESSES
+ # ===============================================================
+ # Total stress = effective stress + pore pressure
+ sigmaTotalCell = stressTotal[origIdx] + pressure[origIdx] * np.eye(3)
+ principal = self.computePrincipalStresses(sigmaTotalCell)
+
+ sigma1Arr[subsetIdx] = principal['sigma1']
+ sigma2Arr[subsetIdx] = principal['sigma2']
+ sigma3Arr[subsetIdx] = principal['sigma3']
+ meanStressArr[subsetIdx] = principal['meanStress']
+ deviatoricStressArr[subsetIdx] = principal['deviatoricStress']
+ pressureArr[subsetIdx] = pressure[origIdx]
+
+ direction1Arr[subsetIdx] = principal['direction1']
+ direction2Arr[subsetIdx] = principal['direction2']
+ direction3Arr[subsetIdx] = principal['direction3']
+
+ # ===============================================================
+ # COMPUTE ANALYTICAL FAULT STRESSES (Anderson formulas)
+ # ===============================================================
+ if origIdx in volumeToDipAvg:
+ dipDeg = volumeToDipAvg[origIdx]
+ dipAngleArr[subsetIdx] = dipDeg
+
+ strikeDeg = volumeToStrikeAvg.get(origIdx, np.nan)
+ strikeAngleArr[subsetIdx] = strikeDeg
+
+ # δ = 90° - dip (angle from horizontal)
+ deltaDeg = 90.0 - dipDeg
+ deltaRad = np.radians(deltaDeg)
+ deltaArr[subsetIdx] = deltaDeg
+
+ # Extract principal stresses (compression negative)
+ sigma1 = principal['sigma1'] # Most compressive (most negative)
+ sigma3 = principal['sigma3'] # Least compressive (least negative)
+
+ # Anderson formulas (1951)
+ # σ_n = (σ1 + σ3)/2 - (σ1 - σ3)/2 * cos(2δ)
+ # τ = |(σ1 - σ3)/2 * sin(2δ)|
+
+ sigmaMean = (sigma1 + sigma3) / 2.0
+ sigmaDiff = (sigma1 - sigma3) / 2.0
+
+ sigmaNAnalytical = sigmaMean - sigmaDiff * np.cos(2 * deltaRad)
+ tauAnalytical = sigmaDiff * np.sin(2 * deltaRad)
+
+ sigmaNAnalyticalArr[subsetIdx] = sigmaNAnalytical
+ tauAnalyticalArr[subsetIdx] = np.abs(tauAnalytical)
+ else:
+ # No fault association - set to NaN
+ dipAngleArr[subsetIdx] = np.nan
+ strikeAngleArr[subsetIdx] = np.nan
+ deltaArr[subsetIdx] = np.nan
+ sigmaNAnalyticalArr[subsetIdx] = np.nan
+ tauAnalyticalArr[subsetIdx] = np.nan
+
+ # ===============================================================
+ # DETERMINE SIDE (plus/minus/both)
+ # ===============================================================
+ isPlus = False
+ isMinus = False
+ faultCellCount = 0
+
+ for faultIdx, neighbors in mapping.items():
+ if origIdx in neighbors['plus']:
+ isPlus = True
+ faultCellCount += 1
+ if origIdx in neighbors['minus']:
+ isMinus = True
+ faultCellCount += 1
+
+ if isPlus and isMinus:
+ sideArr[subsetIdx] = 3 # both
+ elif isPlus:
+ sideArr[subsetIdx] = 1 # plus
+ elif isMinus:
+ sideArr[subsetIdx] = 2 # minus
+ else:
+ sideArr[subsetIdx] = 0 # none (should not happen)
+
+ nFaultCellsArr[subsetIdx] = faultCellCount
+
+ # ===================================================================
+ # ADD DATA TO MESH
+ # ===================================================================
+ subsetMesh.cell_data['sigma1'] = sigma1Arr
+ subsetMesh.cell_data['sigma2'] = sigma2Arr
+ subsetMesh.cell_data['sigma3'] = sigma3Arr
+ subsetMesh.cell_data['meanStress'] = meanStressArr
+ subsetMesh.cell_data['deviatoricStress'] = deviatoricStressArr
+ subsetMesh.cell_data['pressure_bar'] = pressureArr
+
+ subsetMesh.cell_data['sigma1Direction'] = direction1Arr
+ subsetMesh.cell_data['sigma2Direction'] = direction2Arr
+ subsetMesh.cell_data['sigma3Direction'] = direction3Arr
+
+ # Analytical fault stresses
+ subsetMesh.cell_data['sigmaNAnalytical'] = sigmaNAnalyticalArr
+ subsetMesh.cell_data['tauAnalytical'] = tauAnalyticalArr
+ subsetMesh.cell_data['dipAngle'] = dipAngleArr
+ subsetMesh.cell_data['strikeAngle'] = strikeAngleArr
+ subsetMesh.cell_data['deltaAngle'] = deltaArr
+
+ # ===================================================================
+ # COMPUTE SCU ANALYTICALLY (Mohr-Coulomb)
+ # ===================================================================
+ if hasattr(self.config, 'FRICTION_ANGLE') and hasattr(self.config, 'COHESION'):
+ mu = np.tan(np.radians(self.config.FRICTION_ANGLE))
+ cohesion = self.config.COHESION
+
+ # τ_crit = C - σ_n * μ
+ # Note: σ_n is negative (compression), so -σ_n * μ is positive
+ tauCriticalArr = cohesion - sigmaNAnalyticalArr * mu
+
+ # SCU = τ / τ_crit
+ SCUAnalyticalArr = np.divide(
+ tauAnalyticalArr,
+ tauCriticalArr,
+ out=np.zeros_like(tauAnalyticalArr),
+ where=tauCriticalArr != 0
+ )
+
+ subsetMesh.cell_data['tauCriticalAnalytical'] = tauCriticalArr
+ subsetMesh.cell_data['SCUAnalytical'] = SCUAnalyticalArr
+
+ # CFS (Coulomb Failure Stress)
+ CFSAnalyticalArr = tauAnalyticalArr - mu * (-sigmaNAnalyticalArr)
+ subsetMesh.cell_data['CFSAnalytical'] = CFSAnalyticalArr
+
+ subsetMesh.cell_data['side'] = sideArr
+ subsetMesh.cell_data['nFaultCells'] = nFaultCellsArr
+ subsetMesh.cell_data['originalCellId'] = subsetToOriginal
+
+ # ===================================================================
+ # STATISTICS
+ # ===================================================================
+ validAnalytical = ~np.isnan(sigmaNAnalyticalArr)
+ nValid = np.sum(validAnalytical)
+
+ if nValid > 0:
+ print(f" 📊 Analytical fault stresses computed for {nValid}/{nCells} cells")
+ print(f" σ_n range: [{np.nanmin(sigmaNAnalyticalArr):.1f}, {np.nanmax(sigmaNAnalyticalArr):.1f}] bar")
+ print(f" τ range: [{np.nanmin(tauAnalyticalArr):.1f}, {np.nanmax(tauAnalyticalArr):.1f}] bar")
+ print(f" Dip angle range: [{np.nanmin(dipAngleArr):.1f}, {np.nanmax(dipAngleArr):.1f}]°")
+
+ if hasattr(self.config, 'FRICTION_ANGLE') and hasattr(self.config, 'COHESION'):
+ print(f" SCU range: [{np.nanmin(SCUAnalyticalArr[validAnalytical]):.2f}, {np.nanmax(SCUAnalyticalArr[validAnalytical]):.2f}]")
+ nCritical = np.sum((SCUAnalyticalArr >= 0.8) & (SCUAnalyticalArr < 1.0))
+ nUnstable = np.sum(SCUAnalyticalArr >= 1.0)
+ print(f" Critical cells (SCU≥0.8): {nCritical} ({nCritical/nValid*100:.1f}%)")
+ print(f" Unstable cells (SCU≥1.0): {nUnstable} ({nUnstable/nValid*100:.1f}%)")
+ else:
+ print(f" ⚠️ No analytical stresses computed (no fault mapping)")
+
+ return subsetMesh
+
+ # -------------------------------------------------------------------
+ def _savePrincipalStressVTU(self, mesh, time, timestep):
+ """
+ Save principal stress mesh to VTU file
+
+ Parameters:
+ mesh: PyVista mesh with principal stress data
+ time: Simulation time
+ timestep: Timestep index
+ """
+ # Create output directory
+ self.vtuOutputDir.mkdir(parents=True, exist_ok=True)
+
+ # Generate filename
+ vtuFilename = f"principal_stresses_{timestep:05d}.vtu"
+ vtuPath = self.vtuOutputDir / vtuFilename
+
+ # Save mesh
+ mesh.save(str(vtuPath))
+
+ # Store metadata for PVD
+ self.timestepInfo.append({
+ 'time': time if time is not None else timestep,
+ 'timestep': timestep,
+ 'file': vtuFilename
+ })
+
+ print(f" 💾 Saved principal stresses: {vtuFilename}")
+
+ # -------------------------------------------------------------------
+ def savePVDCollection(self, filename="principal_stresses.pvd"):
+ """
+ Create PVD file for time series visualization in ParaView
+
+ Parameters:
+ filename: Name of PVD file
+ """
+ if len(self.timestepInfo) == 0:
+ print("⚠️ No timestep data to save in PVD")
+ return
+
+ pvdPath = self.vtuOutputDir / filename
+
+ print(f"\n💾 Creating PVD collection: {pvdPath}")
+ print(f" Timesteps: {len(self.timestepInfo)}")
+
+ # Create XML structure
+ root = Element('VTKFile')
+ root.set('type', 'Collection')
+ root.set('version', '0.1')
+ root.set('byte_order', 'LittleEndian')
+
+ collection = SubElement(root, 'Collection')
+
+ for info in self.timestepInfo:
+ dataset = SubElement(collection, 'DataSet')
+ dataset.set('timestep', str(info['time']))
+ dataset.set('group', '')
+ dataset.set('part', '0')
+ dataset.set('file', info['file'])
+
+ # Write to file
+ tree = ElementTree(root)
+ tree.write(str(pvdPath), encoding='utf-8', xml_declaration=True)
+
+ print(f" ✅ PVD file created successfully")
+ print(f" 📂 Output directory: {self.vtuOutputDir}")
+ print(f"\n 🎨 To visualize in ParaView:")
+ print(f" 1. Open: {pvdPath}")
+ print(f" 2. Apply")
+ print(f" 3. Color by: sigma1, sigma2, sigma3, meanStress, etc.")
+ print(f" 4. Use 'side' filter to show plus/minus/both")
+
diff --git a/geos-processing/src/geos/processing/tools/FaultVisualizer.py b/geos-processing/src/geos/processing/tools/FaultVisualizer.py
new file mode 100644
index 00000000..b0b85a55
--- /dev/null
+++ b/geos-processing/src/geos/processing/tools/FaultVisualizer.py
@@ -0,0 +1,1312 @@
+import pandas as pd
+from matplotlib.lines import Line2D
+# ============================================================================
+# VISUALIZATION
+# ============================================================================
+class Visualizer:
+ """Visualization utilities"""
+
+ # -------------------------------------------------------------------
+ def __init__(self, config):
+ self.config = config
+
+ # -------------------------------------------------------------------
+ @staticmethod
+ def plotMohrCoulombDiagram(surface, time, path, show=True, save=True):
+ """Create Mohr-Coulomb diagram with depth coloring"""
+
+ sigmaN = -surface.cell_data["sigmaNEffective"]
+ tau = np.abs(surface.cell_data["tauEffective"])
+ SCU = np.abs(surface.cell_data["SCU"])
+ depth = surface.cell_data['elementCenter'][:, 2]
+
+ cohesion = surface.cell_data["mohrCohesion"][0]
+ mu = surface.cell_data["mohrFrictionCoefficient"][0]
+ phi = surface.cell_data['mohrFrictionAngle'][0]
+
+ fig, axes = plt.subplots(1, 2, figsize=(16, 8))
+
+ # Plot 1: τ vs σ_n
+ ax1 = axes[0]
+ sc1 = ax1.scatter(sigmaN, tau, c=depth, cmap='turbo_r', s=20, alpha=0.8)
+ sigmaRange = np.linspace(0, np.max(sigmaN), 100)
+ tauCritical = cohesion + mu * sigmaRange
+ ax1.plot(sigmaRange, tauCritical, 'k--', linewidth=2,
+ label=f'M-C (C={cohesion} bar, φ={phi}°)')
+ ax1.set_xlabel('Normal Stress [bar]')
+ ax1.set_ylabel('Shear Stress [bar]')
+ ax1.legend()
+ ax1.grid(True, alpha=0.3)
+ ax1.set_title('Mohr-Coulomb Diagram')
+
+ # Plot 2: SCU vs σ_n
+ ax2 = axes[1]
+ sc2 = ax2.scatter(sigmaN, SCU, c=depth, cmap='turbo_r', s=20, alpha=0.8)
+ ax2.axhline(y=1.0, color='r', linestyle='--', label='Failure (SCU=1)')
+ ax2.set_xlabel('Normal Stress [bar]')
+ ax2.set_ylabel('SCU [-]')
+ ax2.legend()
+ ax2.grid(True, alpha=0.3)
+ ax2.set_title('Shear Capacity Utilization')
+ ax2.set_ylim(bottom=0)
+
+ plt.colorbar(sc2, ax=ax2, label='Depth [m]')
+ plt.tight_layout()
+
+ if save:
+ years = time / (365.25 * 24 * 3600)
+ filename = f'mohr_coulomb_phi{phi}_c{cohesion}_{years:.0f}y.png'
+ plt.savefig(path / filename, dpi=300, bbox_inches='tight')
+ print(f" 📊 Plot saved: {filename}")
+
+ if show:
+ plt.show()
+ else:
+ plt.close()
+
+ # -------------------------------------------------------------------
+ @staticmethod
+ def loadReferenceData(time, scriptDir=None, profileId=1):
+ """
+ Load GEOS and analytical reference data for comparison
+
+ Parameters
+ ----------
+ time : float
+ Current simulation time in seconds
+ scriptDir : str or Path, optional
+ Directory containing reference data files. If None, uses current directory.
+ profileId : int, optional
+ Profile ID to extract from Excel (default: 1)
+
+ Returns
+ -------
+ dict
+ Dictionary with keys 'geos' and 'analytical', each containing numpy arrays or None
+ Format: {'geos': array or None, 'analytical': array or None}
+
+ For GEOS data from Excel, the array has columns:
+ [Depth_m, Normal_Stress_bar, Shear_Stress_bar, SCU, X_coordinate_m, Y_coordinate_m]
+ """
+ if scriptDir is None:
+ scriptDir = os.path.dirname(os.path.abspath(__file__))
+
+ result = {'geos': None, 'analytical': None}
+
+ # ===================================================================
+ # LOAD GEOS DATA - Try Excel first, then CSV
+ # ===================================================================
+
+ geosFileXLSV = 'geos_data_numerical.xlsx'
+ geosFileCSV = 'geos_data_numerical.csv'
+
+ # Try Excel format with time-based sheets
+ geosXLSVPath = os.path.join(scriptDir, geosFileXLSV)
+
+ if os.path.exists(geosXLSVPath):
+ try:
+ # Generate sheet name based on current time
+ # Format: t_1.00e+02s
+ sheetName = f"t_{time:.2e}s"
+
+ print(f" 📂 Loading GEOS data from Excel sheet: '{sheetName}'")
+
+ # Try to read the specific sheet
+ try:
+ df = pd.read_excel(geosXLSVPath, sheet_name=sheetName)
+
+ # Filter by ProfileID if column exists
+ if 'ProfileID' in df.columns:
+ dfProfile = df[df['ProfileID'] == profileId]
+
+ if len(dfProfile) == 0:
+ print(f" ⚠️ ProfileID {profileId} not found in sheet '{sheetName}'")
+ print(f" Available Profile_IDs: {sorted(df['ProfileID'].unique())}")
+ # Take first profile as fallback
+ availableIds = sorted(df['ProfileID'].unique())
+ if len(availableIds) > 0:
+ fallbackId = availableIds[0]
+ print(f" → Using ProfileID {fallbackId} instead")
+ dfProfile = df[df['ProfileID'] == fallbackId]
+ else:
+ print(f" ✅ Loaded ProfileID {profileId}: {len(dfProfile)} points")
+
+ # Extract relevant columns in the expected order
+ # Expected: [Depth, Normal_Stress, Shear_Stress, SCU, ...]
+ columnsToExtract = ['Depth_m', 'Normal_Stress_bar', 'Shear_Stress_bar', 'SCU']
+
+ # Check which columns exist
+ availableColumns = [col for col in columnsToExtract if col in dfProfile.columns]
+
+ if len(availableColumns) > 0:
+ result['geos'] = dfProfile[availableColumns].values
+ print(f" Extracted columns: {availableColumns}")
+ else:
+ print(f" ⚠️ No expected columns found in DataFrame")
+ print(f" Available columns: {list(dfProfile.columns)}")
+ else:
+ # No ProfileID column, use all data
+ print(f" ℹ️ No ProfileID column, using all data")
+ columnsToExtract = ['Depth_m', 'Normal_Stress_bar', 'Shear_Stress_bar', 'SCU']
+ availableColumns = [col for col in columnsToExtract if col in df.columns]
+
+ if len(availableColumns) > 0:
+ result['geos'] = df[availableColumns].values
+ print(f" ✅ Loaded {len(result['geos'])} points")
+
+ except ValueError:
+ # Sheet not found, try to find closest time
+ print(f" ⚠️ Sheet '{sheetName}' not found, searching for closest time...")
+
+ # Read all sheet names
+ xlFile = pd.ExcelFile(geosXLSVPath)
+ sheetNames = xlFile.sheetNames
+
+ # Extract times from sheet names
+ sheetTimes = []
+ for sname in sheetNames:
+ if sname.startswith('t_') and sname.endswith('s'):
+ try:
+ # Extract time: t_1.00e+02s -> 100.0
+ timeStr = sname[2:-1] # Remove 't_' and 's'
+ sheetTime = float(timeStr)
+ sheetTimes.append((sheetTime, sname))
+ except:
+ continue
+
+ if sheetTimes:
+ # Find closest time
+ sheetTimes.sort(key=lambda x: abs(x[0] - time))
+ closestTime, closestSheet = sheetTimes[0]
+ timeDiff = abs(closestTime - time)
+
+ print(f" → Using closest sheet: '{closestSheet}' (Δt={timeDiff:.2e}s)")
+ df = pd.read_excel(geosXLSVPath, sheet_name=closestSheet)
+
+ # Filter by ProfileID
+ if 'ProfileID' in df.columns:
+ dfProfile = df[df['ProfileID'] == profileId]
+
+ if len(dfProfile) == 0:
+ # Fallback to first profile
+ availableIds = sorted(df['ProfileID'].unique())
+ if len(availableIds) > 0:
+ dfProfile = df[df['ProfileID'] == availableIds[0]]
+ print(f" → Using ProfileID {availableIds[0]}")
+
+ columnsToExtract = ['Depth_m', 'Normal_Stress_bar', 'Shear_Stress_bar', 'SCU'] # TODO check
+ availableColumns = [col for col in columnsToExtract if col in dfProfile.columns]
+
+ if len(availableColumns) > 0:
+ result['geos'] = dfProfile[availableColumns].values
+ print(f" ✅ Loaded {len(result['geos'])} points")
+ else:
+ # Use all data
+ columnsToExtract = ['Depth_m', 'Normal_Stress_bar', 'Shear_Stress_bar', 'SCU']
+ availableColumns = [col for col in columnsToExtract if col in df.columns]
+
+ if len(availableColumns) > 0:
+ result['geos'] = df[availableColumns].values
+ print(f" ✅ Loaded {len(result['geos'])} points")
+ else:
+ print(f" ⚠️ No valid time sheets found in Excel file")
+
+ except ImportError:
+ print(f" ⚠️ pandas not available, cannot read Excel file")
+ except Exception as e:
+ print(f" ⚠️ Error reading Excel: {e}")
+ import traceback
+ traceback.print_exc()
+
+ # Fallback to CSV if Excel not found or failed
+ if result['geos'] is None:
+ geosCSVPath = os.path.join(scriptDir, geosFileCSV)
+ if os.path.exists(geosCSVPath):
+ try:
+ result['geos'] = np.loadtxt(geosCSVPath, delimiter=',', skiprows=1)
+ print(f" ✅ GEOS data loaded from CSV: {len(result['geos'])} points")
+ except Exception as e:
+ print(f" ⚠️ Error reading CSV: {e}")
+
+ # ===================================================================
+ # LOAD ANALYTICAL DATA
+ # ===================================================================
+
+ analyticalFile = 'analyticalData.csv'
+ analyticalPath = os.path.join(scriptDir, analyticalFile)
+
+ if os.path.exists(analyticalPath):
+ try:
+ result['analytical'] = np.loadtxt(analyticalPath, delimiter=',', skiprows=1)
+ print(f" ✅ Analytical data loaded: {len(result['analytical'])} points")
+ except Exception as e:
+ print(f" ⚠️ Error loading analytical data: {e}")
+
+ return result
+
+ # -------------------------------------------------------------------
+ @staticmethod
+ def plotDepthProfiles(self, surface, time, path, show=True, save=True,
+ profileStartPoints=None,
+ maxProfilePoints=1000,
+ referenceProfileId=1
+ ):
+
+ """
+ Plot vertical profiles along the fault showing stress and SCU vs depth
+ """
+
+ print(" 📊 Creating depth profiles ")
+
+ # Extract data
+ centers = surface.cell_data['elementCenter']
+ depth = centers[:, 2]
+ sigmaN = surface.cell_data['sigmaNEffective']
+ tau = surface.cell_data['tauEffective']
+ SCU = surface.cell_data['SCU']
+ SCU = np.sqrt(SCU**2)
+ deltaSCU = surface.cell_data['deltaSCU']
+
+ # Extraire les IDs de faille
+ faultIds = None
+ if 'FaultMask' in surface.cell_data:
+ faultIds = surface.cell_data['FaultMask']
+ print(f" 📋 Detected {len(np.unique(faultIds[faultIds > 0]))} distinct faults")
+ elif 'attribute' in surface.cell_data:
+ faultIds = surface.cell_data['attribute']
+ print(f" 📋 Using 'attribute' field for fault identification")
+ else:
+ print(f" ⚠️ No fault IDs found - profiles may jump between faults")
+
+ # ===================================================================
+ # LOAD REFERENCE DATA (GEOS + Analytical)
+ # ===================================================================
+ scriptDir = os.path.dirname(os.path.abspath(__file__))
+ referenceData = Visualizer.loadReferenceData(
+ time,
+ scriptDir,
+ profileId=referenceProfileId
+ )
+
+ geosData = referenceData['geos']
+ analyticalData = referenceData['analytical']
+
+ # ===================================================================
+ # PROFILE EXTRACTION SETUP
+ # ===================================================================
+
+ # Get fault bounds
+ xMin, xMax = np.min(centers[:, 0]), np.max(centers[:, 0])
+ yMin, yMax = np.min(centers[:, 1]), np.max(centers[:, 1])
+ zMin, zMax = np.min(depth), np.max(depth)
+
+ # Auto-compute search radius if not provided
+ xRange = xMax - xMin
+ yRange = yMax - yMin
+ zRange = zMax - zMin
+
+ if self.config.PROFILE_SEARCH_RADIUS is not None:
+ searchRadius = self.config.PROFILE_SEARCH_RADIUS
+ else:
+ searchRadius = min(xRange, yRange) * 0.15
+
+
+ # Auto-generate profile points if not provided
+ if profileStartPoints is None:
+ print(" ⚠️ No profileStartPoints provided, auto-generating 5 profiles...")
+ nProfiles = 5
+
+ # Determine dominant fault direction
+ if xRange > yRange:
+ coordName = 'X'
+ fixedValue = (yMin + yMax) / 2
+ samplePositions = np.linspace(xMin, xMax, nProfiles)
+ profileStartPoints = [(x, fixedValue) for x in samplePositions]
+ else:
+ coordName = 'Y'
+ fixedValue = (xMin + xMax) / 2
+ samplePositions = np.linspace(yMin, yMax, nProfiles)
+ profileStartPoints = [(fixedValue, y) for y in samplePositions]
+
+ print(f" Auto-generated {nProfiles} profiles along {coordName} direction")
+
+ nProfiles = len(profileStartPoints)
+
+ # ===================================================================
+ # CREATE FIGURE
+ # ===================================================================
+
+ fig, axes = plt.subplots(1, 4, figsize=(24, 12))
+ colors = plt.cm.RdYlGn(np.linspace(0, 1, nProfiles))
+
+ print(f" 📍 Processing {nProfiles} profiles:")
+ print(f" Depth range: [{zMin:.1f}, {zMax:.1f}]m")
+
+ successfulProfiles = 0
+
+ # ===================================================================
+ # EXTRACT AND PLOT PROFILES
+ # ===================================================================
+
+ for i, (xPos, yPos, zPos) in enumerate(profileStartPoints):
+ print(f" → Profile {i+1}: starting at ({xPos:.1f}, {yPos:.1f}, {zPos:.1f})")
+
+ # depthsSigma, profileSigmaN, PathXSigma, PathYSigma = ProfileExtractor.extractVerticalProfileTopologyBased(
+ # surface, 'sigmaNEffective', xPos, yPos, zPos, verbose=True)
+
+ # depthsTau, profileTau, _, _ = ProfileExtractor.extractVerticalProfileTopologyBased(
+ # surface, 'tauEffective', xPos, yPos, zPos, verbose=False)
+
+ # depthsSCU, profileSCU, _, _ = ProfileExtractor.extractVerticalProfileTopologyBased(
+ # surface, 'SCU', xPos, yPos, zPos, verbose=False)
+
+ # depthsDeltaSCU, profileDeltaSCU, _, _ = ProfileExtractor.extractVerticalProfileTopologyBased(
+ # surface, 'deltaSCU', xPos, yPos, zPos, verbose=False)
+
+ depthsSigma, profileSigmaN, PathXSigma, PathYSigma = ProfileExtractor.extractAdaptiveProfile(
+ centers, sigmaN, xPos, yPos, searchRadius)
+
+ depthsTau, profileTau, _, _ = ProfileExtractor.extractAdaptiveProfile(
+ centers, tau, xPos, yPos, searchRadius)
+
+ depthsSCU, profileSCU, _, _ = ProfileExtractor.extractAdaptiveProfile(
+ centers, SCU, xPos, yPos, searchRadius)
+
+ depthsDeltaSCU, profileDeltaSCU, _, _ = ProfileExtractor.extractAdaptiveProfile(
+ centers, SCU, xPos, yPos, searchRadius)
+
+ # Calculate path length
+ if len(PathXSigma) > 1:
+ pathLength = np.sum(np.sqrt(
+ np.diff(PathXSigma)**2 +
+ np.diff(PathYSigma)**2 +
+ np.diff(depthsSigma)**2
+ ))
+ print(f" Path length: {pathLength:.1f}m (horizontal displacement: {np.abs(PathXSigma[-1] - PathXSigma[0]):.1f}m)")
+
+ if self.config.SHOW_PROFILE_EXTRACTOR:
+ ProfileExtractor.plotProfilePath3D(
+ surface=surface,
+ pathX=PathXSigma,
+ pathY=PathYSigma,
+ pathZ=depthsSigma,
+ profileValues=profileSigmaN,
+ scalarName='SCU',
+ savePath=path,
+ show=show
+ )
+
+ # Check if we have enough points
+ minPoints = 3
+ nPoints = len(depthsSigma)
+
+ if nPoints >= minPoints:
+ label = f'Profile {i+1} → ({xPos:.0f}, {yPos:.0f})'
+
+ # Plot 1: Normal stress vs depth
+ axes[0].plot(profileSigmaN, depthsSigma,
+ color=colors[i], label=label, linewidth=2.5, alpha=0.8,
+ marker='o', markersize=3, markevery=2)
+
+ # Plot 2: Shear stress vs depth
+ axes[1].plot(profileTau, depthsTau,
+ color=colors[i], label=label, linewidth=2.5, alpha=0.8,
+ marker='o', markersize=3, markevery=2)
+
+ # Plot 3: SCU vs depth
+ axes[2].plot(profileSCU, depthsSCU,
+ color=colors[i], label=label, linewidth=2.5, alpha=0.8,
+ marker='o', markersize=3, markevery=2)
+
+ # Plot 4: Detla SCU vs depth
+ axes[3].plot(profileDeltaSCU, depthsDeltaSCU,
+ color=colors[i], label=label, linewidth=2.5, alpha=0.8,
+ marker='o', markersize=3, markevery=2)
+
+ successfulProfiles += 1
+ print(f" ✅ {nPoints} points found")
+ else:
+ print(f" ⚠️ Insufficient points ({nPoints}), skipping")
+
+ if successfulProfiles == 0:
+ print(" ❌ No valid profiles found!")
+ plt.close()
+ return
+
+ # ===================================================================
+ # ADD REFERENCE DATA (GEOS + Analytical) - Only once
+ # ===================================================================
+
+ if geosData is not None:
+ # Colonnes: [Depth_m, Normal_Stress_bar, Shear_Stress_bar, SCU]
+ # Index: [0, 1, 2, 3]
+
+ axes[0].plot(geosData[:, 1] *10, geosData[:, 0], 'o',
+ color='blue', markersize=6, label='GEOS Contact Solver',
+ alpha=0.7, mec='k', mew=1, fillstyle='none')
+
+ axes[1].plot(geosData[:, 2] *10, geosData[:, 0], 'o',
+ color='blue', markersize=6, label='GEOS Contact Solver',
+ alpha=0.7, mec='k', mew=1, fillstyle='none')
+
+ if geosData.shape[1] > 3: # SCU column exists
+ axes[2].plot(geosData[:, 3], geosData[:, 0], 'o',
+ color='blue', markersize=6, label='GEOS Contact Solver',
+ alpha=0.7, mec='k', mew=1, fillstyle='none')
+
+ if analyticalData is not None:
+ # Format analytique (peut varier)
+ axes[0].plot(analyticalData[:, 1] * 10, analyticalData[:, 0], '--',
+ color='darkorange', linewidth=2, label='Analytical', alpha=0.8)
+ if analyticalData.shape[1] > 2:
+ axes[1].plot(analyticalData[:, 2] * 10, analyticalData[:, 0], '--',
+ color='darkorange', linewidth=2, label='Analytical', alpha=0.8)
+
+ # ===================================================================
+ # CONFIGURE PLOTS
+ # ===================================================================
+
+ fsize = 14
+
+ # Plot 1: Normal Stress
+ axes[0].set_xlabel('Normal Stress σₙ [bar]', fontsize=fsize, weight="bold")
+ axes[0].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
+ axes[0].set_title('Normal Stress Profile', fontsize=fsize+2, weight="bold")
+ axes[0].grid(True, alpha=0.3, linestyle='--')
+ axes[0].legend(loc='upper left', fontsize=fsize-2)
+ axes[0].tick_params(labelsize=fsize-2)
+
+ # Plot 2: Shear Stress
+ axes[1].set_xlabel('Shear Stress τ [bar]', fontsize=fsize, weight="bold")
+ axes[1].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
+ axes[1].set_title('Shear Stress Profile', fontsize=fsize+2, weight="bold")
+ axes[1].grid(True, alpha=0.3, linestyle='--')
+ axes[1].legend(loc='upper left', fontsize=fsize-2)
+ axes[1].tick_params(labelsize=fsize-2)
+
+ # Plot 3: SCU
+ axes[2].set_xlabel('SCU [-]', fontsize=fsize, weight="bold")
+ axes[2].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
+ axes[2].set_title('Shear Capacity Utilization', fontsize=fsize+2, weight="bold")
+ axes[2].axvline(x=0.8, color='forestgreen', linestyle='--', linewidth=2, label='Critical (0.8)')
+ axes[2].axvline(x=1.0, color='red', linestyle='--', linewidth=2, label='Failure (1.0)')
+ axes[2].grid(True, alpha=0.3, linestyle='--')
+ axes[2].legend(loc='upper right', fontsize=fsize-2)
+ axes[2].tick_params(labelsize=fsize-2)
+ axes[2].set_xlim(left=0)
+
+ # Plot 4: Delta SCU
+ axes[3].set_xlabel('Δ SCU [-]', fontsize=fsize, weight="bold")
+ axes[3].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
+ axes[3].set_title('Delta SCU', fontsize=fsize+2, weight="bold")
+ axes[3].grid(True, alpha=0.3, linestyle='--')
+ axes[3].legend(loc='upper right', fontsize=fsize-2)
+ axes[3].tick_params(labelsize=fsize-2)
+ axes[3].set_xlim(left=0, right=2)
+
+ # Change verticale scale
+ if self.config.MAX_DEPTH_PROFILES != None :
+ for i in range(len(axes)):
+ axes[i].set_ylim(bottom=self.config.MAX_DEPTH_PROFILES)
+
+ if self.config.MIN_DEPTH_PROFILES != None :
+ for i in range(len(axes)):
+ axes[i].set_ylim(top=self.config.MIN_DEPTH_PROFILES)
+
+ # Overall title
+ years = time / (365.25 * 24 * 3600)
+ fig.suptitle(f'Fault Depth Profiles - t={years:.1f} years',
+ fontsize=fsize+2, fontweight='bold', y=0.98)
+
+ plt.tight_layout(rect=[0, 0, 1, 0.96])
+
+ # Save
+ if save:
+ filename = f'depth_profiles_{years:.0f}y.png'
+ plt.savefig(path / filename, dpi=300, bbox_inches='tight')
+ print(f" 💾 Depth profiles saved: {filename}")
+
+ # Show
+ if show:
+ plt.show()
+ else:
+ plt.close()
+
+ # -------------------------------------------------------------------
+ def plotVolumeStressProfiles(self, volumeMesh, faultSurface, time, path,
+ show=True, save=True,
+ profileStartPoints=None,
+ maxProfilePoints=1000):
+ """
+ Plot stress profiles in volume cells adjacent to the fault
+ Extracts profiles through contributing cells on BOTH sides of the fault
+ Shows plus side and minus side on the same plots for comparison
+
+ NOTE: Cette fonction utilise extractAdaptiveProfile pour les VOLUMES
+ car volumeMesh n'est PAS un maillage surfacique.
+ La méthode topologique (extractVerticalProfileTopologyBased)
+ est réservée aux maillages SURFACIQUES (faultSurface).
+ """
+
+ print(" 📊 Creating volume stress profiles (both sides)")
+
+ # ===================================================================
+ # CHECK IF REQUIRED DATA EXISTS
+ # ===================================================================
+
+ requiredFields = ['sigma1', 'sigma2', 'sigma3', 'side', 'elementCenter']
+
+ for field in requiredFields:
+ if field not in volumeMesh.cell_data:
+ print(f" ⚠️ Missing required field: {field}")
+ return
+
+ # Check for pressure
+ if 'pressure_bar' in volumeMesh.cell_data:
+ pressureField = 'pressure_bar'
+ pressure = volumeMesh.cell_data[pressureField]
+ elif 'pressure' in volumeMesh.cell_data:
+ pressureField = 'pressure'
+ pressure = volumeMesh.cell_data[pressureField] / 1e5
+ print(" ℹ️ Converting pressure from Pa to bar")
+ else:
+ print(" ⚠️ No pressure field found")
+ pressure = None
+
+ # Extract volume data
+ centers = volumeMesh.cell_data['elementCenter']
+ sigma1 = volumeMesh.cell_data['sigma1']
+ sigma2 = volumeMesh.cell_data['sigma2']
+ sigma3 = volumeMesh.cell_data['sigma3']
+ sideData = volumeMesh.cell_data['side']
+
+ # ===================================================================
+ # FILTER CELLS BY SIDE (BOTH PLUS AND MINUS)
+ # ===================================================================
+
+ # Plus side (side = 1 or 3)
+ maskPlus = (sideData == 1) | (sideData == 3)
+ centersPlus = centers[maskPlus]
+ sigma1Plus = sigma1[maskPlus]
+ sigma2Plus = sigma2[maskPlus]
+ sigma3Plus = sigma3[maskPlus]
+ if pressure is not None:
+ pressurePlus = pressure[maskPlus]
+
+ # Créer subset de cellData pour le côté plus
+ cellDataPlus = {}
+ for key in volumeMesh.cell_data.keys():
+ cellDataPlus[key] = volumeMesh.cell_data[key][maskPlus]
+
+ # Minus side (side = 2 or 3)
+ maskMinus = (sideData == 2) | (sideData == 3)
+ centersMinus = centers[maskMinus]
+ sigma1Minus = sigma1[maskMinus]
+ sigma2Minus = sigma2[maskMinus]
+ sigma3Minus = sigma3[maskMinus]
+ if pressure is not None:
+ pressureMinus = pressure[maskMinus]
+
+ # Créer subset de cellData pour le côté minus
+ cellDataMinus = {}
+ for key in volumeMesh.cell_data.keys():
+ cellDataMinus[key] = volumeMesh.cell_data[key][maskMinus]
+
+ print(f" 📍 Plus side: {len(centersPlus):,} cells")
+ print(f" 📍 Minus side: {len(centersMinus):,} cells")
+
+ if len(centersPlus) == 0 and len(centersMinus) == 0:
+ print(" ⚠️ No contributing cells found!")
+ return
+
+ # ===================================================================
+ # GET FAULT BOUNDS
+ # ===================================================================
+
+ faultCenters = faultSurface.cell_data['elementCenter']
+
+ xMin, xMax = np.min(faultCenters[:, 0]), np.max(faultCenters[:, 0])
+ yMin, yMax = np.min(faultCenters[:, 1]), np.max(faultCenters[:, 1])
+ zMin, zMax = np.min(faultCenters[:, 2]), np.max(faultCenters[:, 2])
+
+ xRange = xMax - xMin
+ yRange = yMax - yMin
+ zRange = zMax - zMin
+
+ # Search radius (pour extractAdaptiveProfile sur volumes)
+ if self.config.PROFILE_SEARCH_RADIUS is not None:
+ searchRadius = self.config.PROFILE_SEARCH_RADIUS
+ else:
+ searchRadius = min(xRange, yRange) * 0.2
+
+ # ===================================================================
+ # AUTO-GENERATE PROFILE POINTS IF NOT PROVIDED
+ # ===================================================================
+
+ if profileStartPoints is None:
+ print(" ⚠️ No profileStartPoints provided, auto-generating...")
+ nProfiles = 3
+
+ if xRange > yRange:
+ coordName = 'X'
+ fixedValue = (yMin + yMax) / 2
+ samplePositions = np.linspace(xMin, xMax, nProfiles)
+ profileStartPoints = [(x, fixedValue, zMax) for x in samplePositions]
+ else:
+ coordName = 'Y'
+ fixedValue = (xMin + xMax) / 2
+ samplePositions = np.linspace(yMin, yMax, nProfiles)
+ profileStartPoints = [(fixedValue, y, zMax) for y in samplePositions]
+
+ print(f" Auto-generated {nProfiles} profiles along {coordName}")
+
+ nProfiles = len(profileStartPoints)
+
+ # ===================================================================
+ # CREATE FIGURE WITH 5 SUBPLOTS
+ # ===================================================================
+
+ fig, axes = plt.subplots(1, 5, figsize=(22, 10))
+
+ # Colors: different for plus and minus sides
+ colorsPlus = plt.cm.Reds(np.linspace(0.4, 0.9, nProfiles))
+ colorsMinus = plt.cm.Blues(np.linspace(0.4, 0.9, nProfiles))
+
+ print(f" 📍 Processing {nProfiles} volume profiles:")
+ print(f" Depth range: [{zMin:.1f}, {zMax:.1f}]m")
+
+ successfulProfiles = 0
+
+ # ===================================================================
+ # EXTRACT AND PLOT PROFILES FOR BOTH SIDES
+ # ===================================================================
+
+ for i, (xPos, yPos, zPos) in enumerate(profileStartPoints):
+ print(f"\n → Profile {i+1}: starting at ({xPos:.1f}, {yPos:.1f}, {zPos:.1f})")
+
+ # ================================================================
+ # PLUS SIDE
+ # ================================================================
+ if len(centersPlus) > 0:
+ print(f" Processing PLUS side...")
+
+ # Pour VOLUMES, utiliser extractAdaptiveProfile avec cellData
+ depthsSigma1Plus, profileSigma1Plus, _, _ = ProfileExtractor.extractAdaptiveProfile(
+ centersPlus, sigma1Plus, xPos, yPos, zPos,
+ searchRadius, verbose=True, cellData=cellDataPlus)
+
+ depthsSigma2Plus, profileSigma2Plus, _, _ = ProfileExtractor.extractAdaptiveProfile(
+ centersPlus, sigma2Plus, xPos, yPos, zPos,
+ searchRadius, verbose=False, cellData=cellDataPlus)
+
+ depthsSigma3Plus, profileSigma3Plus, _, _ = ProfileExtractor.extractAdaptiveProfile(
+ centersPlus, sigma3Plus, xPos, yPos, zPos,
+ searchRadius, verbose=False, cellData=cellDataPlus)
+
+ if pressure is not None:
+ depthsPressurePlus, profilePressurePlus, _, _ = ProfileExtractor.extractAdaptiveProfile(
+ centersPlus, pressurePlus, xPos, yPos, zPos,
+ searchRadius, verbose=False, cellData=cellDataPlus)
+
+ if len(depthsSigma1Plus) >= 3:
+ labelPlus = f'Plus side'
+
+ # Plot Pressure
+ if pressure is not None:
+ axes[0].plot(profilePressurePlus, depthsPressurePlus,
+ color=colorsPlus[i], label=labelPlus if i == 0 else '',
+ linewidth=2.5, alpha=0.8, marker='o', markersize=3, markevery=2)
+
+ # Plot σ1
+ axes[1].plot(profileSigma1Plus, depthsSigma1Plus,
+ color=colorsPlus[i], label=labelPlus if i == 0 else '',
+ linewidth=2.5, alpha=0.8, marker='o', markersize=3, markevery=2)
+
+ # Plot σ2
+ axes[2].plot(profileSigma2Plus, depthsSigma2Plus,
+ color=colorsPlus[i], label=labelPlus if i == 0 else '',
+ linewidth=2.5, alpha=0.8, marker='o', markersize=3, markevery=2)
+
+ # Plot σ3
+ axes[3].plot(profileSigma3Plus, depthsSigma3Plus,
+ color=colorsPlus[i], label=labelPlus if i == 0 else '',
+ linewidth=2.5, alpha=0.8, marker='o', markersize=3, markevery=2)
+
+ # Plot All stresses
+ axes[4].plot(profileSigma1Plus, depthsSigma1Plus,
+ color=colorsPlus[i], linewidth=2.5, alpha=0.8,
+ linestyle='-', marker="o", markersize=2, markevery=2)
+ axes[4].plot(profileSigma2Plus, depthsSigma2Plus,
+ color=colorsPlus[i], linewidth=2.0, alpha=0.6,
+ linestyle='-', marker="s", markersize=2, markevery=2)
+ axes[4].plot(profileSigma3Plus, depthsSigma3Plus,
+ color=colorsPlus[i], linewidth=2.5, alpha=0.8,
+ linestyle='-', marker="v", markersize=2, markevery=2)
+
+ print(f" ✅ PLUS: {len(depthsSigma1Plus)} points")
+ successfulProfiles += 1
+
+ # ================================================================
+ # MINUS SIDE
+ # ================================================================
+ if len(centersMinus) > 0:
+ print(f" Processing MINUS side...")
+
+ # Pour VOLUMES, utiliser extractAdaptiveProfile avec cellData
+ depthsSigma1Minus, profileSigma1Minus, _, _ = ProfileExtractor.extractAdaptiveProfile(
+ centersMinus, sigma1Minus, xPos, yPos, zPos,
+ searchRadius, verbose=True, cellData=cellDataMinus)
+
+ depthsSigma2Minus, profileSigma2Minus, _, _ = ProfileExtractor.extractAdaptiveProfile(
+ centersMinus, sigma2Minus, xPos, yPos, zPos,
+ searchRadius, verbose=False, cellData=cellDataMinus)
+
+ depthsSigma3Minus, profileSigma3Minus, _, _ = ProfileExtractor.extractAdaptiveProfile(
+ centersMinus, sigma3Minus, xPos, yPos, zPos,
+ searchRadius, verbose=False, cellData=cellDataMinus)
+
+ if pressure is not None:
+ depthsPressureMinus, profilePressureMinus, _, _ = ProfileExtractor.extractAdaptiveProfile(
+ centersMinus, pressureMinus, xPos, yPos, zPos,
+ searchRadius, verbose=False, cellData=cellDataMinus)
+
+ if len(depthsSigma1Minus) >= 3:
+ labelMinus = f'Minus side'
+
+ # Plot Pressure
+ if pressure is not None:
+ axes[0].plot(profilePressureMinus, depthsPressureMinus,
+ color=colorsMinus[i], label=labelMinus if i == 0 else '',
+ linewidth=2.5, alpha=0.8, marker='s', markersize=3, markevery=2)
+
+ # Plot σ1
+ axes[1].plot(profileSigma1Minus, depthsSigma1Minus,
+ color=colorsMinus[i], label=labelMinus if i == 0 else '',
+ linewidth=2.5, alpha=0.8, marker='s', markersize=3, markevery=2)
+
+ # Plot σ2
+ axes[2].plot(profileSigma2Minus, depthsSigma2Minus,
+ color=colorsMinus[i], label=labelMinus if i == 0 else '',
+ linewidth=2.5, alpha=0.8, marker='s', markersize=3, markevery=2)
+
+ # Plot σ3
+ axes[3].plot(profileSigma3Minus, depthsSigma3Minus,
+ color=colorsMinus[i], label=labelMinus if i == 0 else '',
+ linewidth=2.5, alpha=0.8, marker='s', markersize=3, markevery=2)
+
+ # Plot All stresses
+ axes[4].plot(profileSigma1Minus, depthsSigma1Minus,
+ color=colorsMinus[i], linewidth=2.5, alpha=0.8,
+ linestyle='-', marker="o", markersize=2, markevery=2)
+ axes[4].plot(profileSigma2Minus, depthsSigma2Minus,
+ color=colorsMinus[i], linewidth=2.0, alpha=0.6,
+ linestyle='-', marker="s", markersize=2, markevery=2)
+ axes[4].plot(profileSigma3Minus, depthsSigma3Minus,
+ color=colorsMinus[i], linewidth=2.5, alpha=0.8,
+ linestyle='-', marker='v', markersize=2, markevery=2)
+
+ print(f" ✅ MINUS: {len(depthsSigma1Minus)} points")
+ successfulProfiles += 1
+
+ if successfulProfiles == 0:
+ print(" ❌ No valid profiles found!")
+ plt.close()
+ return
+
+ # ===================================================================
+ # CONFIGURE PLOTS
+ # ===================================================================
+
+ fsize = 14
+
+ # Plot 0: Pressure
+ axes[0].set_xlabel('Pressure [bar]', fontsize=fsize, weight="bold")
+ axes[0].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
+ axes[0].grid(True, alpha=0.3, linestyle='--')
+ axes[0].legend(loc='best', fontsize=fsize-2)
+ axes[0].tick_params(labelsize=fsize-2)
+
+ if pressure is None:
+ axes[0].text(0.5, 0.5, 'No pressure data available',
+ ha='center', va='center', transform=axes[0].transAxes,
+ fontsize=fsize, style='italic', color='gray')
+
+ # Plot 1: σ1 (Maximum principal stress)
+ axes[1].set_xlabel('σ₁ (Max Principal) [bar]', fontsize=fsize, weight="bold")
+ axes[1].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
+ axes[1].grid(True, alpha=0.3, linestyle='--')
+ axes[1].legend(loc='best', fontsize=fsize-2)
+ axes[1].tick_params(labelsize=fsize-2)
+
+ # Plot 2: σ2 (Intermediate principal stress)
+ axes[2].set_xlabel('σ₂ (Inter Principal) [bar]', fontsize=fsize, weight="bold")
+ axes[2].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
+ axes[2].grid(True, alpha=0.3, linestyle='--')
+ axes[2].legend(loc='best', fontsize=fsize-2)
+ axes[2].tick_params(labelsize=fsize-2)
+
+ # Plot 3: σ3 (Min principal stress)
+ axes[3].set_xlabel('σ₃ (Min Principal) [bar]', fontsize=fsize, weight="bold")
+ axes[3].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
+ axes[3].grid(True, alpha=0.3, linestyle='--')
+ axes[3].legend(loc='best', fontsize=fsize-2)
+ axes[3].tick_params(labelsize=fsize-2)
+
+ # Plot 4: All stresses together
+ axes[4].set_xlabel('Principal Stresses [bar]', fontsize=fsize, weight="bold")
+ axes[4].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
+ axes[4].grid(True, alpha=0.3, linestyle='--')
+ axes[4].tick_params(labelsize=fsize-2)
+
+ # Add legend for line styles
+ customLines = [
+ Line2D([0], [0], color='red', linewidth=2.5, marker=None, label='Plus side', alpha=0.5),
+ Line2D([0], [0], color='blue', linewidth=2.5, marker=None, label='Minus side', alpha=0.5),
+ Line2D([0], [0], color='gray', linewidth=2.5, linestyle='-', marker='o', label='σ₁ (max)'),
+ Line2D([0], [0], color='gray', linewidth=2.0, linestyle='-', marker='s', label='σ₂ (inter)'),
+ Line2D([0], [0], color='gray', linewidth=2.5, linestyle='-', marker='v', label='σ₃ (min)')
+ ]
+ axes[4].legend(handles=customLines, loc='best', fontsize=fsize-3, ncol=1)
+
+ # Change verticale scale
+ if self.config.MAX_DEPTH_PROFILES != None :
+ for i in range(len(axes)):
+ axes[i].set_ylim(bottom=self.config.MAX_DEPTH_PROFILES)
+
+ if self.config.MIN_DEPTH_PROFILES != None :
+ for i in range(len(axes)):
+ axes[i].set_ylim(top=self.config.MIN_DEPTH_PROFILES)
+
+ # Overall title
+ years = time / (365.25 * 24 * 3600)
+ fig.suptitle(f'Volume Stress Profiles - Both Sides Comparison - t={years:.1f} years',
+ fontsize=fsize+2, fontweight='bold', y=0.98)
+
+ plt.tight_layout(rect=[0, 0, 1, 0.96])
+
+ # Save
+ if save:
+ filename = f'volume_stress_profiles_both_sides_{years:.0f}y.png'
+ plt.savefig(path / filename, dpi=300, bbox_inches='tight')
+ print(f" 💾 Volume profiles saved: {filename}")
+
+ # Show
+ if show:
+ plt.show()
+ else:
+ plt.close()
+
+ # -------------------------------------------------------------------
+ def plotAnalyticalVsNumericalComparison(self, volumeMesh, faultSurface, time, path,
+ show=True, save=True,
+ profileStartPoints=None,
+ referenceProfileId=1):
+ """
+ Plot comparison between analytical fault stresses (Anderson formulas)
+ and numerical tensor projection - COMBINED PLOTS ONLY
+
+ Parameters
+ ----------
+ volumeMesh : pyvista.UnstructuredGrid
+ Volume mesh with principal stresses AND analytical stresses
+ faultSurface : pyvista.PolyData
+ Fault surface mesh with projected stresses
+ time : float
+ Simulation time
+ path : Path
+ Output directory
+ show : bool
+ Show plot interactively
+ save : bool
+ Save plot to file
+ profileStartPoints : list of tuples
+ Starting points (x, y, z) for profiles
+ referenceProfileId : int
+ Which profile ID to load from Excel reference data
+ """
+
+ print("\n 📊 Creating Analytical vs Numerical Comparison")
+
+ # ===================================================================
+ # CHECK IF ANALYTICAL DATA EXISTS
+ # ===================================================================
+
+ requiredAnalytical = ['sigmaNAnalytical', 'tauAnalytical', 'side', 'elementCenter']
+
+ for field in requiredAnalytical:
+ if field not in volumeMesh.cell_data:
+ print(f" ⚠️ Missing analytical field: {field}")
+ print(f" Analytical stresses not computed in volume mesh")
+ return
+
+ # Check numerical data on fault surface
+ if 'sigmaNEffective' not in faultSurface.cell_data:
+ print(f" ⚠️ Missing numerical stress data on fault surface")
+ return
+
+ # ===================================================================
+ # LOAD REFERENCE DATA (GEOS Contact Solver)
+ # ===================================================================
+
+ print(" 📂 Loading GEOS Contact Solver reference data...")
+ scriptDir = os.path.dirname(os.path.abspath(__file__))
+ referenceData = Visualizer.loadReferenceData(
+ time,
+ scriptDir,
+ profileId=referenceProfileId
+ )
+
+ geosContactData = referenceData.get('geos', None)
+
+ if geosContactData is not None:
+ print(f" ✅ Loaded {len(geosContactData)} reference points from GEOS Contact Solver")
+ else:
+ print(f" ⚠️ No GEOS Contact Solver reference data found")
+
+ # Extraire les IDs de faille
+ faultIdsVolume = None
+ faultIdsSurface = None
+
+ if 'faultId' in volumeMesh.cell_data:
+ faultIdsVolume = volumeMesh.cell_data['faultId']
+
+ if 'FaultMask' in faultSurface.cell_data:
+ faultIdsSurface = faultSurface.cell_data['FaultMask']
+ elif 'attribute' in faultSurface.cell_data:
+ faultIdsSurface = faultSurface.cell_data['attribute']
+
+ # ===================================================================
+ # EXTRACT DATA
+ # ===================================================================
+
+ # Volume analytical data
+ centersVolume = volumeMesh.cell_data['elementCenter']
+ sideData = volumeMesh.cell_data['side']
+ sigmaNAnalytical = volumeMesh.cell_data['sigmaNAnalytical']
+ tauAnalytical = volumeMesh.cell_data['tauAnalytical']
+
+ # Optional: SCU if available
+ hasSCUAnalytical = 'SCUAnalytical' in volumeMesh.cell_data
+ if hasSCUAnalytical:
+ SCUAnalytical = volumeMesh.cell_data['SCUAnalytical']
+
+ # Fault numerical data
+ centersFault = faultSurface.cell_data['elementCenter']
+ sigmaNNumerical = faultSurface.cell_data['sigmaNEffective']
+ tauNumerical = faultSurface.cell_data['tauEffective']
+
+ # Optional: SCU numerical
+ hasSCUNumerical = 'SCU' in faultSurface.cell_data
+ if hasSCUNumerical:
+ SCUNumerical = faultSurface.cell_data['SCU']
+
+ # Filter volume by side
+ maskPlus = (sideData == 1) | (sideData == 3)
+ maskMinus = (sideData == 2) | (sideData == 3)
+
+ centersPlus = centersVolume[maskPlus]
+ sigmaNAnalyticalPlus = sigmaNAnalytical[maskPlus]
+ tauAnalyticalPlus = tauAnalytical[maskPlus]
+ if hasSCUAnalytical:
+ SCUAnalyticalPlus = SCUAnalytical[maskPlus]
+
+ centersMinus = centersVolume[maskMinus]
+ sigmaNAnalyticalMinus = sigmaNAnalytical[maskMinus]
+ tauAnalyticalMinus = tauAnalytical[maskMinus]
+ if hasSCUAnalytical:
+ SCUAnalyticalMinus = SCUAnalytical[maskMinus]
+
+ print(f" 📍 Plus side: {len(centersPlus):,} cells with analytical data")
+ print(f" 📍 Minus side: {len(centersMinus):,} cells with analytical data")
+ print(f" 📍 Fault surface: {len(centersFault):,} cells with numerical data")
+
+ # ===================================================================
+ # GET FAULT BOUNDS AND PROFILE SETUP
+ # ===================================================================
+
+ xMin, xMax = np.min(centersFault[:, 0]), np.max(centersFault[:, 0])
+ yMin, yMax = np.min(centersFault[:, 1]), np.max(centersFault[:, 1])
+ zMin, zMax = np.min(centersFault[:, 2]), np.max(centersFault[:, 2])
+
+ xRange = xMax - xMin
+ yRange = yMax - yMin
+
+ # Search radius
+ if self.config.PROFILE_SEARCH_RADIUS is not None:
+ searchRadius = self.config.PROFILE_SEARCH_RADIUS
+ else:
+ searchRadius = min(xRange, yRange) * 0.2
+
+ # Auto-generate profile points if not provided
+ if profileStartPoints is None:
+ print(" ⚠️ No profileStartPoints provided, auto-generating...")
+ nProfiles = 3
+
+ if xRange > yRange:
+ coordName = 'X'
+ fixedValue = (yMin + yMax) / 2
+ samplePositions = np.linspace(xMin, xMax, nProfiles)
+ profileStartPoints = [(x, fixedValue, zMax) for x in samplePositions]
+ else:
+ coordName = 'Y'
+ fixedValue = (xMin + xMax) / 2
+ samplePositions = np.linspace(yMin, yMax, nProfiles)
+ profileStartPoints = [(fixedValue, y, zMax) for y in samplePositions]
+
+ print(f" Auto-generated {nProfiles} profiles along {coordName}")
+
+ nProfiles = len(profileStartPoints)
+
+ # ===================================================================
+ # CREATE FIGURE: COMBINED PLOTS ONLY
+ # 3 columns (σ_n, τ, SCU) x 1 row
+ # ===================================================================
+
+ fig, axes = plt.subplots(1, 3, figsize=(18, 10))
+
+ print(f" 📍 Processing {nProfiles} profiles for comparison:")
+
+ successfulProfiles = 0
+
+ # ===================================================================
+ # EXTRACT AND PLOT PROFILES
+ # ===================================================================
+
+ for i, (xPos, yPos, zPos) in enumerate(profileStartPoints):
+ print(f"\n → Profile {i+1}: starting at ({xPos:.1f}, {yPos:.1f}, {zPos:.1f})")
+
+ # ================================================================
+ # PLUS SIDE - ANALYTICAL
+ # ================================================================
+ if len(centersPlus) > 0:
+ depthsSnAnaPlus, profileSnAnaPlus, _, _ = ProfileExtractor.extractAdaptiveProfile(
+ centersPlus, sigmaNAnalyticalPlus, xPos, yPos, zPos,
+ searchRadius, verbose=False)
+
+ depthsTauAnaPlus, profileTauAnaPlus, _, _ = ProfileExtractor.extractAdaptiveProfile(
+ centersPlus, tauAnalyticalPlus, xPos, yPos, zPos,
+ searchRadius, verbose=False)
+
+ if hasSCUAnalytical:
+ depthsSCUAnaPlus, profileSCUAnaPlus, _, _ = ProfileExtractor.extractAdaptiveProfile(
+ centersPlus, SCUAnalyticalPlus, xPos, yPos, zPos,
+ searchRadius, verbose=False, )
+
+ if len(depthsSnAnaPlus) >= 3:
+ # Plot σ_n
+ axes[0].plot(profileSnAnaPlus, depthsSnAnaPlus,
+ color='red', linestyle='-', linewidth=2,
+ alpha=0.3, label='Analytical Side +' if i == 0 else '', marker=None)
+
+ # Plot τ
+ axes[1].plot(profileTauAnaPlus, depthsTauAnaPlus,
+ color='red', linestyle='-', linewidth=2,
+ alpha=0.3, label='Analytical Side +' if i == 0 else '', marker=None)
+
+ # Plot SCU if available
+ if hasSCUAnalytical and len(depthsSCUAnaPlus) >= 3:
+ axes[2].plot(profileSCUAnaPlus, depthsSCUAnaPlus,
+ color='red', linestyle='-', linewidth=2,
+ alpha=0.3, label='Analytical Side +' if i == 0 else '', marker=None)
+
+ # ================================================================
+ # MINUS SIDE - ANALYTICAL
+ # ================================================================
+ if len(centersMinus) > 0:
+ depthsSigmaNAnaMinus, profileSigmaNAnaMinus, _, _ = ProfileExtractor.extractAdaptiveProfile(
+ centersMinus, sigmaNAnalyticalMinus, xPos, yPos, zPos,
+ searchRadius, verbose=False)
+
+ depthsTauAnaMinus, profileTauAnaMinus, _, _ = ProfileExtractor.extractAdaptiveProfile(
+ centersMinus, tauAnalyticalMinus, xPos, yPos, zPos,
+ searchRadius, verbose=False)
+
+ if hasSCUAnalytical:
+ depthsSCUAnaMinus, profileSCUAnaMinus, _, _ = ProfileExtractor.extractAdaptiveProfile(
+ centersMinus, SCUAnalyticalMinus, xPos, yPos, zPos,
+ searchRadius, verbose=False)
+
+ if len(depthsSigmaNAnaMinus) >= 3:
+ # Plot σ_n
+ axes[0].plot(profileSigmaNAnaMinus, depthsSigmaNAnaMinus,
+ color='blue', linestyle='-', linewidth=2,
+ alpha=0.3, label='Analytical Side -' if i == 0 else '', marker=None)
+
+ # Plot τ
+ axes[1].plot(profileTauAnaMinus, depthsTauAnaMinus,
+ color='blue', linestyle='-', linewidth=2,
+ alpha=0.3, label='Analytical Side -' if i == 0 else '', marker=None)
+
+ # Plot SCU if available
+ if hasSCUAnalytical and len(depthsSCUAnaMinus) >= 3:
+ axes[2].plot(profileSCUAnaMinus, depthsSCUAnaMinus,
+ color='blue', linestyle='-', linewidth=2,
+ alpha=0.3, label='Analytical Side -' if i == 0 else '', marker=None)
+
+ # ================================================================
+ # AVERAGES - ANALYTICAL (only for first profile to avoid clutter)
+ # ================================================================
+ if i == 0 and len(depthsSigmaNAnaMinus) >= 3 and len(depthsSnAnaPlus) >= 3:
+ # Arithmetic average
+ avgSigmaNArith = (profileSigmaNAnaMinus + profileSnAnaPlus) / 2
+ avgTauArith = (profileTauAnaMinus + profileTauAnaPlus) / 2
+
+ axes[0].plot(avgSigmaNArith, depthsSigmaNAnaMinus,
+ color='darkorange', linestyle='-', linewidth=2,
+ alpha=0.6, label='Arithmetic average')
+
+ axes[1].plot(avgTauArith, depthsSigmaNAnaMinus,
+ color='darkorange', linestyle='-', linewidth=2,
+ alpha=0.6, label='Arithmetic average')
+
+ # Geometric average
+ avgTauGeom = np.sqrt(profileTauAnaMinus * profileTauAnaPlus)
+
+ axes[1].plot(avgTauGeom, depthsSigmaNAnaMinus,
+ color='purple', linestyle='-', linewidth=2,
+ alpha=0.6, label='Geometric average')
+
+ # Harmonic average
+ AvgSigmaNHarm = 2 / (1/profileSigmaNAnaMinus + 1/profileSnAnaPlus)
+ AvgTauHarm = 2 / (1/profileTauAnaMinus + 1/profileTauAnaPlus)
+
+ axes[0].plot(AvgSigmaNHarm, depthsSigmaNAnaMinus,
+ color='green', linestyle='-', linewidth=2,
+ alpha=0.6, label='Harmonic average')
+
+ axes[1].plot(AvgTauHarm, depthsSigmaNAnaMinus,
+ color='green', linestyle='-', linewidth=2,
+ alpha=0.6, label='Harmonic average')
+
+ # ================================================================
+ # NUMERICAL DATA FROM FAULT SURFACE (Continuum)
+ # ================================================================
+ print(f" Extracting numerical data from fault surface...")
+
+ depthsSigmaNNum, profileSigmaNNum, _, _ = ProfileExtractor.extractAdaptiveProfile(
+ centersFault, sigmaNNumerical, xPos, yPos, zPos,
+ searchRadius, verbose=False)
+
+ depthsTauNum, profileTauNum, _, _ = ProfileExtractor.extractAdaptiveProfile(
+ centersFault, tauNumerical, xPos, yPos, zPos,
+ searchRadius, verbose=False)
+
+ if hasSCUNumerical:
+ depthsSCUNum, profileSCUNum, _, _ = ProfileExtractor.extractAdaptiveProfile(
+ centersFault, SCUNumerical, xPos, yPos, zPos,
+ searchRadius, verbose=False)
+
+ if len(depthsSigmaNNum) >= 3:
+ # Plot numerical with distinct style
+ axes[0].plot(profileSigmaNNum, depthsSigmaNNum,
+ color='black', linestyle='-', linewidth=2,
+ alpha=0.7, label='GEOS Continuum' if i == 0 else '',
+ marker='x', markersize=5, markevery=3)
+
+ axes[1].plot(profileTauNum, depthsTauNum,
+ color='black', linestyle='-', linewidth=2,
+ alpha=0.7, label='GEOS Continuum' if i == 0 else '',
+ marker='x', markersize=5, markevery=3)
+
+ if hasSCUNumerical and len(depthsSCUNum) >= 3:
+ axes[2].plot(profileSCUNum, depthsSCUNum,
+ color='black', linestyle='-', linewidth=2,
+ alpha=0.7, label='GEOS Continuum' if i == 0 else '',
+ marker='x', markersize=5, markevery=3)
+
+ successfulProfiles += 1
+
+ if successfulProfiles == 0:
+ print(" ❌ No valid profiles found!")
+ plt.close()
+ return
+
+ # ===================================================================
+ # ADD GEOS CONTACT SOLVER REFERENCE DATA (only once)
+ # ===================================================================
+
+ if geosContactData is not None:
+ # Format: [Depth_m, Normal_Stress_bar, Shear_Stress_bar, SCU]
+ # Index: [0, 1, 2, 3]
+
+ print(" 📊 Adding GEOS Contact Solver reference data...")
+
+ # Normal stress
+ axes[0].plot(geosContactData[:, 1], geosContactData[:, 0],
+ marker='o', color='black', markersize=7,
+ label='GEOS Contact Solver', linestyle='none',
+ alpha=0.8, mec='black', mew=1.5, fillstyle='none')
+
+ # Shear stress
+ axes[1].plot(geosContactData[:, 2], geosContactData[:, 0],
+ marker='o', color='black', markersize=7,
+ label='GEOS Contact Solver', linestyle='none',
+ alpha=0.8, mec='black', mew=1.5, fillstyle='none')
+
+ # SCU (if available)
+ if geosContactData.shape[1] > 3:
+ axes[2].plot(geosContactData[:, 3], geosContactData[:, 0],
+ marker='o', color='black', markersize=7,
+ label='GEOS Contact Solver', linestyle='none',
+ alpha=0.8, mec='black', mew=1.5, fillstyle='none')
+
+ # ===================================================================
+ # CONFIGURE PLOTS
+ # ===================================================================
+
+ fsize = 14
+
+ # Plot 0: Normal Stress
+ axes[0].set_xlabel('Normal Stress σₙ [bar]', fontsize=fsize, weight="bold")
+ axes[0].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
+ axes[0].grid(True, alpha=0.3, linestyle='--')
+ axes[0].legend(loc='best', fontsize=fsize-2)
+ axes[0].tick_params(labelsize=fsize-1)
+
+ # Plot 1: Shear Stress
+ axes[1].set_xlabel('Shear Stress τ [bar]', fontsize=fsize, weight="bold")
+ axes[1].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
+ axes[1].grid(True, alpha=0.3, linestyle='--')
+ axes[1].legend(loc='best', fontsize=fsize-2)
+ axes[1].tick_params(labelsize=fsize-1)
+
+ # Plot 2: SCU
+ axes[2].set_xlabel('SCU [-]', fontsize=fsize, weight="bold")
+ axes[2].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
+ axes[2].axvline(x=0.8, color='forestgreen', linestyle='--', linewidth=2,
+ alpha=0.5, label='Critical (0.8)')
+ axes[2].axvline(x=1.0, color='red', linestyle='--', linewidth=2,
+ alpha=0.5, label='Failure (1.0)')
+ axes[2].grid(True, alpha=0.3, linestyle='--')
+ axes[2].legend(loc='upper right', fontsize=fsize-2, ncol=1)
+ axes[2].tick_params(labelsize=fsize-1)
+ axes[2].set_xlim(left=0)
+
+ # Overall title
+ years = time / (365.25 * 24 * 3600)
+ fig.suptitle(f'Analytical (Anderson) vs Numerical (GEOS Continuum & Contact) - t={years:.1f} years',
+ fontsize=fsize+2, fontweight='bold', y=0.995)
+
+ # Change verticale scale
+ if self.config.MAX_DEPTH_PROFILES != None :
+ for i in range(len(axes)):
+ axes[i].set_ylim(bottom=self.config.MAX_DEPTH_PROFILES)
+
+ if self.config.MIN_DEPTH_PROFILES != None :
+ for i in range(len(axes)):
+ axes[i].set_ylim(top=self.config.MIN_DEPTH_PROFILES)
+
+ plt.tight_layout(rect=[0, 0, 1, 0.99])
+
+ # Save
+ if save:
+ filename = f'analytical_vs_numerical_comparison_{years:.0f}y.png'
+ plt.savefig(path / filename, dpi=300, bbox_inches='tight')
+ print(f"\n 💾 Comparison plot saved: {filename}")
+
+ # Show
+ if show:
+ plt.show()
+ else:
+ plt.close()
+
From 237d21ae5be51c8bb27a2a0c085d8ae0b7eb6fad Mon Sep 17 00:00:00 2001
From: Paloma Martinez <104762252+paloma-martinez@users.noreply.github.com>
Date: Fri, 6 Feb 2026 16:59:37 +0100
Subject: [PATCH 3/5] First pass of typing
---
.../geos/geomechanics/model/StressTensor.py | 63 +-
.../post_processing/FaultGeometry.py | 821 ++++-----
.../post_processing/FaultStabilityAnalysis.py | 351 ++--
.../post_processing/ProfileExtractor.py | 618 ++++---
.../post_processing/SensitivityAnalyzer.py | 287 +--
.../post_processing/StressProjector.py | 634 +++----
.../geos/processing/tools/FaultVisualizer.py | 1572 ++++++++++-------
7 files changed, 2277 insertions(+), 2069 deletions(-)
diff --git a/geos-geomechanics/src/geos/geomechanics/model/StressTensor.py b/geos-geomechanics/src/geos/geomechanics/model/StressTensor.py
index c64ebf90..464bdcbb 100644
--- a/geos-geomechanics/src/geos/geomechanics/model/StressTensor.py
+++ b/geos-geomechanics/src/geos/geomechanics/model/StressTensor.py
@@ -3,55 +3,60 @@
# SPDX-FileContributor: Nicolas Pillardou, Paloma Martinez
import numpy as np
+import numpy.typing as npt
+from typing_extensions import Any
+
# ============================================================================
# STRESS TENSOR OPERATIONS
# ============================================================================
class StressTensor:
- """Utility class for stress tensor operations"""
+ """Utility class for stress tensor operations."""
@staticmethod
- def buildFromArray(arr):
- """Convert stress array to 3x3 tensor format"""
- n = arr.shape[0]
- tensors = np.zeros((n, 3, 3))
-
- if arr.shape[1] == 6: # Voigt notation
- tensors[:, 0, 0] = arr[:, 0] # Sxx
- tensors[:, 1, 1] = arr[:, 1] # Syy
- tensors[:, 2, 2] = arr[:, 2] # Szz
- tensors[:, 1, 2] = tensors[:, 2, 1] = arr[:, 3] # Syz
- tensors[:, 0, 2] = tensors[:, 2, 0] = arr[:, 4] # Sxz
- tensors[:, 0, 1] = tensors[:, 1, 0] = arr[:, 5] # Sxy
- elif arr.shape[1] == 9:
- tensors = arr.reshape((-1, 3, 3))
+ def buildFromArray( arr: npt.NDArray[ np.float64 ] ) -> npt.NDArray[ np.float64 ]:
+ """Convert stress array to 3x3 tensor format."""
+ n = arr.shape[ 0 ]
+ tensors: npt.NDArray[ np.float64 ] = np.zeros( ( n, 3, 3 ), dtype=np.float64 )
+
+ if arr.shape[ 1 ] == 6: # Voigt notation
+ tensors[ :, 0, 0 ] = arr[ :, 0 ] # Sxx
+ tensors[ :, 1, 1 ] = arr[ :, 1 ] # Syy
+ tensors[ :, 2, 2 ] = arr[ :, 2 ] # Szz
+ tensors[ :, 1, 2 ] = tensors[ :, 2, 1 ] = arr[ :, 3 ] # Syz
+ tensors[ :, 0, 2 ] = tensors[ :, 2, 0 ] = arr[ :, 4 ] # Sxz
+ tensors[ :, 0, 1 ] = tensors[ :, 1, 0 ] = arr[ :, 5 ] # Sxy
+ elif arr.shape[ 1 ] == 9:
+ tensors = arr.reshape( ( -1, 3, 3 ) )
else:
- raise ValueError(f"Unsupported stress shape: {arr.shape}")
+ raise ValueError( f"Unsupported stress shape: {arr.shape}" )
return tensors
@staticmethod
- def rotateToFaultFrame(stressTensor, normal, tangent1, tangent2):
- """Rotate stress tensor to fault local coordinate system"""
+ def rotateToFaultFrame( stressTensorarr: npt.NDArray[ np.float64 ], normal: npt.NDArray[ np.float64 ],
+ tangent1: npt.NDArray[ np.float64 ],
+ tangent2: npt.NDArray[ np.float64 ] ) -> dict[ str, Any ]:
+ """Rotate stress tensor to fault local coordinate system."""
# Verify orthonormality
- assert np.abs(np.linalg.norm(tangent1) - 1.0) < 1e-10
- assert np.abs(np.linalg.norm(tangent2) - 1.0) < 1e-10
- assert np.abs(np.dot(normal, tangent1)) < 1e-10
- assert np.abs(np.dot(normal, tangent2)) < 1e-10
+ assert np.abs( np.linalg.norm( tangent1 ) - 1.0 ) < 1e-10
+ assert np.abs( np.linalg.norm( tangent2 ) - 1.0 ) < 1e-10
+ assert np.abs( np.dot( normal, tangent1 ) ) < 1e-10
+ assert np.abs( np.dot( normal, tangent2 ) ) < 1e-10
# Rotation matrix: columns = local directions (n, t1, t2)
- R = np.column_stack((normal, tangent1, tangent2))
+ R = np.column_stack( ( normal, tangent1, tangent2 ) )
# Rotate tensor
- stressLocal = R.T @ stressTensor @ R
+ stressLocal = R.T @ stressTensorarr @ R
# Traction on fault plane (normal = [1,0,0] in local frame)
- tractionLocal = stressLocal @ np.array([1.0, 0.0, 0.0])
+ tractionLocal = stressLocal @ np.array( [ 1.0, 0.0, 0.0 ] )
return {
'stressLocal': stressLocal,
- 'normalStress': tractionLocal[0],
- 'shearStress': np.sqrt(tractionLocal[1]**2 + tractionLocal[2]**2),
- 'shearStrike': tractionLocal[1],
- 'shearDip': tractionLocal[2]
+ 'normalStress': tractionLocal[ 0 ],
+ 'shearStress': np.sqrt( tractionLocal[ 1 ]**2 + tractionLocal[ 2 ]**2 ),
+ 'shearStrike': tractionLocal[ 1 ],
+ 'shearDip': tractionLocal[ 2 ]
}
diff --git a/geos-processing/src/geos/processing/post_processing/FaultGeometry.py b/geos-processing/src/geos/processing/post_processing/FaultGeometry.py
index 67f92ecb..59bc95d2 100644
--- a/geos-processing/src/geos/processing/post_processing/FaultGeometry.py
+++ b/geos-processing/src/geos/processing/post_processing/FaultGeometry.py
@@ -5,24 +5,31 @@
# FAULT GEOMETRY
# ============================================================================
import pyvista as pv
+import numpy as np
from pathlib import Path
+from typing_extensions import Self, Any
from vtkmodules.vtkCommonDataModel import vtkCellLocator
+from vtkmodules.vtkCommonDataModel import vtkIdList
+import numpy.typing as npt
+from scipy.spatial import cKDTree
+
+from geos.processing.FaultStabilityAnalysis import Config
-class FaultGeometry:
- """Handles fault surface extraction and normal computation with optimizations"""
+class FaultGeometry:
+ """Handles fault surface extraction and normal computation with optimizations."""
# -------------------------------------------------------------------
- def __init__(self, config, mesh, faultValues, faultAttribute, volumeMesh):
- """
- Initialize fault geometry with pre-computed topology.
+ def __init__( self: Self, config: Config, mesh: pv.DataSet, faultValues: list[ int ], faultAttribute: str,
+ volumeMesh: pv.DataSet ) -> None:
+ """Initialize fault geometry with pre-computed topology.
Args:
config (Config):
- mesh (): pv.read(path / config.GRID_FILE) -> "mesh_faulted_reservoir_60_mod.vtu"
+ mesh (pv.DataSet): pv.read(path / config.GRID_FILE) -> "mesh_faulted_reservoir_60_mod.vtu"
faultValues (list[int]): Config.FAULT_VALUES
faultAttribute (str): Config.FAULT_ATTRIBUTES
- volumeMesh (): processor._merge_blocks(dataset)
+ volumeMesh (pv.DataSet): processor._merge_blocks(dataset)
"""
self.mesh = mesh
self.faultValues = faultValues
@@ -39,31 +46,29 @@ def __init__(self, config, mesh, faultValues, faultAttribute, volumeMesh):
# NEW: Pre-computed geometric properties
self.volumeCellVolumes = None # Volume of each cell
- self.volumeCenters = None # Center coordinates
- self.distanceToFault = None # Distance from each volume cell to nearest fault
- self.faultTree = None # KDTree for fault surface
+ self.volumeCenters = None # Center coordinates
+ self.distanceToFault = None # Distance from each volume cell to nearest fault
+ self.faultTree = None # KDTree for fault surface
# Config
self.config = config
# -------------------------------------------------------------------
- def initialize(self, scaleFactor=50.0, processFaultsSeparately=True):
- """
- One-time initialization: compute normals, adjacency topology, and geometric properties
- """
-
+ def initialize( self: Self,
+ scaleFactor: float = 50.0,
+ processFaultsSeparately: bool = True ) -> tuple[ pv.DataSet, dict[ int, pv.DataSet ] ]:
+ """One-time initialization: compute normals, adjacency topology, and geometric properties."""
# Extract and compute normals
- self.faultSurface, self.surfaces = self._extractAndComputeNormals(
- showPlot=self.config.SHOW_NORMAL_PLOTS,
- scaleFactor=scaleFactor,
- zScale=self.config.Z_SCALE)
+ self.faultSurface, self.surfaces = self._extractAndComputeNormals( showPlot=self.config.SHOW_NORMAL_PLOTS,
+ scaleFactor=scaleFactor,
+ zScale=self.config.Z_SCALE )
# Pre-compute adjacency mapping
- print("\n🔍 Pre-computing volume-fault adjacency topology")
- print(" Method: Face-sharing (adaptive epsilon)")
+ print( "\n🔍 Pre-computing volume-fault adjacency topology" )
+ print( " Method: Face-sharing (adaptive epsilon)" )
self.adjacencyMapping = self._buildAdjacencyMappingFaceSharing(
- processFaultsSeparately=processFaultsSeparately)
+ processFaultsSeparately=processFaultsSeparately )
# Mark and optionally save contributing cells
self._markContributingCells()
@@ -71,13 +76,13 @@ def initialize(self, scaleFactor=50.0, processFaultsSeparately=True):
# NEW: Pre-compute geometric properties
self._precomputeGeometricProperties()
- nMapped = len(self.adjacencyMapping)
- nWithBoth = sum(1 for m in self.adjacencyMapping.values()
- if len(m['plus']) > 0 and len(m['minus']) > 0)
+ nMapped = len( self.adjacencyMapping )
+ nWithBoth = sum( 1 for m in self.adjacencyMapping.values()
+ if len( m[ 'plus' ] ) > 0 and len( m[ 'minus' ] ) > 0 )
- print("\n✅ Adjacency topology computed:")
- print(f" - {nMapped}/{self.faultSurface.n_cells} fault cells mapped")
- print(f" - {nWithBoth} cells have neighbors on both sides")
+ print( "\n✅ Adjacency topology computed:" )
+ print( f" - {nMapped}/{self.faultSurface.n_cells} fault cells mapped" )
+ print( f" - {nWithBoth} cells have neighbors on both sides" )
# Visualize contributions if requested
if self.config.SHOW_CONTRIBUTION_VIZ:
@@ -86,11 +91,9 @@ def initialize(self, scaleFactor=50.0, processFaultsSeparately=True):
return self.faultSurface, self.adjacencyMapping
# -------------------------------------------------------------------
- def _markContributingCells(self):
- """
- Mark volume cells that contribute to fault stress projection
- """
- print("\n📦 Marking contributing volume cells...")
+ def _markContributingCells( self: Self ) -> None:
+ """Mark volume cells that contribute to fault stress projection."""
+ print( "\n📦 Marking contributing volume cells..." )
nVolume = self.volumeMesh.n_cells
@@ -98,85 +101,82 @@ def _markContributingCells(self):
allPlus = set()
allMinus = set()
- for faultIdx, neighbors in self.adjacencyMapping.items():
- allPlus.update(neighbors['plus'])
- allMinus.update(neighbors['minus'])
+ for _faultIdx, neighbors in self.adjacencyMapping.items():
+ allPlus.update( neighbors[ 'plus' ] )
+ allMinus.update( neighbors[ 'minus' ] )
# Create classification array
- contributionSide = np.zeros(nVolume, dtype=int)
+ contributionSide = np.zeros( nVolume, dtype=int )
for idx in allPlus:
if 0 <= idx < nVolume:
- contributionSide[idx] += 1
+ contributionSide[ idx ] += 1
for idx in allMinus:
if 0 <= idx < nVolume:
- contributionSide[idx] += 2
+ contributionSide[ idx ] += 2
# Add classification to volume mesh
- self.volumeMesh.cell_data["contributionSide"] = contributionSide
+ self.volumeMesh.cell_data[ "contributionSide" ] = contributionSide
contribMask = contributionSide > 0
- self.volumeMesh.cell_data["contribution_to_faults"] = contribMask.astype(int)
+ self.volumeMesh.cell_data[ "contribution_to_faults" ] = contribMask.astype( int )
# Extract subsets
maskAll = contribMask
- maskPlus = (contributionSide == 1) | (contributionSide == 3)
- maskMinus = (contributionSide == 2) | (contributionSide == 3)
+ maskPlus = ( contributionSide == 1 ) | ( contributionSide == 3 )
+ maskMinus = ( contributionSide == 2 ) | ( contributionSide == 3 )
- self.contributingCells = self.volumeMesh.extract_cells(maskAll)
- self.contributingCellsPlus = self.volumeMesh.extract_cells(maskPlus)
- self.contributingCellsMinus = self.volumeMesh.extract_cells(maskMinus)
+ self.contributingCells = self.volumeMesh.extract_cells( maskAll )
+ self.contributingCellsPlus = self.volumeMesh.extract_cells( maskPlus )
+ self.contributingCellsMinus = self.volumeMesh.extract_cells( maskMinus )
# Statistics
- nContrib = np.sum(maskAll)
- nPlus = np.sum(contributionSide == 1)
- nMinus = np.sum(contributionSide == 2)
- nBoth = np.sum(contributionSide == 3)
+ nContrib = np.sum( maskAll )
+ nPlus = np.sum( contributionSide == 1 )
+ nMinus = np.sum( contributionSide == 2 )
+ nBoth = np.sum( contributionSide == 3 )
pctContrib = nContrib / nVolume * 100
- print(f" ✅ Total contributing: {nContrib}/{nVolume} ({pctContrib:.1f}%)")
- print(f" Plus side only: {nPlus} cells")
- print(f" Minus side only: {nMinus} cells")
- print(f" Both sides: {nBoth} cells")
+ print( f" ✅ Total contributing: {nContrib}/{nVolume} ({pctContrib:.1f}%)" )
+ print( f" Plus side only: {nPlus} cells" )
+ print( f" Minus side only: {nMinus} cells" )
+ print( f" Both sides: {nBoth} cells" )
# Save to files if requested
if self.config.SAVE_CONTRIBUTION_CELLS:
self._saveContributingCells()
# -------------------------------------------------------------------
- def _saveContributingCells(self):
- """
- Save contributing volume cells to VTU files
- Saves three files: all, plus side, minus side
- """
- from pathlib import Path
+ def _saveContributingCells( self: Self ) -> None:
+ """Save contributing volume cells to VTU files.
+ Saves three files: all, plus side, minus side.
+ """
# Create output directory if it doesn't exist
- outputDir = Path(self.config.OUTPUT_DIR) if hasattr(self.config, 'OUTPUT_DIR') else Path('.')
- outputDir.mkdir(parents=True, exist_ok=True)
+ outputDir = Path( self.config.OUTPUT_DIR ) if hasattr( self.config, 'OUTPUT_DIR' ) else Path( '.' )
+ outputDir.mkdir( parents=True, exist_ok=True )
# Save all contributing cells
filenameAll = outputDir / "contributing_cells_all.vtu"
- self.contributingCells.save(str(filenameAll))
- print(f"\n 💾 All contributing cells saved: {filenameAll}")
- print(f" ({self.contributingCells.n_cells} cells, {self.contributingCells.n_points} points)")
+ self.contributingCells.save( str( filenameAll ) )
+ print( f"\n 💾 All contributing cells saved: {filenameAll}" )
+ print( f" ({self.contributingCells.n_cells} cells, {self.contributingCells.n_points} points)" )
# Save plus side
- filenamePlus = outputDir / "contributingCellsPlus.vtu"
+ outputDir / "contributingCellsPlus.vtu"
# self.contributingCellsPlus.save(str(filenamePlus))
# print(f" 💾 Plus side cells saved: {filenamePlus}")
- print(f" ({self.contributingCellsPlus.n_cells} cells, {self.contributingCellsPlus.n_points} points)")
+ print( f" ({self.contributingCellsPlus.n_cells} cells, {self.contributingCellsPlus.n_points} points)" )
# Save minus side
- filenameMinus = outputDir / "contributingCellsMinus.vtu"
+ outputDir / "contributingCellsMinus.vtu"
# self.contributingCellsMinus.save(str(filenameMinus))
# print(f" 💾 Minus side cells saved: {filenameMinus}")
- print(f" ({self.contributingCellsMinus.n_cells} cells, {self.contributingCellsMinus.n_points} points)")
+ print( f" ({self.contributingCellsMinus.n_cells} cells, {self.contributingCellsMinus.n_points} points)" )
# -------------------------------------------------------------------
- def getContributingCells(self, side='all'):
- """
- Get the extracted contributing cells
+ def getContributingCells( self: Self, side: str = 'all' ) -> pv.UnstructuredGrid:
+ """Get the extracted contributing cells.
Parameters:
side: 'all', 'plus', or 'minus'
@@ -185,7 +185,7 @@ def getContributingCells(self, side='all'):
pyvista.UnstructuredGrid: Contributing volume cells
"""
if self.contributingCells is None:
- raise ValueError("Contributing cells not yet computed. Call initialize() first.")
+ raise ValueError( "Contributing cells not yet computed. Call initialize() first." )
if side == 'all':
return self.contributingCells
@@ -194,14 +194,13 @@ def getContributingCells(self, side='all'):
elif side == 'minus':
return self.contributingCellsMinus
else:
- raise ValueError(f"Invalid side '{side}'. Must be 'all', 'plus', or 'minus'.")
+ raise ValueError( f"Invalid side '{side}'. Must be 'all', 'plus', or 'minus'." )
# -------------------------------------------------------------------
- def getGeometricProperties(self):
- """
- Get pre-computed geometric properties
+ def getGeometricProperties( self: Self ) -> dict[ str, Any ]:
+ """Get pre-computed geometric properties.
- Returns
+ Returns:
-------
dict with keys:
- 'volumes': ndarray of cell volumes
@@ -210,7 +209,7 @@ def getGeometricProperties(self):
- 'faultTree': KDTree for fault surface
"""
if self.volumeCellVolumes is None:
- raise ValueError("Geometric properties not computed. Call initialize() first.")
+ raise ValueError( "Geometric properties not computed. Call initialize() first." )
return {
'volumes': self.volumeCellVolumes,
@@ -220,9 +219,8 @@ def getGeometricProperties(self):
}
# -------------------------------------------------------------------
- def _precomputeGeometricProperties(self):
- """
- Pre-compute geometric properties of volume mesh for efficient stress projection
+ def _precomputeGeometricProperties( self: Self ) -> None:
+ """Pre-compute geometric properties of volume mesh for efficient stress projection.
Computes:
- Cell volumes (for volume-weighted averaging)
@@ -230,114 +228,104 @@ def _precomputeGeometricProperties(self):
- Distance from each volume cell to nearest fault cell
- KDTree for fault surface
"""
- print("\n📐 Pre-computing geometric properties...")
+ print( "\n📐 Pre-computing geometric properties..." )
nVolume = self.volumeMesh.n_cells
# 1. Compute volume centers
- print(" Computing cell centers...")
+ print( " Computing cell centers..." )
self.volumeCenters = self.volumeMesh.cell_centers().points
# 2. Compute cell volumes
- print(" Computing cell volumes...")
- volumeWithSizes = self.volumeMesh.compute_cell_sizes(
- length=False, area=False, volume=True
- )
- self.volumeCellVolumes = volumeWithSizes.cell_data['Volume']
+ print( " Computing cell volumes..." )
+ volumeWithSizes = self.volumeMesh.compute_cell_sizes( length=False, area=False, volume=True )
+ self.volumeCellVolumes = volumeWithSizes.cell_data[ 'Volume' ]
- print(f" Volume range: [{np.min(self.volumeCellVolumes):.1e}, "
- f"{np.max(self.volumeCellVolumes):.1e}] m³")
+ print( f" Volume range: [{np.min(self.volumeCellVolumes):.1e}, "
+ f"{np.max(self.volumeCellVolumes):.1e}] m³" )
# 3. Build KDTree for fault surface (for fast distance queries)
- print(" Building KDTree for fault surface...")
+ print( " Building KDTree for fault surface..." )
faultCenters = self.faultSurface.cell_centers().points
- self.faultTree = cKDTree(faultCenters)
+ self.faultTree = cKDTree( faultCenters )
# 4. Compute distance from each volume cell to nearest fault cell
- print(" Computing distances to fault...")
- self.distanceToFault = np.zeros(nVolume)
+ print( " Computing distances to fault..." )
+ self.distanceToFault = np.zeros( nVolume )
# Vectorized query for all points at once (much faster)
- distances, _ = self.faultTree.query(self.volumeCenters)
+ distances, _ = self.faultTree.query( self.volumeCenters )
self.distanceToFault = distances
- print(f" Distance range: [{np.min(self.distanceToFault):.1f}, "
- f"{np.max(self.distanceToFault):.1f}] m")
+ print( f" Distance range: [{np.min(self.distanceToFault):.1f}, "
+ f"{np.max(self.distanceToFault):.1f}] m" )
# 5. Add these properties to volume mesh for reference
- self.volumeMesh.cell_data['cellVolume'] = self.volumeCellVolumes # TODO FIX
- self.volumeMesh.cell_data['distanceToFault'] = self.distanceToFault
+ self.volumeMesh.cell_data[ 'cellVolume' ] = self.volumeCellVolumes # TODO FIX
+ self.volumeMesh.cell_data[ 'distanceToFault' ] = self.distanceToFault
- print(" ✅ Geometric properties computed and cached")
+ print( " ✅ Geometric properties computed and cached" )
# -------------------------------------------------------------------
- def _buildAdjacencyMappingFaceSharing(self, processFaultsSeparately=True):
- """
- Build adjacency for cells sharing faces with fault
- Uses adaptive epsilon optimization
- """
+ def _buildAdjacencyMappingFaceSharing( self: Self,
+ processFaultsSeparately: bool = True ) -> dict[ int, pv.DataSet ]:
+ """Build adjacency for cells sharing faces with fault.
- faultIds = np.unique(self.faultSurface.cell_data[self.faultAttribute])
- nFaults = len(faultIds)
- print(f" 📋 Processing {nFaults} separate faults: {faultIds}")
+ Uses adaptive epsilon optimization.
+ """
+ faultIds = np.unique( self.faultSurface.cell_data[ self.faultAttribute ] )
+ nFaults = len( faultIds )
+ print( f" 📋 Processing {nFaults} separate faults: {faultIds}" )
allMappings = {}
for faultId in faultIds:
- mask = self.faultSurface.cell_data[self.faultAttribute] == faultId
- indices = np.where(mask)[0]
- singleFault = self.faultSurface.extract_cells(indices)
+ mask = self.faultSurface.cell_data[ self.faultAttribute ] == faultId
+ indices = np.where( mask )[ 0 ]
+ singleFault = self.faultSurface.extract_cells( indices )
- print(f" 🔧 Mapping Fault {faultId}...")
+ print( f" 🔧 Mapping Fault {faultId}..." )
# Build face-sharing mapping with adaptive epsilon
- localMapping = self._findFaceSharingCells(singleFault)
+ localMapping = self._findFaceSharingCells( singleFault )
# Remap local indices to global fault indices
for localIdx, neighbors in localMapping.items():
- globalIdx = indices[localIdx]
- allMappings[globalIdx] = neighbors
+ globalIdx = indices[ localIdx ]
+ allMappings[ globalIdx ] = neighbors
return allMappings
# -------------------------------------------------------------------
- def _findFaceSharingCells(self, faultSurface):
- """
- Find volume cells that share a FACE with fault cells
+ def _findFaceSharingCells( self: Self, faultSurface: pv.DataSet ) -> pv.DataSet:
+ """Find volume cells that share a FACE with fault cells.
Uses FindCell with adaptive epsilon to maximize cells with both neighbors
"""
volMesh = self.volumeMesh
volCenters = volMesh.cell_centers().points
- faultNormals = faultSurface.cell_data["Normals"]
+ faultNormals = faultSurface.cell_data[ "Normals" ]
faultCenters = faultSurface.cell_centers().points
# Determine base epsilon based on mesh size
volBounds = volMesh.bounds
- typicalSize = np.mean([
- volBounds[1] - volBounds[0],
- volBounds[3] - volBounds[2],
- volBounds[5] - volBounds[4]
- ]) / 100.0
+ typicalSize = np.mean( [
+ volBounds[ 1 ] - volBounds[ 0 ], volBounds[ 3 ] - volBounds[ 2 ], volBounds[ 5 ] - volBounds[ 4 ]
+ ] ) / 100.0
# Build VTK cell locator (once)
locator = vtkCellLocator()
- locator.SetDataSet(volMesh)
+ locator.SetDataSet( volMesh )
locator.BuildLocator()
# Try multiple epsilon values and keep the best
epsilonCandidates = [
- typicalSize * 0.005,
- typicalSize * 0.01,
- typicalSize * 0.05,
- typicalSize * 0.1,
- typicalSize * 0.2,
- typicalSize * 0.5,
- typicalSize * 1.0
+ typicalSize * 0.005, typicalSize * 0.01, typicalSize * 0.05, typicalSize * 0.1, typicalSize * 0.2,
+ typicalSize * 0.5, typicalSize * 1.0
]
- print(f" Testing {len(epsilonCandidates)} epsilon values...")
+ print( f" Testing {len(epsilonCandidates)} epsilon values..." )
bestEpsilon = None
bestMapping = None
@@ -346,17 +334,14 @@ def _findFaceSharingCells(self, faultSurface):
for epsilon in epsilonCandidates:
# Test this epsilon
- mapping, stats = self._testEpsilon(
- faultSurface, locator, epsilon,
- faultCenters, faultNormals, volCenters
- )
+ mapping, stats = self._testEpsilon( faultSurface, locator, epsilon, faultCenters, faultNormals, volCenters )
# Score = percentage with both sides + penalty for no neighbors
- score = stats['pctBoth'] - 2.0 * stats['pctNone']
+ score = stats[ 'pctBoth' ] - 2.0 * stats[ 'pctNone' ]
- print(f" ε={epsilon:.3f}m → Both: {stats['pctBoth']:.1f}%, "
- f"One: {stats['pctOne']:.1f}%, None: {stats['pctNone']:.1f}%, "
- f"Avg: {stats['avgNeighbors']:.2f} (score: {score:.1f})")
+ print( f" ε={epsilon:.3f}m → Both: {stats['pctBoth']:.1f}%, "
+ f"One: {stats['pctOne']:.1f}%, None: {stats['pctNone']:.1f}%, "
+ f"Avg: {stats['avgNeighbors']:.2f} (score: {score:.1f})" )
if score > bestScore:
bestScore = score
@@ -364,20 +349,31 @@ def _findFaceSharingCells(self, faultSurface):
bestMapping = mapping
bestStats = stats
- print(f"\n ✅ Best epsilon: {bestEpsilon:.6f}m")
- print(f" ✅ Face-sharing mapping completed:")
- print(f" Both sides: {bestStats['nBoth']} ({bestStats['pctBoth']:.1f}%)")
- print(f" One side: {bestStats['nOne']} ({bestStats['pctOne']:.1f}%)")
- print(f" No neighbors: {bestStats['nNone']} ({bestStats['pctNone']:.1f}%)")
- print(f" Average neighbors per fault cell: {bestStats['avgNeighbors']:.2f}")
+ print( f"\n ✅ Best epsilon: {bestEpsilon:.6f}m" )
+ print( " ✅ Face-sharing mapping completed:" )
+ print( f" Both sides: {bestStats['nBoth']} ({bestStats['pctBoth']:.1f}%)" )
+ print( f" One side: {bestStats['nOne']} ({bestStats['pctOne']:.1f}%)" )
+ print( f" No neighbors: {bestStats['nNone']} ({bestStats['pctNone']:.1f}%)" )
+ print( f" Average neighbors per fault cell: {bestStats['avgNeighbors']:.2f}" )
return bestMapping
# -------------------------------------------------------------------
- def _testEpsilon(self, faultSurface, locator, epsilon,
- faultCenters, faultNormals, volCenters):
- """
- Test a specific epsilon value and return mapping + statistics
+ def _testEpsilon( self: Self, faultSurface: pv.DataSet, locator: vtkCellLocator, epsilon: list[ float ],
+ faultCenters, faultNormals: npt.NDArray[ np.float64 ], volCenters ) -> tuple[ dict[
+ int,
+ dict[ str, list[ vtkIdList ] ],
+ ], dict[ str, Any ] ]:
+ """Test a specific epsilon value and return mapping + statistics.
+
+ Statistics include:
+ - 'nBoth': nFoundBoth,
+ - 'nOne': nFoundOne,
+ - 'nNone': nFoundNone,
+ - 'pctBoth': nFoundBoth / nCells * 100,
+ - 'pctOne': nFoundOne / nCells * 100,
+ - 'pctNone': nFoundNone / nCells * 100,
+ - 'avgNeighbors': avgNeighbors
"""
mapping = {}
nFoundBoth = 0
@@ -385,34 +381,35 @@ def _testEpsilon(self, faultSurface, locator, epsilon,
nFoundNone = 0
totalNeighbors = 0
- for fid in range(faultSurface.n_cells):
- fcenter = faultCenters[fid]
- fnormal = faultNormals[fid]
+ for fid in range( faultSurface.n_cells ):
+ fcenter = faultCenters[ fid ]
+ fnormal = faultNormals[ fid ]
plusCells = []
minusCells = []
# Search on PLUS side
pointPlus = fcenter + epsilon * fnormal
- cellIdPlus = locator.FindCell(pointPlus)
+ cellIdPlus = locator.FindCell( pointPlus )
+ print( cellIdPlus )
if cellIdPlus >= 0:
- plusCells.append(cellIdPlus)
+ plusCells.append( cellIdPlus )
# Search on MINUS side
pointMinus = fcenter - epsilon * fnormal
- cellIdMinus = locator.FindCell(pointMinus)
+ cellIdMinus = locator.FindCell( pointMinus )
if cellIdMinus >= 0:
- minusCells.append(cellIdMinus)
+ minusCells.append( cellIdMinus )
- mapping[fid] = {"plus": plusCells, "minus": minusCells}
+ mapping[ fid ] = { "plus": plusCells, "minus": minusCells }
# Statistics
- nNeighbors = len(plusCells) + len(minusCells)
+ nNeighbors = len( plusCells ) + len( minusCells )
totalNeighbors += nNeighbors
- if len(plusCells) > 0 and len(minusCells) > 0:
+ if len( plusCells ) > 0 and len( minusCells ) > 0:
nFoundBoth += 1
- elif len(plusCells) > 0 or len(minusCells) > 0:
+ elif len( plusCells ) > 0 or len( minusCells ) > 0:
nFoundOne += 1
else:
nFoundNone += 1
@@ -433,135 +430,139 @@ def _testEpsilon(self, faultSurface, locator, epsilon,
return mapping, stats
# -------------------------------------------------------------------
- def _visualizeContributions(self):
- """
- Unified visualization of volume contributions to fault surfaces
- 4-panel view combining full context, side classification, clip, and slice
- """
+ def _visualizeContributions( self ) -> None:
+ """Unified visualization of volume contributions to fault surfaces.
-
- print("\n📊 Creating contribution visualization...")
+ 4-panel view combining full context, side classification, clip, and slice.
+ """
+ print( "\n📊 Creating contribution visualization..." )
# Create plotter with 4 subplots
- plotter = pv.Plotter(shape=(2, 2), window_size=[1800, 1400])
+ plotter = pv.Plotter( shape=( 2, 2 ), window_size=[ 1800, 1400 ] )
# ========== PLOT 1: Full context (top-left) ==========
- plotter.subplot(0, 0)
- plotter.add_text("Full Context - Volume & Fault", font_size=14, position='upper_edge')
+ plotter.subplot( 0, 0 )
+ plotter.add_text( "Full Context - Volume & Fault", font_size=14, position='upper_edge' )
# All volume (transparent)
- plotter.add_mesh(self.mesh, color='lightgray', opacity=0.05,
- show_edges=False, label='Volume')
+ plotter.add_mesh( self.mesh, color='lightgray', opacity=0.05, show_edges=False, label='Volume' )
# Fault surface (red)
- plotter.add_mesh(self.faultSurface, color='red', opacity=1,
- show_edges=True, label='Fault Surface')
+ plotter.add_mesh( self.faultSurface, color='red', opacity=1, show_edges=True, label='Fault Surface' )
- plotter.add_legend(loc="upper left")
+ plotter.add_legend( loc="upper left" )
plotter.add_axes()
- plotter.set_scale(zscale=self.config.Z_SCALE)
+ plotter.set_scale( zscale=self.config.Z_SCALE )
# ========== PLOT 2: Contributing cells by side (top-right) ==========
- plotter.subplot(0, 1)
- plotter.add_text("Contributing Cells",
- font_size=14, position='upper_edge')
+ plotter.subplot( 0, 1 )
+ plotter.add_text( "Contributing Cells", font_size=14, position='upper_edge' )
if 'contributionSide' in self.volumeMesh.cell_data:
# Plus side (blue)
if self.contributingCellsPlus.n_cells > 0:
- plotter.add_mesh(self.contributingCellsPlus, color='dodgerblue',
- opacity=1.0, show_edges=True,
- label=f'Plus side ({self.contributingCellsPlus.n_cells} cells)')
+ plotter.add_mesh( self.contributingCellsPlus,
+ color='dodgerblue',
+ opacity=1.0,
+ show_edges=True,
+ label=f'Plus side ({self.contributingCellsPlus.n_cells} cells)' )
# Minus side (orange)
if self.contributingCellsMinus.n_cells > 0:
- plotter.add_mesh(self.contributingCellsMinus, color='darkorange',
- opacity=1.0, show_edges=True,
- label=f'Minus side ({self.contributingCellsMinus.n_cells} cells)')
+ plotter.add_mesh( self.contributingCellsMinus,
+ color='darkorange',
+ opacity=1.0,
+ show_edges=True,
+ label=f'Minus side ({self.contributingCellsMinus.n_cells} cells)' )
# Fault surface for reference
- plotter.add_mesh(self.faultSurface, color='red', opacity=1.0,
- show_edges=True, label='Fault')
+ plotter.add_mesh( self.faultSurface, color='red', opacity=1.0, show_edges=True, label='Fault' )
- plotter.add_legend(loc='upper right')
+ plotter.add_legend( loc='upper right' )
plotter.add_axes()
- plotter.set_scale(zscale=self.config.Z_SCALE)
+ plotter.set_scale( zscale=self.config.Z_SCALE )
# ========== PLOT 3: Clipped view (bottom-left) ==========
- plotter.subplot(1, 0)
- plotter.add_text("Clipped View - Contributing Cells",
- font_size=14, position='upper_edge')
+ plotter.subplot( 1, 0 )
+ plotter.add_text( "Clipped View - Contributing Cells", font_size=14, position='upper_edge' )
# Determine clip position (middle of fault)
bounds = self.faultSurface.bounds
- clip_normal = [0, 0, -1] # Clip along Z axis
- clip_origin = [0,0, (bounds[4] + bounds[5]) / 2]
+ clip_normal = [ 0, 0, -1 ] # Clip along Z axis
+ clip_origin = [ 0, 0, ( bounds[ 4 ] + bounds[ 5 ] ) / 2 ]
# Clip and show contributing cells
if self.contributingCells.n_cells > 0:
- plotter.add_mesh_clip_plane(
- self.contributingCells,
- normal=clip_normal,
- origin=clip_origin,
- color='blue',
- opacity=1,
- show_edges=True,
- label='Contributing (clipped)'
- )
+ plotter.add_mesh_clip_plane( self.contributingCells,
+ normal=clip_normal,
+ origin=clip_origin,
+ color='blue',
+ opacity=1,
+ show_edges=True,
+ label='Contributing (clipped)' )
# Fault surface
- plotter.add_mesh(self.faultSurface, color='red', opacity=1.0,
- show_edges=True, label='Fault')
+ plotter.add_mesh( self.faultSurface, color='red', opacity=1.0, show_edges=True, label='Fault' )
- plotter.add_legend(loc='upper left')
+ plotter.add_legend( loc='upper left' )
plotter.add_axes()
- plotter.set_scale(zscale=self.config.Z_SCALE)
+ plotter.set_scale( zscale=self.config.Z_SCALE )
# ========== PLOT 4: Slice view (bottom-right) ==========
- plotter.subplot(1, 1)
+ plotter.subplot( 1, 1 )
# Determine slice position (middle of fault in Z)
- slice_position = (bounds[4] + bounds[5]) / 2
- plotter.add_text(f"Slice View at Z={slice_position:.1f}m",
- font_size=14, position='upper_edge')
+ slice_position = ( bounds[ 4 ] + bounds[ 5 ] ) / 2
+ plotter.add_text( f"Slice View at Z={slice_position:.1f}m", font_size=14, position='upper_edge' )
# Create slice of volume
- sliceVol = self.volumeMesh.slice(normal='z', origin=[0, 0, slice_position])
- sliceFault = self.faultSurface.slice(normal='z', origin=[0, 0, slice_position])
+ sliceVol = self.volumeMesh.slice( normal='z', origin=[ 0, 0, slice_position ] )
+ sliceFault = self.faultSurface.slice( normal='z', origin=[ 0, 0, slice_position ] )
# Show contributing vs non-contributing in slice
if 'contributionSide' in sliceVol.cell_data:
# Non-contributing cells (gray)
- nonContribMask = sliceVol.cell_data['contributionSide'] == 0
- if np.sum(nonContribMask) > 0:
- nonContrib = sliceVol.extract_cells(nonContribMask)
- plotter.add_mesh(nonContrib, color='lightgray', opacity=0.15,
- show_edges=True, line_width=1, label='Non-contributing')
+ nonContribMask = sliceVol.cell_data[ 'contributionSide' ] == 0
+ if np.sum( nonContribMask ) > 0:
+ nonContrib = sliceVol.extract_cells( nonContribMask )
+ plotter.add_mesh( nonContrib,
+ color='lightgray',
+ opacity=0.15,
+ show_edges=True,
+ line_width=1,
+ label='Non-contributing' )
# Plus side (blue)
plusMask = (sliceVol.cell_data['contributionSide'] == 1) | \
(sliceVol.cell_data['contributionSide'] == 3)
- if np.sum(plusMask) > 0:
- plusCells = sliceVol.extract_cells(plusMask)
- plotter.add_mesh(plusCells, color='dodgerblue', opacity=0.7,
- show_edges=True, line_width=2, label='Plus side')
+ if np.sum( plusMask ) > 0:
+ plusCells = sliceVol.extract_cells( plusMask )
+ plotter.add_mesh( plusCells,
+ color='dodgerblue',
+ opacity=0.7,
+ show_edges=True,
+ line_width=2,
+ label='Plus side' )
# Minus side (orange)
minusMask = (sliceVol.cell_data['contributionSide'] == 2) | \
(sliceVol.cell_data['contributionSide'] == 3)
- if np.sum(minusMask) > 0:
- minusCells = sliceVol.extract_cells(minusMask)
- plotter.add_mesh(minusCells, color='darkorange', opacity=0.7,
- show_edges=True, line_width=2, label='Minus side')
+ if np.sum( minusMask ) > 0:
+ minusCells = sliceVol.extract_cells( minusMask )
+ plotter.add_mesh( minusCells,
+ color='darkorange',
+ opacity=0.7,
+ show_edges=True,
+ line_width=2,
+ label='Minus side' )
# Fault slice (thick red line)
if sliceFault.n_cells > 0:
- plotter.add_mesh(sliceFault, color='red', line_width=6,
- label='Fault', render_lines_as_tubes=True)
+ plotter.add_mesh( sliceFault, color='red', line_width=6, label='Fault', render_lines_as_tubes=True )
- plotter.add_legend(loc='upper right')
+ plotter.add_legend( loc='upper right' )
plotter.add_axes()
- plotter.set_scale(zscale=self.config.Z_SCALE)
+ plotter.set_scale( zscale=self.config.Z_SCALE )
plotter.view_xy()
# Link all views for synchronized rotation
@@ -573,27 +574,30 @@ def _visualizeContributions(self):
else:
# Save screenshot
- outputDir = Path(self.config.OUTPUT_DIR) if hasattr(self.config, 'OUTPUT_DIR') else Path('.')
- outputDir.mkdir(parents=True, exist_ok=True)
+ outputDir = Path( self.config.OUTPUT_DIR ) if hasattr( self.config, 'OUTPUT_DIR' ) else Path( '.' )
+ outputDir.mkdir( parents=True, exist_ok=True )
screenshot_path = outputDir / "contribution_visualization.png"
- plotter.screenshot(str(screenshot_path))
- print(f" 💾 Visualization saved: {screenshot_path}")
+ plotter.screenshot( str( screenshot_path ) )
+ print( f" 💾 Visualization saved: {screenshot_path}" )
plotter.close()
# -------------------------------------------------------------------
# NORMALS
# -------------------------------------------------------------------
- def _extractAndComputeNormals(self, showPlot=False, scaleFactor=50.0, zScale=1.0):
- """Extract fault surfaces and compute oriented normals/tangents"""
+ def _extractAndComputeNormals( self: Self,
+ showPlot: bool = False,
+ scaleFactor: float = 50.0,
+ zScale: float = 1.0 ) -> tuple[ pv.DataSet, list[ pv.DataSet ] ]:
+ """Extract fault surfaces and compute oriented normals/tangents."""
surfaces = []
for faultId in self.faultValues:
# Extract fault cells
- faultMask = self.mesh.cell_data[self.faultAttribute] == faultId
- faultCells = self.mesh.extract_cells(faultMask)
+ faultMask = self.mesh.cell_data[ self.faultAttribute ] == faultId
+ faultCells = self.mesh.extract_cells( faultMask )
if faultCells.n_cells == 0:
- print(f"⚠️ No cells for fault {faultId}")
+ print( f"⚠️ No cells for fault {faultId}" )
continue
# Extract surface
@@ -602,70 +606,73 @@ def _extractAndComputeNormals(self, showPlot=False, scaleFactor=50.0, zScale=1.0
continue
# Compute normals
- surf.compute_normals(cell_normals=True, point_normals=True, inplace=True)
+ surf.compute_normals( cell_normals=True, point_normals=True, inplace=True )
# Orient normals consistently within the fault
- surf = self._orientNormals(surf)
+ surf = self._orientNormals( surf )
- surfaces.append(surf)
+ surfaces.append( surf )
- merged = pv.MultiBlock(surfaces).combine()
- print(f"✅ Normals computed for {merged.n_cells} fault cells")
+ merged = pv.MultiBlock( surfaces ).combine()
+ print( f"✅ Normals computed for {merged.n_cells} fault cells" )
if showPlot:
- self.plotGeometry(merged, scaleFactor, zScale)
+ self.plotGeometry( merged, scaleFactor, zScale )
return merged, surfaces
# -------------------------------------------------------------------
- def _orientNormals(self, surf):
- """Ensure normals point in consistent direction within the fault"""
- normals = surf.cell_data['Normals']
- meanNormal = np.mean(normals, axis=0)
- meanNormal /= np.linalg.norm(meanNormal)
+ def _orientNormals( self: Self, surf: pv.DataSet ) -> pv.DataSet:
+ """Ensure normals point in consistent direction within the fault."""
+ normals = surf.cell_data[ 'Normals' ]
+ meanNormal = np.mean( normals, axis=0 )
+ meanNormal /= np.linalg.norm( meanNormal )
- nCells = len(normals)
- tangents1 = np.zeros((nCells, 3))
- tangents2 = np.zeros((nCells, 3))
+ nCells = len( normals )
+ tangents1 = np.zeros( ( nCells, 3 ) )
+ tangents2 = np.zeros( ( nCells, 3 ) )
- for i, normal in enumerate(normals):
+ for i, normal in enumerate( normals ):
# Flip if pointing opposite to mean
- if np.dot(normal, meanNormal) < 0:
- normals[i] = -normal
+ if np.dot( normal, meanNormal ) < 0:
+ normals[ i ] = -normal
if self.config.ROTATE_NORMALS:
- normals[i] = -normal
+ normals[ i ] = -normal
# Compute orthogonal tangents
- normal = normals[i]
- if abs(normal[0]) > 1e-6 or abs(normal[1]) > 1e-6:
- t1 = np.array([-normal[1], normal[0], 0])
+ normal = normals[ i ]
+ if abs( normal[ 0 ] ) > 1e-6 or abs( normal[ 1 ] ) > 1e-6:
+ t1 = np.array( [ -normal[ 1 ], normal[ 0 ], 0 ] )
else:
- t1 = np.array([0, -normal[2], normal[1]])
+ t1 = np.array( [ 0, -normal[ 2 ], normal[ 1 ] ] )
- t1 /= np.linalg.norm(t1)
- t2 = np.cross(normal, t1)
- t2 /= np.linalg.norm(t2)
+ t1 /= np.linalg.norm( t1 )
+ t2 = np.cross( normal, t1 )
+ t2 /= np.linalg.norm( t2 )
- tangents1[i] = t1
- tangents2[i] = t2
+ tangents1[ i ] = t1
+ tangents2[ i ] = t2
- surf.cell_data['Normals'] = normals
- surf.cell_data['tangent1'] = tangents1
- surf.cell_data['tangent2'] = tangents2
+ surf.cell_data[ 'Normals' ] = normals
+ surf.cell_data[ 'tangent1' ] = tangents1
+ surf.cell_data[ 'tangent2' ] = tangents2
- dip_angles, strike_angles = self.computeDipStrikeFromCellBase(normals, tangents1, tangents2)
+ dip_angles, strike_angles = self.computeDipStrikeFromCellBase( normals, tangents1, tangents2 )
- surf.cell_data['dipAngle'] = dip_angles
- surf.cell_data['strikeAngle'] = strike_angles
+ surf.cell_data[ 'dipAngle' ] = dip_angles
+ surf.cell_data[ 'strikeAngle' ] = strike_angles
return surf
# -------------------------------------------------------------------
- def computeDipStrikeFromCellBase(self, normals, tangent1, tangent2):
- """
- Calcule les angles dip et strike à partir des vecteurs normaux et tangents des cellules.
+ def computeDipStrikeFromCellBase(
+ self: Self, normals: npt.NDArray[ np.float64 ], tangent1: npt.NDArray[ np.float64 ],
+ tangent2: npt.NDArray[ np.float64 ]
+ ) -> tuple[ npt.NDArray[ np.float64 ], npt.NDArray[ np.float64 ] ]: # TODO translate docstring
+ """Calcule les angles dip et strike à partir des vecteurs normaux et tangents des cellules.
+
Hypothèses :
- Système de coordonnées : X=Est, Y=Nord, Z=Haut.
- Vecteurs donnés par cellule (shape: (nCells, 3)).
@@ -675,244 +682,158 @@ def computeDipStrikeFromCellBase(self, normals, tangent1, tangent2):
dipDeg, strikeDeg (two arrays of shape (nCells,))
"""
# 1. Identifier le vecteur strike (le plus horizontal)
- t1Horizontal = tangent1 - (tangent1[:, 2][:, np.newaxis] * np.array([0, 0, 1]))
- t2Horizontal = tangent2 - (tangent2[:, 2][:, np.newaxis] * np.array([0, 0, 1]))
- normT1Horizontal = np.linalg.norm(t1Horizontal, axis=1)
- normT2Horizontal = np.linalg.norm(t2Horizontal, axis=1)
+ t1Horizontal = tangent1 - ( tangent1[ :, 2 ][ :, np.newaxis ] * np.array( [ 0, 0, 1 ] ) )
+ t2Horizontal = tangent2 - ( tangent2[ :, 2 ][ :, np.newaxis ] * np.array( [ 0, 0, 1 ] ) )
+ normT1Horizontal = np.linalg.norm( t1Horizontal, axis=1 )
+ normT2Horizontal = np.linalg.norm( t2Horizontal, axis=1 )
useT1 = normT1Horizontal > normT2Horizontal
- strikeVector = np.zeros_like(tangent1)
- strikeVector[useT1] = t1Horizontal[useT1]
- strikeVector[~useT1] = t2Horizontal[~useT1]
+ strikeVector = np.zeros_like( tangent1 )
+ strikeVector[ useT1 ] = t1Horizontal[ useT1 ]
+ strikeVector[ ~useT1 ] = t2Horizontal[ ~useT1 ]
# Normaliser
- strikeNorm = np.linalg.norm(strikeVector, axis=1)
+ strikeNorm = np.linalg.norm( strikeVector, axis=1 )
# Éviter la division par zéro (si la faille est parfaitement verticale, le strike est bien défini par l'autre vecteur)
- strikeNorm[strikeNorm == 0] = 1.0
- strikeVector = strikeVector / strikeNorm[:, np.newaxis]
+ strikeNorm[ strikeNorm == 0 ] = 1.0
+ strikeVector = strikeVector / strikeNorm[ :, np.newaxis ]
# 2. Calculer le strike (azimut depuis le Nord, sens horaire)
- strikeRad = np.arctan2(strikeVector[:, 0], strikeVector[:, 1]) # atan2(E, N)
- strikeDeg = np.degrees(strikeRad)
- strikeDeg = np.where(strikeDeg < 0, strikeDeg + 360, strikeDeg)
+ strikeRad = np.arctan2( strikeVector[ :, 0 ], strikeVector[ :, 1 ] ) # atan2(E, N)
+ strikeDeg = np.degrees( strikeRad )
+ strikeDeg = np.where( strikeDeg < 0, strikeDeg + 360, strikeDeg )
# 3. Calculer le dip
- normHorizontal = np.linalg.norm(normals[:, :2], axis=1)
- dipRad = np.arcsin(np.clip(normHorizontal, 0, 1)) # clip pour éviter les erreurs d'arrondi
- dipDeg = np.degrees(dipRad)
+ normHorizontal = np.linalg.norm( normals[ :, :2 ], axis=1 )
+ dipRad = np.arcsin( np.clip( normHorizontal, 0, 1 ) ) # clip pour éviter les erreurs d'arrondi
+ dipDeg = np.degrees( dipRad )
return dipDeg, strikeDeg
# -------------------------------------------------------------------
- def plotGeometry(self, surface, scaleFactor, zScale):
- """Visualize fault geometry with normals"""
+ def plotGeometry( self: Self, surface: pv.DataSet, scaleFactor: float, zScale: float ) -> None:
+ """Visualize fault geometry with normals."""
plotter = pv.Plotter()
- plotter.add_mesh(self.mesh, color='lightgray', opacity=0.1, label='Volume')
- plotter.add_mesh(surface, color='darkgray', opacity=0.7, show_edges=True, label='Fault')
+ plotter.add_mesh( self.mesh, color='lightgray', opacity=0.1, label='Volume' )
+ plotter.add_mesh( surface, color='darkgray', opacity=0.7, show_edges=True, label='Fault' )
centers = surface.cell_centers()
- for name, color in [('Normals', 'red'), ('tangent1', 'green'), ('tangent2', 'blue')]:
- arrows = centers.glyph(orient=name, scale=zScale, factor=scaleFactor)
- plotter.add_mesh(arrows, color=color, label=name)
+ for name, color in [ ( 'Normals', 'red' ), ( 'tangent1', 'green' ), ( 'tangent2', 'blue' ) ]:
+ arrows = centers.glyph( orient=name, scale=zScale, factor=scaleFactor )
+ plotter.add_mesh( arrows, color=color, label=name )
plotter.add_legend()
plotter.add_axes()
- plotter.set_scale(zscale=zScale)
+ plotter.set_scale( zscale=zScale )
plotter.show()
# -------------------------------------------------------------------
- def diagnoseNormals(self, scaleFactor=50.0, zScale=1.0):
- """
- Diagnostic visualization to check normal quality
- Shows orthogonality and orientation issues
+ def diagnoseNormals( self: Self, scaleFactor: float = 50.0, zScale: float = 1.0 ) -> pv.DataSet:
+ """Diagnostic visualization to check normal quality.
+
+ Shows orthogonality and orientation issues.
"""
surface = self.faultSurface
- print("\n🔍 DIAGNOSTIC DES NORMALES")
- print("=" * 60)
+ print( "\n🔍 DIAGNOSTIC DES NORMALES" )
+ print( "=" * 60 )
- normals = surface.cell_data['Normals']
- tangent1 = surface.cell_data['tangent1']
- tangent2 = surface.cell_data['tangent2']
+ normals = surface.cell_data[ 'Normals' ]
+ tangent1 = surface.cell_data[ 'tangent1' ]
+ tangent2 = surface.cell_data[ 'tangent2' ]
- nCells = len(normals)
+ nCells = len( normals )
# Check orthogonality
- dotNormT1 = np.array([np.dot(normals[i], tangent1[i]) for i in range(nCells)])
- dotNormT2 = np.array([np.dot(normals[i], tangent2[i]) for i in range(nCells)])
- dotT1T2 = np.array([np.dot(tangent1[i], tangent2[i]) for i in range(nCells)])
+ dotNormT1 = np.array( [ np.dot( normals[ i ], tangent1[ i ] ) for i in range( nCells ) ] )
+ dotNormT2 = np.array( [ np.dot( normals[ i ], tangent2[ i ] ) for i in range( nCells ) ] )
+ dotT1T2 = np.array( [ np.dot( tangent1[ i ], tangent2[ i ] ) for i in range( nCells ) ] )
- print(f"Orthogonalité (doit être proche de 0):")
- print(f" Normal · Tangent1 : max={np.max(np.abs(dotNormT1)):.2e}, mean={np.mean(np.abs(dotNormT1)):.2e}")
- print(f" Normal · Tangent2 : max={np.max(np.abs(dotNormT2)):.2e}, mean={np.mean(np.abs(dotNormT2)):.2e}")
- print(f" Tangent1 · Tangent2: max={np.max(np.abs(dotT1T2)):.2e}, mean={np.mean(np.abs(dotT1T2)):.2e}")
+ print( "Orthogonalité (doit être proche de 0):" )
+ print( f" Normal · Tangent1 : max={np.max(np.abs(dotNormT1)):.2e}, mean={np.mean(np.abs(dotNormT1)):.2e}" )
+ print( f" Normal · Tangent2 : max={np.max(np.abs(dotNormT2)):.2e}, mean={np.mean(np.abs(dotNormT2)):.2e}" )
+ print( f" Tangent1 · Tangent2: max={np.max(np.abs(dotT1T2)):.2e}, mean={np.mean(np.abs(dotT1T2)):.2e}" )
# Check unit vectors
- normN = np.linalg.norm(normals, axis=1)
- normT1 = np.linalg.norm(tangent1, axis=1)
- normT2 = np.linalg.norm(tangent2, axis=1)
-
- print(f"\nNormes (doit être proche de 1):")
- print(f" Normals : min={np.min(normN):.6f}, max={np.max(normN):.6f}")
- print(f" Tangent1 : min={np.min(normT1):.6f}, max={np.max(normT1):.6f}")
- print(f" Tangent2 : min={np.min(normT2):.6f}, max={np.max(normT2):.6f}")
-
- # Check orientation consistency
- meanNormal = np.mean(normals, axis=0)
- meanNormal = meanNormal / np.linalg.norm(meanNormal)
-
- dotsWithMean = np.array([np.dot(normals[i], meanNormal) for i in range(nCells)])
- nReversed = np.sum(dotsWithMean < 0)
-
- print(f"\nCohérence d'orientation:")
- print(f" Normale moyenne: [{meanNormal[0]:.3f}, {meanNormal[1]:.3f}, {meanNormal[2]:.3f}]")
- print(f" Normales inversées: {nReversed}/{nCells} ({nReversed/nCells*100:.1f}%)")
-
- if nReversed > nCells * 0.1:
- print(f" ⚠️ Plus de 10% des normales pointent dans la direction opposée!")
- else:
- print(f" ✅ Orientation cohérente")
-
- print("=" * 60)
-
- # Visualization
- plotter = pv.Plotter(shape=(1, 2))
-
- # Plot 1: Surface with normals
- plotter.subplot(0, 0)
- plotter.add_mesh(surface, color='lightgray', show_edges=True, opacity=0.8)
-
- centers = surface.cell_centers()
- arrowsNorm = centers.glyph(orient='Normals', scale=False, factor=scaleFactor)
- plotter.add_mesh(arrowsNorm, color='red', label='Normals')
-
- plotter.add_legend()
- plotter.add_axes()
- plotter.add_text("Normales (Rouge)", position='upper_edge')
- plotter.set_scale(zscale=zScale)
-
- # Plot 2: All vectors
- plotter.subplot(0, 1)
- plotter.add_mesh(surface, color='lightgray', show_edges=True, opacity=0.5)
-
- arrowsNorm = centers.glyph(orient='Normals', scale=False, factor=scaleFactor)
- arrowsT1 = centers.glyph(orient='tangent1', scale=False, factor=scaleFactor)
- arrowsT2 = centers.glyph(orient='tangent2', scale=False, factor=scaleFactor)
-
- plotter.add_mesh(arrowsNorm, color='red', label='Normal')
- plotter.add_mesh(arrowsT1, color='green', label='Tangent1')
- plotter.add_mesh(arrowsT2, color='blue', label='Tangent2')
-
- plotter.add_legend()
- plotter.add_axes()
- plotter.add_text("Système complet (R,G,B)", position='upper_edge')
- plotter.set_scale(zscale=zScale)
-
- plotter.link_views()
- plotter.show()
-
- return surface
-
-
- """
- Diagnostic visualization to check normal quality
- Shows orthogonality and orientation issues
- """
- print("\n🔍 DIAGNOSTIC DES NORMALES")
- print("=" * 60)
-
- normals = surface.cell_data['Normals']
- tangent1 = surface.cell_data['tangent1']
- tangent2 = surface.cell_data['tangent2']
-
- nCells = len(normals)
-
- # Check orthogonality
- dotNormT1 = np.array([np.dot(normals[i], tangent1[i]) for i in range(nCells)])
- dotNormT2 = np.array([np.dot(normals[i], tangent2[i]) for i in range(nCells)])
- dotT1T2 = np.array([np.dot(tangent1[i], tangent2[i]) for i in range(nCells)])
-
- print(f"Orthogonalité (doit être proche de 0):")
- print(f" Normal · Tangent1 : max={np.max(np.abs(dotNormT1)):.2e}, mean={np.mean(np.abs(dotNormT1)):.2e}")
- print(f" Normal · Tangent2 : max={np.max(np.abs(dotNormT2)):.2e}, mean={np.mean(np.abs(dotNormT2)):.2e}")
- print(f" Tangent1 · Tangent2: max={np.max(np.abs(dotT1T2)):.2e}, mean={np.mean(np.abs(dotT1T2)):.2e}")
+ normN = np.linalg.norm( normals, axis=1 )
+ normT1 = np.linalg.norm( tangent1, axis=1 )
+ normT2 = np.linalg.norm( tangent2, axis=1 )
# Check unit vectors
- normN = np.array([np.linalg.norm(normals[i]) for i in range(nCells)])
- normT1 = np.array([np.linalg.norm(tangent1[i]) for i in range(nCells)])
- normT2 = np.array([np.linalg.norm(tangent2[i]) for i in range(nCells)])
+ # normN = np.array( [ np.linalg.norm( normals[ i ] ) for i in range( nCells ) ] ) # TODO
+ # normT1 = np.array( [ np.linalg.norm( tangent1[ i ] ) for i in range( nCells ) ] )
+ # normT2 = np.array( [ np.linalg.norm( tangent2[ i ] ) for i in range( nCells ) ] )
- print(f"\nNormes (doit être proche de 1):")
- print(f" Normals : min={np.min(normN):.6f}, max={np.max(normN):.6f}")
- print(f" Tangent1 : min={np.min(normT1):.6f}, max={np.max(normT1):.6f}")
- print(f" Tangent2 : min={np.min(normT2):.6f}, max={np.max(normT2):.6f}")
+ print( "\nNormes (doit être proche de 1):" )
+ print( f" Normals : min={np.min(normN):.6f}, max={np.max(normN):.6f}" )
+ print( f" Tangent1 : min={np.min(normT1):.6f}, max={np.max(normT1):.6f}" )
+ print( f" Tangent2 : min={np.min(normT2):.6f}, max={np.max(normT2):.6f}" )
# Check orientation consistency
- meanNormal = np.mean(normals, axis=0)
- meanNormal = meanNormal / np.linalg.norm(meanNormal)
+ meanNormal = np.mean( normals, axis=0 )
+ meanNormal = meanNormal / np.linalg.norm( meanNormal )
- dotsWithMean = np.array([np.dot(normals[i], meanNormal) for i in range(nCells)])
- nReversed = np.sum(dotsWithMean < 0)
+ dotsWithMean = np.array( [ np.dot( normals[ i ], meanNormal ) for i in range( nCells ) ] )
+ nReversed = np.sum( dotsWithMean < 0 )
- print(f"\nCohérence d'orientation:")
- print(f" Normale moyenne: [{meanNormal[0]:.3f}, {meanNormal[1]:.3f}, {meanNormal[2]:.3f}]")
- print(f" Normales inversées: {nReversed}/{nCells} ({nReversed/nCells*100:.1f}%)")
+ print( "\nCohérence d'orientation:" )
+ print( f" Normale moyenne: [{meanNormal[0]:.3f}, {meanNormal[1]:.3f}, {meanNormal[2]:.3f}]" )
+ print( f" Normales inversées: {nReversed}/{nCells} ({nReversed/nCells*100:.1f}%)" )
# Visual check
if nReversed > nCells * 0.1:
- print(f" ⚠️ Plus de 10% des normales pointent dans la direction opposée!")
+ print( " ⚠️ Plus de 10% des normales pointent dans la direction opposée!" )
else:
- print(f" ✅ Orientation cohérente")
+ print( " ✅ Orientation cohérente" )
# Check for problematic cells
- badOrtho = (np.abs(dotNormT1) > 1e-3) | (np.abs(dotNormT2) > 1e-3) | (np.abs(dotT1T2) > 1e-3)
- nBad = np.sum(badOrtho)
+ badOrtho = ( np.abs( dotNormT1 ) > 1e-3 ) | ( np.abs( dotNormT2 ) > 1e-3 ) | ( np.abs( dotT1T2 ) > 1e-3 )
+ nBad = np.sum( badOrtho )
if nBad > 0:
- print(f"\n⚠️ {nBad} cellules avec orthogonalité douteuse (|dot| > 1e-3)")
- surface.cell_data['orthogonality_error'] = np.maximum.reduce([
- np.abs(dotNormT1), np.abs(dotNormT2), np.abs(dotT1T2)
- ])
+ print( f"\n⚠️ {nBad} cellules avec orthogonalité douteuse (|dot| > 1e-3)" )
+ surface.cell_data[ 'orthogonality_error' ] = np.maximum.reduce(
+ [ np.abs( dotNormT1 ), np.abs( dotNormT2 ),
+ np.abs( dotT1T2 ) ] )
else:
- print(f"\n✅ Toutes les cellules ont une bonne orthogonalité")
+ print( "\n✅ Toutes les cellules ont une bonne orthogonalité" )
- print("=" * 60)
+ print( "=" * 60 )
# Visualization
- plotter = pv.Plotter(shape=(1, 2))
+ plotter = pv.Plotter( shape=( 1, 2 ) )
# Plot 1: Surface with normals
- plotter.subplot(0, 0)
- plotter.add_mesh(surface, color='lightgray', show_edges=True, opacity=0.8)
+ plotter.subplot( 0, 0 )
+ plotter.add_mesh( surface, color='lightgray', show_edges=True, opacity=0.8 )
centers = surface.cell_centers()
- arrowsNorm = centers.glyph(orient='Normals', scale=False, factor=scaleFactor)
- plotter.add_mesh(arrowsNorm, color='red', label='Normals')
+ arrowsNorm = centers.glyph( orient='Normals', scale=False, factor=scaleFactor )
+ plotter.add_mesh( arrowsNorm, color='red', label='Normals' )
plotter.add_legend()
plotter.add_axes()
- plotter.add_text("Normales (Rouge)", position='upper_edge')
- plotter.set_scale(zscale=zScale)
+ plotter.add_text( "Normales (Rouge)", position='upper_edge' )
+ plotter.set_scale( zscale=zScale )
- # Plot 2: All vectors (normal + tangents)
- plotter.subplot(0, 1)
- plotter.add_mesh(surface, color='lightgray', show_edges=True, opacity=0.5)
+ # Plot 2: All vectors
+ plotter.subplot( 0, 1 )
+ plotter.add_mesh( surface, color='lightgray', show_edges=True, opacity=0.5 )
- arrowsNorm = centers.glyph(orient='Normals', scale=False, factor=scaleFactor)
- arrowsT1 = centers.glyph(orient='tangent1', scale=False, factor=scaleFactor)
- arrowsT2 = centers.glyph(orient='tangent2', scale=False, factor=scaleFactor)
+ arrowsNorm = centers.glyph( orient='Normals', scale=False, factor=scaleFactor )
+ arrowsT1 = centers.glyph( orient='tangent1', scale=False, factor=scaleFactor )
+ arrowsT2 = centers.glyph( orient='tangent2', scale=False, factor=scaleFactor )
- plotter.add_mesh(arrowsNorm, color='red', label='Normal')
- plotter.add_mesh(arrowsT1, color='green', label='Tangent1')
- plotter.add_mesh(arrowsT2, color='blue', label='Tangent2')
+ plotter.add_mesh( arrowsNorm, color='red', label='Normal' )
+ plotter.add_mesh( arrowsT1, color='green', label='Tangent1' )
+ plotter.add_mesh( arrowsT2, color='blue', label='Tangent2' )
plotter.add_legend()
plotter.add_axes()
- plotter.add_text("Système complet (R,G,B)", position='upper_edge')
- plotter.set_scale(zscale=zScale)
+ plotter.add_text( "Système complet (R,G,B)", position='upper_edge' )
+ plotter.set_scale( zscale=zScale )
plotter.link_views()
plotter.show()
return surface
-
diff --git a/geos-processing/src/geos/processing/post_processing/FaultStabilityAnalysis.py b/geos-processing/src/geos/processing/post_processing/FaultStabilityAnalysis.py
index 1fcbc5f0..db1042c0 100755
--- a/geos-processing/src/geos/processing/post_processing/FaultStabilityAnalysis.py
+++ b/geos-processing/src/geos/processing/post_processing/FaultStabilityAnalysis.py
@@ -4,57 +4,57 @@
from pathlib import Path
import numpy as np
import pyvista as pv
-import matplotlib.pyplot as plt
import pyfiglet
-from scipy.spatial import cKDTree
-from scipy.interpolate import splprep, splev, LinearNDInterpolator, Rbf
-import os
-import math
+from typing_extensions import Self
+from geos.processing.post_processing.FaultGeometry import FaultGeometry
+from geos.processing.post_processing.Visualizer import Visualizer
+from geos.processing.post_processing.SensitivityAnalyzer import SensitivityAnalyzer
+from geos.processing.post_processing.StressProjector import StressProjector
# ============================================================================
# CONFIGURATION
# ============================================================================
+
class Config:
- """Configuration parameters for fault analysis"""
+ """Configuration parameters for fault analysis."""
# Mechanical parameters
- FRICTION_ANGLE = 12 # [degrees]
- COHESION = 0 # [bar]
+ FRICTION_ANGLE: float = 12 # [degrees]
+ COHESION: float = 0 # [bar]
# Normal orientation
- ROTATE_NORMALS = False # Rotate normals and tangents from 180°
+ ROTATE_NORMALS: bool = False # Rotate normals and tangents from 180°
# Sensitivity analysis
- RUN_SENSITIVITY = True # Enable sensitivity analysis
- SENSITIVITY_FRICTION_ANGLES = [12,15,18,20,22,25] # degrees
- SENSITIVITY_COHESIONS = [0,1,2,5,10] # bar
+ RUN_SENSITIVITY: bool = True # Enable sensitivity analysis
+ SENSITIVITY_FRICTION_ANGLES: list[ float ] = [ 12, 15, 18, 20, 22, 25 ] # degrees
+ SENSITIVITY_COHESIONS: list[ float ] = [ 0, 1, 2, 5, 10 ] # bar
# Visualization
Z_SCALE = 1.0
- SHOW_NORMAL_PLOTS = True # Show the mesh grid and normals at fault planes
+ SHOW_NORMAL_PLOTS = True # Show the mesh grid and normals at fault planes
SHOW_CONTRIBUTION_VIZ = True # Show volume contribution visualization (first timestep only)
- SHOW_DEPTH_PROFILES = True # Active les profils verticaux
- N_DEPTH_PROFILES = 1 # Nombre de lignes verticales
+ SHOW_DEPTH_PROFILES = True # Active les profils verticaux
+ N_DEPTH_PROFILES = 1 # Nombre de lignes verticales
MIN_DEPTH_PROFILES = None
MAX_DEPTH_PROFILES = None
- SHOW_PLOTS = True # Set to False to skip interactive plots
- SAVE_PLOTS = True # Set to False to skip saving plots
- SAVE_CONTRIBUTION_CELLS = True # Save vtu contributive cells
+ SHOW_PLOTS = True # Set to False to skip interactive plots
+ SAVE_PLOTS = True # Set to False to skip saving plots
+ SAVE_CONTRIBUTION_CELLS = True # Save vtu contributive cells
WEIGHTING_SCHEME = "arithmetic"
COMPUTE_PRINCIPAL_STRESS = False
SHOW_PROFILE_EXTRACTOR = True
- PROFILE_START_POINTS = [
- (2282.61, 1040, 0)] # Profile Fault 1
+ PROFILE_START_POINTS = [ ( 2282.61, 1040, 0 ) ] # Profile Fault 1
PROFILE_SEARCH_RADIUS = None
# Time series - List of time indices to process (None = all)
- TIME_INDEX = [0,-1]
+ TIME_INDEX = [ 0, -1 ]
# File paths
PATH = ""
@@ -67,7 +67,7 @@ class Config:
# Faults attributes
FAULT_ATTRIBUTE = "Fault"
- FAULT_VALUES = [1]
+ FAULT_VALUES = [ 1 ]
# Output
OUTPUT_DIR = "Processed_Fault_Analysis"
@@ -78,27 +78,26 @@ class Config:
# MOHR COULOMB
# ============================================================================
class MohrCoulomb:
- """Mohr-Coulomb failure criterion analysis"""
+ """Mohr-Coulomb failure criterion analysis."""
@staticmethod
- def analyze(surface, cohesion, frictionAngleDeg, time=0, verbose=True):
- """
- Perform Mohr-Coulomb stability analysis
+ # def analyze( surface: pv.DataSet, cohesion: float, frictionAngleDeg: float, time=0, verbose=True ):
+ def analyze( surface: pv.DataSet, cohesion: float, frictionAngleDeg: float, verbose: bool = True ) -> pv.DataSet:
+ """Perform Mohr-Coulomb stability analysis.
Parameters:
surface: fault surface with stress data
cohesion: cohesion in bar
frictionAngleDeg: friction angle in degrees
- time: simulation time
verbose: print statistics
"""
- mu = np.tan(np.radians(frictionAngleDeg))
+ mu = np.tan( np.radians( frictionAngleDeg ) )
# Extract stress components
- sigmaN = surface.cell_data["sigmaNEffective"]
- tau = surface.cell_data["tauEffective"]
- deltaSigmaN = surface.cell_data['deltaSigmaNEffective']
- deltaTau = surface.cell_data['deltaTauEffective']
+ sigmaN = surface.cell_data[ "sigmaNEffective" ]
+ tau = surface.cell_data[ "tauEffective" ]
+ surface.cell_data[ 'deltaSigmaNEffective' ]
+ surface.cell_data[ 'deltaTauEffective' ]
# Mohr-Coulomb failure envelope
tauCritical = cohesion - sigmaN * mu
@@ -108,71 +107,71 @@ def analyze(surface, cohesion, frictionAngleDeg, time=0, verbose=True):
# deltaCFS = deltaTau - mu * deltaSigmaN
# Shear Capacity Utilization: SCU = τ / τ_crit
- SCU = np.divide(tau, tauCritical, out=np.zeros_like(tau), where=tauCritical != 0)
+ SCU = np.divide( tau, tauCritical, out=np.zeros_like( tau ), where=tauCritical != 0 )
if "SCUInitial" not in surface.cell_data:
# First timestep: store as initial reference
SCUInitial = SCU.copy()
CFSInitial = CFS.copy()
- deltaSCU = np.zeros_like(SCU)
- deltaCFS = np.zeros_like(CFS)
+ deltaSCU = np.zeros_like( SCU )
+ deltaCFS = np.zeros_like( CFS )
- surface.cell_data["SCUInitial"] = SCUInitial
- surface.cell_data["CFSInitial"] = CFSInitial
+ surface.cell_data[ "SCUInitial" ] = SCUInitial
+ surface.cell_data[ "CFSInitial" ] = CFSInitial
isInitial = True
else:
# Subsequent timesteps: calculate change from initial
- SCUInitial = surface.cell_data["SCUInitial"]
- CFSInitial = surface.cell_data['CFSInitial']
+ SCUInitial = surface.cell_data[ "SCUInitial" ]
+ CFSInitial = surface.cell_data[ 'CFSInitial' ]
deltaSCU = SCU - SCUInitial
deltaCFS = CFS - CFSInitial
isInitial = False
# Stability classification
- stability = np.zeros_like(tau, dtype=int)
- stability[SCU >= 0.8] = 1 # Critical
- stability[SCU >= 1.0] = 2 # Unstable
+ stability = np.zeros_like( tau, dtype=int )
+ stability[ SCU >= 0.8 ] = 1 # Critical
+ stability[ SCU >= 1.0 ] = 2 # Unstable
# Failure probability (sigmoid)
k = 10.0
- failureProba = 1.0 / (1.0 + np.exp(-k * (SCU - 1.0)))
+ failureProba = 1.0 / ( 1.0 + np.exp( -k * ( SCU - 1.0 ) ) )
# Safety margin
safety = tauCritical - tau
# Store results
- surface.cell_data.update({
- "mohrCohesion": np.full(surface.n_cells, cohesion),
- "mohrFrictionAngle": np.full(surface.n_cells, frictionAngleDeg),
- "mohrFrictionCoefficient": np.full(surface.n_cells, mu),
+ surface.cell_data.update( {
+ "mohrCohesion": np.full( surface.n_cells, cohesion ),
+ "mohrFrictionAngle": np.full( surface.n_cells, frictionAngleDeg ),
+ "mohrFrictionCoefficient": np.full( surface.n_cells, mu ),
"mohr_critical_shear_stress": tauCritical,
"SCU": SCU,
"deltaSCU": deltaSCU,
- "CFS" : CFS,
+ "CFS": CFS,
"deltaCFS": deltaCFS,
"safetyMargin": safety,
"stabilityState": stability,
"failureProbability": failureProba
- })
+ } )
if verbose:
- nStable = np.sum(stability == 0)
- nCritical = np.sum(stability == 1)
- nUnstable = np.sum(stability == 2)
+ nStable = np.sum( stability == 0 )
+ nCritical = np.sum( stability == 1 )
+ nUnstable = np.sum( stability == 2 )
# Additional info on deltaSCU
if not isInitial:
- meanDelta = np.mean(np.abs(deltaSCU))
- maxIncrease = np.max(deltaSCU)
- maxDecrease = np.min(deltaSCU)
- print(f" ✅ Mohr-Coulomb: {nUnstable} unstable, {nCritical} critical, "
- f"{nStable} stable cells")
- print(f" ΔSCU: mean={meanDelta:.3f}, maxIncrease={maxIncrease:.3f}, "
- f"maxDecrease={maxDecrease:.3f}")
+ meanDelta = np.mean( np.abs( deltaSCU ) )
+ maxIncrease = np.max( deltaSCU )
+ maxDecrease = np.min( deltaSCU )
+ print( f" ✅ Mohr-Coulomb: {nUnstable} unstable, {nCritical} critical, "
+ f"{nStable} stable cells" )
+ print( f" ΔSCU: mean={meanDelta:.3f}, maxIncrease={maxIncrease:.3f}, "
+ f"maxDecrease={maxDecrease:.3f}" )
else:
- print(f" ✅ Mohr-Coulomb (initial): {nUnstable} unstable, {nCritical} critical, "
- f"{nStable} stable cells")
+ print( f" ✅ Mohr-Coulomb (initial): {nUnstable} unstable, {nCritical} critical, "
+ f"{nStable} stable cells" )
return surface
@@ -181,33 +180,32 @@ def analyze(surface, cohesion, frictionAngleDeg, time=0, verbose=True):
# TIME SERIES PROCESSING
# ============================================================================
class TimeSeriesProcessor:
- """Process multiple time steps from PVD file"""
+ """Process multiple time steps from PVD file."""
# -------------------------------------------------------------------
- def __init__(self, config):
+ def __init__( self: Self, config: Config ) -> None:
+ """Init."""
self.config = config
- self.outputDir = Path(config.OUTPUT_DIR)
- self.outputDir.mkdir(exist_ok=True)
+ self.outputDir = Path( config.OUTPUT_DIR )
+ self.outputDir.mkdir( exist_ok=True )
# -------------------------------------------------------------------
- def process(self, path, faultGeometry, pvdFile):
- """
- Process all time steps using pre-computed fault geometry
+ def process( self: Self, path: Path, faultGeometry: FaultGeometry, pvdFile: str ) -> pv.DataSet:
+ """Process all time steps using pre-computed fault geometry.
Parameters:
path: base path for input files
faultGeometry: FaultGeometry object with initialized topology
pvdFile: PVD file name
"""
- pvdReader = pv.PVDReader(path / pvdFile)
- timeValues = np.array(pvdReader.timeValues)
+ pvdReader = pv.PVDReader( path / pvdFile )
+ timeValues = np.array( pvdReader.timeValues )
if self.config.TIME_INDEX:
- timeValues = timeValues[self.config.TIME_INDEX]
+ timeValues = timeValues[ self.config.TIME_INDEX ]
outputFiles = []
dataInitial = None
- SCUInitialReference = None
# Get pre-computed data from faultGeometry
surface = faultGeometry.faultSurface
@@ -215,24 +213,23 @@ def process(self, path, faultGeometry, pvdFile):
geometricProperties = faultGeometry.getGeometricProperties()
# Initialize projector with pre-computed topology
- projector = StressProjector(self.config, adjacencyMapping, geometricProperties)
+ projector = StressProjector( self.config, adjacencyMapping, geometricProperties )
+ print( '\n' )
+ print( "=" * 60 )
+ print( "TIME SERIES PROCESSING" )
+ print( "=" * 60 )
- print('\n')
- print("=" * 60)
- print("TIME SERIES PROCESSING")
- print("=" * 60)
-
- for i, time in enumerate(timeValues):
- print(f"\n→ Step {i+1}/{len(timeValues)}: {time/(365.25*24*3600):.2f} years")
+ for i, time in enumerate( timeValues ):
+ print( f"\n→ Step {i+1}/{len(timeValues)}: {time/(365.25*24*3600):.2f} years" )
# Read time step
- idx = self.config.TIME_INDEX[i] if self.config.TIME_INDEX else i
- pvdReader.set_active_time_point(idx)
+ idx = self.config.TIME_INDEX[ i ] if self.config.TIME_INDEX else i
+ pvdReader.set_active_time_point( idx )
dataset = pvdReader.read()
# Merge blocks
- volumeData = self._mergeBlocks(dataset)
+ volumeData = self._mergeBlocks( dataset )
if dataInitial is None:
dataInitial = volumeData
@@ -245,49 +242,51 @@ def process(self, path, faultGeometry, pvdFile):
volumeData,
dataInitial,
surface,
- time=timeValues[i], # Simulation time
- timestep=i, # Timestep index
- weightingScheme=self.config.WEIGHTING_SCHEME
- )
+ time=timeValues[ i ], # Simulation time
+ timestep=i, # Timestep index
+ weightingScheme=self.config.WEIGHTING_SCHEME )
# -----------------------------------
# Mohr-Coulomb analysis
# -----------------------------------
cohesion = self.config.COHESION
frictionAngle = self.config.FRICTION_ANGLE
- surfaceResult = MohrCoulomb.analyze(surfaceResult, cohesion, frictionAngle, time)
+ surfaceResult = MohrCoulomb.analyze( surfaceResult, cohesion, frictionAngle ) #, time )
# -----------------------------------
# Visualize
# -----------------------------------
- self._plotResults(surfaceResult, contributingCells, time, self.outputDir)
+ self._plotResults( surfaceResult, contributingCells, time, self.outputDir )
# -----------------------------------
# Sensitivity analysis
# -----------------------------------
if self.config.RUN_SENSITIVITY:
- analyzer = SensitivityAnalyzer(self.config)
- sensitivityResults = analyzer.runAnalysis(surfaceResult, time)
+ analyzer = SensitivityAnalyzer( self.config )
+ analyzer.runAnalysis( surfaceResult, time )
# Save
filename = f'fault_analysis_{i:04d}.vtu'
- surfaceResult.save(self.outputDir / filename)
- outputFiles.append((time, filename))
- print(f" 💾 Saved: {filename}")
+ surfaceResult.save( self.outputDir / filename )
+ outputFiles.append( ( time, filename ) )
+ print( f" 💾 Saved: {filename}" )
# Create master PVD
- self._createPVD(outputFiles)
+ self._createPVD( outputFiles )
return surfaceResult
# -------------------------------------------------------------------
- def _mergeBlocks(self, dataset):
- """Merge multi-block dataset - descente automatique jusqu'aux données"""
+ def _mergeBlocks( self, dataset: pv.DataSet ) -> pv.DataSet:
+ """Merge multi-block dataset - descente automatique jusqu'aux données."""
# -----------------------------------------------
- def extractLeafBlocks(block, path="", depth=0):
- """
- Descend récursivement dans la structure MultiBlock jusqu'aux feuilles avec données
+ def extractLeafBlocks(
+ block: pv.DataSet,
+ path: str = "",
+ depth: float = 0
+ ) -> list[ tuple[ pv.DataSet, str, tuple[ float, float, float, float, float, float ] ] ]:
+ """Descend récursivement dans la structure MultiBlock jusqu'aux feuilles avec données.
Returns:
list of (block, path, bounds) tuples
@@ -295,151 +294,147 @@ def extractLeafBlocks(block, path="", depth=0):
leaves = []
# Cas 1: C'est un MultiBlock avec des sous-blocs
- if hasattr(block, 'n_blocks') and block.n_blocks > 0:
- for i in range(block.n_blocks):
- subBlock = block.GetBlock(i)
- blockName = block.get_block_name(i) if hasattr(block, 'get_block_name') else f"Block{i}"
+ if hasattr( block, 'n_blocks' ) and block.n_blocks > 0:
+ for i in range( block.n_blocks ):
+ subBlock = block.GetBlock( i )
+ blockName = block.get_block_name( i ) if hasattr( block, 'get_block_name' ) else f"Block{i}"
newPath = f"{path}/{blockName}" if path else blockName
if subBlock is not None:
# Récursion
- leaves.extend(extractLeafBlocks(subBlock, newPath, depth + 1))
+ leaves.extend( extractLeafBlocks( subBlock, newPath, depth + 1 ) )
# Cas 2: C'est un dataset final (feuille)
- elif hasattr(block, 'n_cells') and block.n_cells > 0:
+ elif hasattr( block, 'n_cells' ) and block.n_cells > 0:
bounds = block.bounds
- leaves.append((block, path, bounds))
+ leaves.append( ( block, path, bounds ) )
return leaves
- print(f" 📦 Extracting volume blocks")
+ print( " 📦 Extracting volume blocks" )
# Extraire toutes les feuilles
- allBlocks = extractLeafBlocks(dataset)
+ allBlocks = extractLeafBlocks( dataset )
# Filtrer et afficher
merged = []
blocksWithPressure = 0
blocksWithoutPressure = 0
- for block, path, bounds in allBlocks:
+ for block, _path, _bounds in allBlocks:
hasPressure = 'pressure' in block.cell_data
if hasPressure:
blocksWithPressure += 1
- merged.append(block)
+ merged.append( block )
else:
blocksWithoutPressure += 1
# Combiner
- combined = pv.MultiBlock(merged).combine()
+ combined = pv.MultiBlock( merged ).combine()
return combined
# -------------------------------------------------------------------
- def _plotResults(self, surface, contributingCells, time, path):
-
- Visualizer.plotMohrCoulombDiagram( surface, time, path,
- show=self.config.SHOW_PLOTS,
- save=self.config.SAVE_PLOTS )
+ def _plotResults( self, surface: pv.DataSet, contributingCells: pv.DataSet, time: list[ int ],
+ path: str ) -> None: # TODO check type surface
+ Visualizer.plotMohrCoulombDiagram( surface,
+ time,
+ path,
+ show=self.config.SHOW_PLOTS,
+ save=self.config.SAVE_PLOTS )
# Profils verticaux automatiques
if self.config.SHOW_DEPTH_PROFILES:
- Visualizer.plotDepthProfiles(
- self,
- surface, time, path,
- show=self.config.SHOW_PLOTS,
- save=self.config.SAVE_PLOTS,
- profileStartPoints=self.config.PROFILE_START_POINTS )
+ Visualizer.plotDepthProfiles( self,
+ surface,
+ time,
+ path,
+ show=self.config.SHOW_PLOTS,
+ save=self.config.SAVE_PLOTS,
+ profileStartPoints=self.config.PROFILE_START_POINTS )
- visualizer = Visualizer(self.config)
+ visualizer = Visualizer( self.config )
if self.config.COMPUTE_PRINCIPAL_STRESS:
# Plot principal stress from volume cells
- visualizer.plotVolumeStressProfiles(
- volumeMesh=contributingCells,
- faultSurface=surface,
- time=time,
- path=path,
- profileStartPoints=self.config.PROFILE_START_POINTS )
+ visualizer.plotVolumeStressProfiles( volumeMesh=contributingCells,
+ faultSurface=surface,
+ time=time,
+ path=path,
+ profileStartPoints=self.config.PROFILE_START_POINTS )
# Visualize comparison analytical/numerical
- visualizer.plotAnalyticalVsNumericalComparison(
- volumeMesh=contributingCells,
- faultSurface=surface,
- time=time,
- path=path,
- show=self.config.SHOW_PLOTS,
- save=self.config.SAVE_PLOTS,
- profileStartPoints=self.config.PROFILE_START_POINTS)
+ visualizer.plotAnalyticalVsNumericalComparison( volumeMesh=contributingCells,
+ faultSurface=surface,
+ time=time,
+ path=path,
+ show=self.config.SHOW_PLOTS,
+ save=self.config.SAVE_PLOTS,
+ profileStartPoints=self.config.PROFILE_START_POINTS )
# -------------------------------------------------------------------
- def _createPVD(self, outputFiles):
- """Create PVD collection file"""
+ def _createPVD( self, outputFiles: list[ tuple[ int, str ] ] ) -> None:
+ """Create PVD collection file."""
pvdPath = self.outputDir / 'fault_analysis.pvd'
- with open(pvdPath, 'w') as f:
- f.write('\n')
- f.write(' \n')
+ with open( pvdPath, 'w' ) as f:
+ f.write( '\n' )
+ f.write( ' \n' )
for t, fname in outputFiles:
- f.write(f' \n')
- f.write(' \n')
- f.write('\n')
- print(f"\n✅ PVD created: {pvdPath}")
+ f.write( f' \n' )
+ f.write( ' \n' )
+ f.write( '\n' )
+ print( f"\n✅ PVD created: {pvdPath}" )
# ============================================================================
# MAIN
# ============================================================================
-def main():
-
- """Main execution function"""
+def main() -> None:
+ """Main execution function."""
config = Config()
- print("=" * 62)
- ascii_banner = pyfiglet.figlet_format("Fault Analysis")
- print(ascii_banner)
- print("=" * 62)
+ print( "=" * 62 )
+ ascii_banner = pyfiglet.figlet_format( "Fault Analysis" )
+ print( ascii_banner )
+ print( "=" * 62 )
- path = Path(config.PATH)
+ path = Path( config.PATH )
# Load fault geometry
- mesh = pv.read(path / config.GRID_FILE)
- print(f"✅ Mesh loaded: {config.GRID_FILE} | {mesh.n_cells} cells")
+ mesh = pv.read( path / config.GRID_FILE )
+ print( f"✅ Mesh loaded: {config.GRID_FILE} | {mesh.n_cells} cells" )
# Read first volume dataset
- pvdReader = pv.PVDReader(path / config.PVD_FILE)
- pvdReader.set_active_time_point(0)
+ pvdReader = pv.PVDReader( path / config.PVD_FILE )
+ pvdReader.set_active_time_point( 0 )
dataset = pvdReader.read()
# IMPORTANT : Utiliser le même merge que dans la boucle
- processor = TimeSeriesProcessor(config)
- volumeMesh = processor._mergeBlocks(dataset)
- print(f"✅ Volume mesh extracted: {volumeMesh.n_cells} cells")
-
+ processor = TimeSeriesProcessor( config )
+ volumeMesh = processor._mergeBlocks( dataset )
+ print( f"✅ Volume mesh extracted: {volumeMesh.n_cells} cells" )
# Initialize fault geometry with topology pre-computation
- print("\n📐 Initialize fault geometry")
- faultGeometry = FaultGeometry(
- config = config,
- mesh=mesh,
- faultValues=config.FAULT_VALUES,
- faultAttribute=config.FAULT_ATTRIBUTE,
- volumeMesh=volumeMesh)
-
+ print( "\n📐 Initialize fault geometry" )
+ faultGeometry = FaultGeometry( config=config,
+ mesh=mesh,
+ faultValues=config.FAULT_VALUES,
+ faultAttribute=config.FAULT_ATTRIBUTE,
+ volumeMesh=volumeMesh )
# Compute normals and adjacency topology (done once!)
- print("🔧 Computing normals and adjacency topology")
+ print( "🔧 Computing normals and adjacency topology" )
faultSurface, adjacencyMapping = faultGeometry.initialize( scaleFactor=50.0 )
-
# Process time series
- processor = TimeSeriesProcessor(config)
- processor.process(path, faultGeometry, config.PVD_FILE)
+ processor = TimeSeriesProcessor( config )
+ processor.process( path, faultGeometry, config.PVD_FILE )
- print("\n" + "=" * 60)
- print("✅ ANALYSIS COMPLETE")
- print("=" * 60)
+ print( "\n" + "=" * 60 )
+ print( "✅ ANALYSIS COMPLETE" )
+ print( "=" * 60 )
if __name__ == "__main__":
diff --git a/geos-processing/src/geos/processing/post_processing/ProfileExtractor.py b/geos-processing/src/geos/processing/post_processing/ProfileExtractor.py
index e3692cf5..ebc8c3f8 100644
--- a/geos-processing/src/geos/processing/post_processing/ProfileExtractor.py
+++ b/geos-processing/src/geos/processing/post_processing/ProfileExtractor.py
@@ -2,21 +2,32 @@
# SPDX-FileCopyrightText: Copyright 2023-2026 TotalEnergies.
# SPDX-FileContributor: Nicolas Pillardou, Paloma Martinez
import numpy as np
-from scipy.spatial import cKDTree
import pyvista as pv
+import numpy.typing as npt
+
+
# ============================================================================
# PROFILE EXTRACTOR
# ============================================================================
class ProfileExtractor:
- """Utility class for extracting profiles along fault surfaces"""
+ """Utility class for extracting profiles along fault surfaces."""
# -------------------------------------------------------------------
@staticmethod
- def extractAdaptiveProfile(centers, values, xStart, yStart, zStart=None,
- searchRadius=None, stepSize=20.0, maxSteps=500,
- verbose=True, faultBounds=None, cellData=None):
- """
- Extraction de profil vertical par COUCHES DE PROFONDEUR avec détection automatique de faille.
+ def extractAdaptiveProfile(
+ centers: npt.NDArray[ np.float64 ],
+ values: npt.NDArray[ np.float64 ],
+ xStart: npt.NDArray[ np.float64 ],
+ yStart: npt.NDArray[ np.float64 ],
+ zStart: npt.NDArray[ np.float64 ] | None = None,
+ searchRadius: float | None = None,
+ stepSize: float = 20.0,
+ maxSteps: float = 500,
+ verbose: bool = True,
+ cellData: dict[ str, npt.NDArray ] | None = None
+ ) -> tuple[ npt.NDArray[ np.float64 ], npt.NDArray[ np.float64 ], npt.NDArray[ np.float64 ],
+ npt.NDArray[ np.float64 ] ]:
+ """Extraction de profil vertical par COUCHES DE PROFONDEUR avec détection automatique de faille.
Stratégie:
1. Trouver le point de départ le plus proche
@@ -43,19 +54,19 @@ def extractAdaptiveProfile(centers, values, xStart, yStart, zStart=None,
verbose : bool
Print detailed information
- Returns
+ Returns:
-------
depths, profileValues, pathX, pathY : ndarrays
Extracted profile data
"""
# Convert to np arrays
- centers = np.asarray(centers)
- values = np.asarray(values)
+ centers = np.asarray( centers )
+ values = np.asarray( values )
- if len(centers) == 0:
+ if len( centers ) == 0:
if verbose:
- print(f" ⚠️ No cells provided")
- return np.array([]), np.array([]), np.array([]), np.array([])
+ print( " ⚠️ No cells provided" )
+ return np.array( [] ), np.array( [] ), np.array( [] ), np.array( [] )
# ===================================================================
# ÉTAPE 1: TROUVER LE POINT DE DÉPART
@@ -64,33 +75,32 @@ def extractAdaptiveProfile(centers, values, xStart, yStart, zStart=None,
if zStart is None:
# Chercher en 2D (XY), prendre le plus haut
if verbose:
- print(f" Searching near ({xStart:.1f}, {yStart:.1f})")
+ print( f" Searching near ({xStart:.1f}, {yStart:.1f})" )
- dXY = np.sqrt((centers[:, 0] - xStart)**2 + (centers[:, 1] - yStart)**2)
- closestIndices = np.argsort(dXY)[:20]
+ dXY = np.sqrt( ( centers[ :, 0 ] - xStart )**2 + ( centers[ :, 1 ] - yStart )**2 )
+ closestIndices = np.argsort( dXY )[ :20 ]
- if len(closestIndices) == 0:
- print(f" ⚠️ No cells found near start point")
- return np.array([]), np.array([]), np.array([]), np.array([])
+ if len( closestIndices ) == 0:
+ print( " ⚠️ No cells found near start point" )
+ return np.array( [] ), np.array( [] ), np.array( [] ), np.array( [] )
# Prendre le plus haut (plus grand Z)
- closestDepths = centers[closestIndices, 2]
- startIdx = closestIndices[np.argmax(closestDepths)]
+ closestDepths = centers[ closestIndices, 2 ]
+ startIdx = closestIndices[ np.argmax( closestDepths ) ]
else:
# Chercher en 3D
if verbose:
- print(f" Searching near ({xStart:.1f}, {yStart:.1f}, {zStart:.1f})")
+ print( f" Searching near ({xStart:.1f}, {yStart:.1f}, {zStart:.1f})" )
- d3D = np.sqrt((centers[:, 0] - xStart)**2 +
- (centers[:, 1] - yStart)**2 +
- (centers[:, 2] - zStart)**2)
- startIdx = np.argmin(d3D)
+ d3D = np.sqrt( ( centers[ :, 0 ] - xStart )**2 + ( centers[ :, 1 ] - yStart )**2 +
+ ( centers[ :, 2 ] - zStart )**2 )
+ startIdx = np.argmin( d3D )
- startPoint = centers[startIdx]
+ startPoint = centers[ startIdx ]
if verbose:
- print(f" Starting point: ({startPoint[0]:.1f}, {startPoint[1]:.1f}, {startPoint[2]:.1f})")
- print(f" Starting cell index: {startIdx}")
+ print( f" Starting point: ({startPoint[0]:.1f}, {startPoint[1]:.1f}, {startPoint[2]:.1f})" )
+ print( f" Starting cell index: {startIdx}" )
# ===================================================================
# ÉTAPE 2: DÉTECTER AUTOMATIQUEMENT L'ID DE LA FAILLE
@@ -101,25 +111,25 @@ def extractAdaptiveProfile(centers, values, xStart, yStart, zStart=None,
if cellData is not None:
# Chercher dans l'ordre de priorité
- faultFieldNames = ['attribute', 'FaultMask', 'faultId', 'region']
+ faultFieldNames = [ 'attribute', 'FaultMask', 'faultId', 'region' ]
for fieldName in faultFieldNames:
if fieldName in cellData:
- faultIds = np.asarray(cellData[fieldName])
+ faultIds = np.asarray( cellData[ fieldName ] )
- if len(faultIds) != len(centers):
+ if len( faultIds ) != len( centers ):
if verbose:
- print(f" ⚠️ Field '{fieldName}' length mismatch, skipping")
+ print( f" ⚠️ Field '{fieldName}' length mismatch, skipping" )
continue
# Récupérer l'ID au point de départ
- targetFaultId = faultIds[startIdx]
+ targetFaultId = faultIds[ startIdx ]
if verbose:
- uniqueIds = np.unique(faultIds)
- print(f" Found fault field: '{fieldName}'")
- print(f" Available fault IDs: {uniqueIds}")
- print(f" Target fault ID at start point: {targetFaultId}")
+ uniqueIds = np.unique( faultIds )
+ print( f" Found fault field: '{fieldName}'" )
+ print( f" Available fault IDs: {uniqueIds}" )
+ print( f" Target fault ID at start point: {targetFaultId}" )
break
@@ -129,35 +139,37 @@ def extractAdaptiveProfile(centers, values, xStart, yStart, zStart=None,
if targetFaultId is not None:
# FILTRER: garder SEULEMENT cette faille
- maskSameFault = (faultIds == targetFaultId)
- nTotal = len(centers)
- nOnFault = np.sum(maskSameFault)
+ maskSameFault = ( faultIds == targetFaultId )
+ nTotal = len( centers )
+ nOnFault = np.sum( maskSameFault )
if verbose:
- print(f" Filtering to fault ID={targetFaultId}: {nOnFault}/{nTotal} cells ({nOnFault/nTotal*100:.1f}%)")
+ print(
+ f" Filtering to fault ID={targetFaultId}: {nOnFault}/{nTotal} cells ({nOnFault/nTotal*100:.1f}%)"
+ )
if nOnFault == 0:
- print(f" ⚠️ No cells found on target fault")
- return np.array([]), np.array([]), np.array([]), np.array([])
+ print( " ⚠️ No cells found on target fault" )
+ return np.array( [] ), np.array( [] ), np.array( [] ), np.array( [] )
# REMPLACER centers et values par le subset filtré
- centers = centers[maskSameFault].copy()
- values = values[maskSameFault].copy()
+ centers = centers[ maskSameFault ].copy()
+ values = values[ maskSameFault ].copy()
# Trouver le nouvel index de départ dans le subset
- dToStart = np.sqrt(np.sum((centers - startPoint)**2, axis=1))
- startIdx = np.argmin(dToStart)
+ dToStart = np.sqrt( np.sum( ( centers - startPoint )**2, axis=1 ) )
+ startIdx = np.argmin( dToStart )
if verbose:
- print(f" ✅ Profile will stay on fault ID={targetFaultId}")
+ print( f" ✅ Profile will stay on fault ID={targetFaultId}" )
else:
if verbose:
- print(f" ⚠️ No fault identification field found")
+ print( " ⚠️ No fault identification field found" )
if cellData is not None:
- print(f" Available fields: {list(cellData.keys())}")
+ print( f" Available fields: {list(cellData.keys())}" )
else:
- print(f" cellData not provided")
- print(f" Profile may jump between faults!")
+ print( " cellData not provided" )
+ print( " Profile may jump between faults!" )
# À partir d'ici, centers/values ne contiennent QUE la faille cible
@@ -165,52 +177,50 @@ def extractAdaptiveProfile(centers, values, xStart, yStart, zStart=None,
# ÉTAPE 4: POSITION DE RÉFÉRENCE
# ===================================================================
- refX = centers[startIdx, 0]
- refY = centers[startIdx, 1]
+ refX = centers[ startIdx, 0 ]
+ refY = centers[ startIdx, 1 ]
if verbose:
- print(f" Reference XY: ({refX:.1f}, {refY:.1f})")
+ print( f" Reference XY: ({refX:.1f}, {refY:.1f})" )
# ===================================================================
# ÉTAPE 5: GÉOMÉTRIE DE LA FAILLE
# ===================================================================
- xRange = np.max(centers[:, 0]) - np.min(centers[:, 0])
- yRange = np.max(centers[:, 1]) - np.min(centers[:, 1])
- zRange = np.max(centers[:, 2]) - np.min(centers[:, 2])
+ xRange = np.max( centers[ :, 0 ] ) - np.min( centers[ :, 0 ] )
+ yRange = np.max( centers[ :, 1 ] ) - np.min( centers[ :, 1 ] )
+ zRange = np.max( centers[ :, 2 ] ) - np.min( centers[ :, 2 ] )
if zRange <= 0:
- print(f" ⚠️ Invalid zRange: {zRange}")
- return np.array([]), np.array([]), np.array([]), np.array([])
+ print( f" ⚠️ Invalid zRange: {zRange}" )
+ return np.array( [] ), np.array( [] ), np.array( [] ), np.array( [] )
- lateralExtent = max(xRange, yRange)
- xyTolerance = max(lateralExtent * 0.3, 100.0)
+ lateralExtent = max( xRange, yRange )
+ xyTolerance = max( lateralExtent * 0.3, 100.0 )
if verbose:
- print(f" Fault extent: X={xRange:.1f}m, Y={yRange:.1f}m, Z={zRange:.1f}m")
- print(f" XY tolerance: {xyTolerance:.1f}m")
+ print( f" Fault extent: X={xRange:.1f}m, Y={yRange:.1f}m, Z={zRange:.1f}m" )
+ print( f" XY tolerance: {xyTolerance:.1f}m" )
# ===================================================================
# ÉTAPE 6: CALCUL DES TRANCHES
# ===================================================================
- zCoordsSorted = np.sort(centers[:, 2])
- zDiffs = np.diff(zCoordsSorted)
- zDiffsPositive = zDiffs[zDiffs > 1e-6]
+ zCoordsSorted = np.sort( centers[ :, 2 ] )
+ zDiffs = np.diff( zCoordsSorted )
+ zDiffsPositive = zDiffs[ zDiffs > 1e-6 ]
- if len(zDiffsPositive) == 0:
+ if len( zDiffsPositive ) == 0:
if verbose:
- print(f" ⚠️ All cells at same Z")
+ print( " ⚠️ All cells at same Z" )
- dXY = np.sqrt((centers[:, 0] - refX)**2 + (centers[:, 1] - refY)**2)
- sortedIndices = np.argsort(dXY)
+ dXY = np.sqrt( ( centers[ :, 0 ] - refX )**2 + ( centers[ :, 1 ] - refY )**2 )
+ sortedIndices = np.argsort( dXY )
- return (centers[sortedIndices, 2],
- values[sortedIndices],
- centers[sortedIndices, 0],
- centers[sortedIndices, 1])
+ return ( centers[ sortedIndices, 2 ], values[ sortedIndices ], centers[ sortedIndices,
+ 0 ], centers[ sortedIndices, 1 ] )
- medianZSpacing = np.median(zDiffsPositive)
+ medianZSpacing = np.median( zDiffsPositive )
# Vérifier que medianZSpacing est raisonnable
if medianZSpacing <= 0 or medianZSpacing > zRange:
@@ -219,25 +229,25 @@ def extractAdaptiveProfile(centers, values, xStart, yStart, zStart=None,
# Taille de tranche = espacement médian
sliceThickness = medianZSpacing
- zMin = np.min(centers[:, 2])
- zMax = np.max(centers[:, 2])
+ zMin = np.min( centers[ :, 2 ] )
+ zMax = np.max( centers[ :, 2 ] )
- nSlices = int(np.ceil(zRange / sliceThickness))
- nSlices = min(nSlices, 10000) # Limiter à 10k tranches max
+ nSlices = int( np.ceil( zRange / sliceThickness ) )
+ nSlices = min( nSlices, 10000 ) # Limiter à 10k tranches max
if nSlices <= 0:
- print(f" ⚠️ Invalid nSlices: {nSlices}")
- return np.array([]), np.array([]), np.array([]), np.array([])
+ print( f" ⚠️ Invalid nSlices: {nSlices}" )
+ return np.array( [] ), np.array( [] ), np.array( [] ), np.array( [] )
if verbose:
- print(f" Median Z spacing: {medianZSpacing:.1f}m")
- print(f" Creating {nSlices} slices")
+ print( f" Median Z spacing: {medianZSpacing:.1f}m" )
+ print( f" Creating {nSlices} slices" )
try:
- zSlices = np.linspace(zMax, zMin, nSlices + 1)
- except (MemoryError, ValueError) as e:
- print(f" ⚠️ Error creating slices: {e}")
- return np.array([]), np.array([]), np.array([]), np.array([])
+ zSlices = np.linspace( zMax, zMin, nSlices + 1 )
+ except ( MemoryError, ValueError ) as e:
+ print( f" ⚠️ Error creating slices: {e}" )
+ return np.array( [] ), np.array( [] ), np.array( [] ), np.array( [] )
# ===================================================================
# ÉTAPE 7: EXTRACTION PAR TRANCHES
@@ -245,36 +255,34 @@ def extractAdaptiveProfile(centers, values, xStart, yStart, zStart=None,
profileIndices = []
- for i in range(len(zSlices) - 1):
- zTop = zSlices[i]
- zBottom = zSlices[i + 1]
+ for i in range( len( zSlices ) - 1 ):
+ zTop = zSlices[ i ]
+ zBottom = zSlices[ i + 1 ]
# Cellules dans cette tranche
- maskInSlice = (centers[:, 2] <= zTop) & (centers[:, 2] >= zBottom)
- indicesInSlice = np.where(maskInSlice)[0]
+ maskInSlice = ( centers[ :, 2 ] <= zTop ) & ( centers[ :, 2 ] >= zBottom )
+ indicesInSlice = np.where( maskInSlice )[ 0 ]
- if len(indicesInSlice) == 0:
+ if len( indicesInSlice ) == 0:
continue
# Distance XY à la référence
- dXYInSlice = np.sqrt(
- (centers[indicesInSlice, 0] - refX)**2 +
- (centers[indicesInSlice, 1] - refY)**2
- )
+ dXYInSlice = np.sqrt( ( centers[ indicesInSlice, 0 ] - refX )**2 +
+ ( centers[ indicesInSlice, 1 ] - refY )**2 )
# Ne garder que celles dans la tolérance XY
validMask = dXYInSlice < xyTolerance
- if not np.any(validMask):
+ if not np.any( validMask ):
# Aucune dans la tolérance → prendre la plus proche
- closestInSlice = indicesInSlice[np.argmin(dXYInSlice)]
+ closestInSlice = indicesInSlice[ np.argmin( dXYInSlice ) ]
else:
# Prendre la plus proche parmi celles dans la tolérance
- validIndices = indicesInSlice[validMask]
- dXYValid = dXYInSlice[validMask]
- closestInSlice = validIndices[np.argmin(dXYValid)]
+ validIndices = indicesInSlice[ validMask ]
+ dXYValid = dXYInSlice[ validMask ]
+ closestInSlice = validIndices[ np.argmin( dXYValid ) ]
- profileIndices.append(closestInSlice)
+ profileIndices.append( closestInSlice )
# ===================================================================
# ÉTAPE 8: SUPPRIMER DOUBLONS ET TRIER
@@ -285,57 +293,60 @@ def extractAdaptiveProfile(centers, values, xStart, yStart, zStart=None,
uniqueIndices = []
for idx in profileIndices:
if idx not in seen:
- seen.add(idx)
- uniqueIndices.append(idx)
+ seen.add( idx )
+ uniqueIndices.append( idx )
- if len(uniqueIndices) == 0:
+ if len( uniqueIndices ) == 0:
if verbose:
- print(f" ⚠️ No points extracted")
- return np.array([]), np.array([]), np.array([]), np.array([])
+ print( " ⚠️ No points extracted" )
+ return np.array( [] ), np.array( [] ), np.array( [] ), np.array( [] )
- profileIndices = np.array(uniqueIndices)
+ profileIndicesArr = np.array( uniqueIndices )
# Trier par profondeur décroissante (haut → bas)
- sortOrder = np.argsort(-centers[profileIndices, 2])
- profileIndices = profileIndices[sortOrder]
+ sortOrder = np.argsort( -centers[ profileIndicesArr, 2 ] )
+ profileIndicesArr = profileIndices[ sortOrder ]
# Extraire résultats
- depths = centers[profileIndices, 2]
- profileValues = values[profileIndices]
- pathX = centers[profileIndices, 0]
- pathY = centers[profileIndices, 1]
+ depths = centers[ profileIndicesArr, 2 ]
+ profileValues = values[ profileIndicesArr ]
+ pathX = centers[ profileIndicesArr, 0 ]
+ pathY = centers[ profileIndicesArr, 1 ]
# ===================================================================
# STATISTIQUES
# ===================================================================
if verbose:
- depthCoverage = (depths.max() - depths.min()) / zRange * 100 if zRange > 0 else 0
- xyDisplacement = np.sqrt((pathX[-1] - pathX[0])**2 + (pathY[-1] - pathY[0])**2)
+ depthCoverage = ( depths.max() - depths.min() ) / zRange * 100 if zRange > 0 else 0
+ xyDisplacement = np.sqrt( ( pathX[ -1 ] - pathX[ 0 ] )**2 + ( pathY[ -1 ] - pathY[ 0 ] )**2 )
- print(f" ✅ Extracted {len(profileIndices)} points")
- print(f" Depth range: [{depths.max():.1f}, {depths.min():.1f}]m")
- print(f" Coverage: {depthCoverage:.1f}% of fault depth")
- print(f" XY displacement: {xyDisplacement:.1f}m")
+ print( f" ✅ Extracted {len(profileIndices)} points" )
+ print( f" Depth range: [{depths.max():.1f}, {depths.min():.1f}]m" )
+ print( f" Coverage: {depthCoverage:.1f}% of fault depth" )
+ print( f" XY displacement: {xyDisplacement:.1f}m" )
- return (depths, profileValues, pathX, pathY)
+ return ( depths, profileValues, pathX, pathY )
# -------------------------------------------------------------------
@staticmethod
- def extractVerticalProfileTopologyBased(surfaceMesh, fieldName, xStart, yStart, zStart=None,
- maxSteps=500, verbose=True):
- """
- Extraction de profil vertical en utilisant la TOPOLOGIE du maillage de surface.
- """
-
- import pyvista as pv
-
+ def extractVerticalProfileTopologyBased(
+ surfaceMesh: pv.DataSet,
+ fieldName: str,
+ xStart: float,
+ yStart: float,
+ zStart: float | None = None,
+ maxSteps: int = 500,
+ verbose: bool = True
+ ) -> tuple[ npt.NDArray[ np.float64 ], npt.NDArray[ np.float64 ], npt.NDArray[ np.float64 ],
+ npt.NDArray[ np.float64 ] ]:
+ """Extraction de profil vertical en utilisant la TOPOLOGIE du maillage de surface."""
if fieldName not in surfaceMesh.cell_data:
- print(f" ⚠️ Field '{fieldName}' not found in mesh")
- return np.array([]), np.array([]), np.array([]), np.array([])
+ print( f" ⚠️ Field '{fieldName}' not found in mesh" )
+ return np.array( [] ), np.array( [] ), np.array( [] ), np.array( [] )
centers = surfaceMesh.cell_centers().points
- values = surfaceMesh.cell_data[fieldName]
+ values = surfaceMesh.cell_data[ fieldName ]
# ===================================================================
# ÉTAPE 1: TROUVER LA CELLULE DE DÉPART
@@ -343,31 +354,30 @@ def extractVerticalProfileTopologyBased(surfaceMesh, fieldName, xStart, yStart,
if zStart is None:
if verbose:
- print(f" Searching near ({xStart:.1f}, {yStart:.1f})")
+ print( f" Searching near ({xStart:.1f}, {yStart:.1f})" )
- dXY = np.sqrt((centers[:, 0] - xStart)**2 + (centers[:, 1] - yStart)**2)
- closestIndices = np.argsort(dXY)[:20]
+ dXY = np.sqrt( ( centers[ :, 0 ] - xStart )**2 + ( centers[ :, 1 ] - yStart )**2 )
+ closestIndices = np.argsort( dXY )[ :20 ]
- if len(closestIndices) == 0:
- print(f" ⚠️ No cells found")
- return np.array([]), np.array([]), np.array([]), np.array([])
+ if len( closestIndices ) == 0:
+ print( " ⚠️ No cells found" )
+ return np.array( [] ), np.array( [] ), np.array( [] ), np.array( [] )
- closestDepths = centers[closestIndices, 2]
- startIdx = closestIndices[np.argmax(closestDepths)]
+ closestDepths = centers[ closestIndices, 2 ]
+ startIdx = closestIndices[ np.argmax( closestDepths ) ]
else:
if verbose:
- print(f" Searching near ({xStart:.1f}, {yStart:.1f}, {zStart:.1f})")
+ print( f" Searching near ({xStart:.1f}, {yStart:.1f}, {zStart:.1f})" )
- d3D = np.sqrt((centers[:, 0] - xStart)**2 +
- (centers[:, 1] - yStart)**2 +
- (centers[:, 2] - zStart)**2)
- startIdx = np.argmin(d3D)
+ d3D = np.sqrt( ( centers[ :, 0 ] - xStart )**2 + ( centers[ :, 1 ] - yStart )**2 +
+ ( centers[ :, 2 ] - zStart )**2 )
+ startIdx = np.argmin( d3D )
- startPoint = centers[startIdx]
+ startPoint = centers[ startIdx ]
if verbose:
- print(f" Starting cell: {startIdx}")
- print(f" Starting point: ({startPoint[0]:.1f}, {startPoint[1]:.1f}, {startPoint[2]:.1f})")
+ print( f" Starting cell: {startIdx}" )
+ print( f" Starting point: ({startPoint[0]:.1f}, {startPoint[1]:.1f}, {startPoint[2]:.1f})" )
# ===================================================================
# ÉTAPE 2: IDENTIFIER LA FAILLE
@@ -375,97 +385,97 @@ def extractVerticalProfileTopologyBased(surfaceMesh, fieldName, xStart, yStart,
targetFaultId = None
faultIds = None
- faultFieldNames = ['attribute', 'FaultMask', 'faultId', 'region']
+ faultFieldNames = [ 'attribute', 'FaultMask', 'faultId', 'region' ]
for fieldNameCheck in faultFieldNames:
if fieldNameCheck in surfaceMesh.cell_data:
- faultIds = surfaceMesh.cell_data[fieldNameCheck]
- targetFaultId = faultIds[startIdx]
+ faultIds = surfaceMesh.cell_data[ fieldNameCheck ]
+ targetFaultId = faultIds[ startIdx ]
if verbose:
- uniqueIds = np.unique(faultIds)
- print(f" Fault field: '{fieldNameCheck}'")
- print(f" Target fault ID: {targetFaultId} (from {uniqueIds})")
+ uniqueIds = np.unique( faultIds )
+ print( f" Fault field: '{fieldNameCheck}'" )
+ print( f" Target fault ID: {targetFaultId} (from {uniqueIds})" )
break
if targetFaultId is None and verbose:
- print(f" ⚠️ No fault ID found - will use all cells")
+ print( " ⚠️ No fault ID found - will use all cells" )
# ===================================================================
# ÉTAPE 3: CONSTRUIRE LA CONNECTIVITÉ (VOISINS TOPOLOGIQUES)
# ===================================================================
if verbose:
- print(f" Building cell connectivity...")
+ print( " Building cell connectivity..." )
nCells = surfaceMesh.n_cells
- connectivity = [[] for _ in range(nCells)]
+ connectivity: list[ list[ int ] ] = [ [] for _ in range( nCells ) ]
# Construire un dictionnaire arête -> cellules
- edgeToCells = {}
+ edgeToCells: dict[ tuple[ int, int ], list[ int ] ] = {}
- for cellId in range(nCells):
- cell = surfaceMesh.get_cell(cellId)
+ for cellId in range( nCells ):
+ cell = surfaceMesh.get_cell( cellId )
nPoints = cell.n_points
# Pour chaque arête de la cellule
- for i in range(nPoints):
- p1 = cell.point_ids[i]
- p2 = cell.point_ids[(i + 1) % nPoints]
+ for i in range( nPoints ):
+ p1 = cell.point_ids[ i ]
+ p2 = cell.point_ids[ ( i + 1 ) % nPoints ]
# Arête normalisée (ordre canonique)
- edge = tuple(sorted([p1, p2]))
+ edge = tuple( sorted( [ p1, p2 ] ) )
if edge not in edgeToCells:
- edgeToCells[edge] = []
- edgeToCells[edge].append(cellId)
+ edgeToCells[ edge ] = []
+ edgeToCells[ edge ].append( cellId )
# Pour chaque cellule, trouver ses voisins via arêtes partagées
- for cellId in range(nCells):
- cell = surfaceMesh.get_cell(cellId)
+ for cellId in range( nCells ):
+ cell = surfaceMesh.get_cell( cellId )
nPoints = cell.n_points
neighborsSet = set()
- for i in range(nPoints):
- p1 = cell.point_ids[i]
- p2 = cell.point_ids[(i + 1) % nPoints]
- edge = tuple(sorted([p1, p2]))
+ for i in range( nPoints ):
+ p1 = cell.point_ids[ i ]
+ p2 = cell.point_ids[ ( i + 1 ) % nPoints ]
+ edge = tuple( sorted( [ p1, p2 ] ) )
# Toutes les cellules partageant cette arête sont voisines
- for neighborId in edgeToCells[edge]:
+ for neighborId in edgeToCells[ edge ]:
if neighborId != cellId:
- neighborsSet.add(neighborId)
+ neighborsSet.add( neighborId )
- connectivity[cellId] = list(neighborsSet)
+ connectivity[ cellId ] = list( neighborsSet )
if verbose:
- avgNeighbors = np.mean([len(c) for c in connectivity])
- maxNeighbors = np.max([len(c) for c in connectivity])
- print(f" Connectivity built: avg={avgNeighbors:.1f} neighbors/cell, max={maxNeighbors}")
+ avgNeighbors = np.mean( [ len( c ) for c in connectivity ] )
+ maxNeighbors = np.max( [ len( c ) for c in connectivity ] )
+ print( f" Connectivity built: avg={avgNeighbors:.1f} neighbors/cell, max={maxNeighbors}" )
# ===================================================================
# ÉTAPE 4: ALGORITHME DE DESCENTE PAR VOISINAGE TOPOLOGIQUE
# ===================================================================
- profileIndices = [startIdx]
- visited = {startIdx}
+ profileIndices = [ startIdx ]
+ visited = { startIdx }
currentIdx = startIdx
- refXY = startPoint[:2] # Position XY de référence
+ refXY = startPoint[ :2 ] # Position XY de référence
if verbose:
- print(f" Starting descent from Z={startPoint[2]:.1f}m...")
+ print( f" Starting descent from Z={startPoint[2]:.1f}m..." )
stuckCount = 0
maxStuck = 3
- for step in range(maxSteps):
- currentZ = centers[currentIdx, 2]
+ for step in range( maxSteps ):
+ currentZ = centers[ currentIdx, 2 ]
# Obtenir les voisins topologiques
- neighborIndices = connectivity[currentIdx]
+ neighborIndices = connectivity[ currentIdx ]
# Filtrer les voisins:
# 1. Non visités
@@ -478,23 +488,22 @@ def extractVerticalProfileTopologyBased(surfaceMesh, fieldName, xStart, yStart,
continue
# Vérifier la faille
- if targetFaultId is not None and faultIds is not None:
- if faultIds[idx] != targetFaultId:
- continue
+ if targetFaultId is not None and faultIds is not None and faultIds[ idx ] != targetFaultId:
+ continue
# Vérifier qu'on descend
- if centers[idx, 2] >= currentZ:
+ if centers[ idx, 2 ] >= currentZ:
continue
- candidates.append(idx)
+ candidates.append( idx )
- if len(candidates) == 0:
+ if len( candidates ) == 0:
# Si bloqué, essayer de regarder les voisins des voisins
stuckCount += 1
if stuckCount >= maxStuck:
if verbose:
- print(f" Reached bottom at Z={currentZ:.1f}m after {step+1} steps (no more neighbors)")
+ print( f" Reached bottom at Z={currentZ:.1f}m after {step+1} steps (no more neighbors)" )
break
# Essayer niveau 2 (voisins des voisins)
@@ -503,35 +512,35 @@ def extractVerticalProfileTopologyBased(surfaceMesh, fieldName, xStart, yStart,
if neighborIdx in visited:
continue
- for secondNeighborIdx in connectivity[neighborIdx]:
+ for secondNeighborIdx in connectivity[ neighborIdx ]:
if secondNeighborIdx in visited:
continue
- if targetFaultId is not None and faultIds is not None:
- if faultIds[secondNeighborIdx] != targetFaultId:
- continue
+ if targetFaultId is not None and faultIds is not None and faultIds[
+ secondNeighborIdx ] != targetFaultId:
+ continue
- if centers[secondNeighborIdx, 2] < currentZ:
- extendedCandidates.append(secondNeighborIdx)
+ if centers[ secondNeighborIdx, 2 ] < currentZ:
+ extendedCandidates.append( secondNeighborIdx )
- if len(extendedCandidates) == 0:
+ if len( extendedCandidates ) == 0:
if verbose:
- print(f" Reached bottom at Z={currentZ:.1f}m (extended search failed)")
+ print( f" Reached bottom at Z={currentZ:.1f}m (extended search failed)" )
break
candidates = extendedCandidates
if verbose:
- print(f" Used extended search at step {step+1}")
+ print( f" Used extended search at step {step+1}" )
else:
stuckCount = 0
# Parmi les candidats, choisir celui le plus proche en XY de la référence
bestIdx = None
- bestDistanceXY = float('inf')
+ bestDistanceXY = float( 'inf' )
for idx in candidates:
- pos = centers[idx]
- dXY = np.sqrt((pos[0] - refXY[0])**2 + (pos[1] - refXY[1])**2)
+ pos = centers[ idx ]
+ dXY = np.sqrt( ( pos[ 0 ] - refXY[ 0 ] )**2 + ( pos[ 1 ] - refXY[ 1 ] )**2 )
if dXY < bestDistanceXY:
bestDistanceXY = dXY
@@ -539,56 +548,61 @@ def extractVerticalProfileTopologyBased(surfaceMesh, fieldName, xStart, yStart,
if bestIdx is None:
if verbose:
- print(f" No valid neighbor at Z={currentZ:.1f}m")
+ print( f" No valid neighbor at Z={currentZ:.1f}m" )
break
# Ajouter au profil
- profileIndices.append(bestIdx)
- visited.add(bestIdx)
+ profileIndices.append( bestIdx )
+ visited.add( bestIdx )
currentIdx = bestIdx
# Debug
- if verbose and (step + 1) % 100 == 0:
- print(f" Step {step+1}: Z={centers[currentIdx, 2]:.1f}m, XY=({centers[currentIdx, 0]:.1f}, {centers[currentIdx, 1]:.1f})")
+ if verbose and ( step + 1 ) % 100 == 0:
+ print(
+ f" Step {step+1}: Z={centers[currentIdx, 2]:.1f}m, XY=({centers[currentIdx, 0]:.1f}, {centers[currentIdx, 1]:.1f})"
+ )
# ===================================================================
# ÉTAPE 5: EXTRAIRE LES RÉSULTATS
# ===================================================================
- if len(profileIndices) == 0:
+ if len( profileIndices ) == 0:
if verbose:
- print(f" ⚠️ No profile extracted")
- return np.array([]), np.array([]), np.array([]), np.array([])
+ print( " ⚠️ No profile extracted" )
+ return np.array( [] ), np.array( [] ), np.array( [] ), np.array( [] )
- profileIndices = np.array(profileIndices)
-
- depths = centers[profileIndices, 2]
- profileValues = values[profileIndices]
- pathX = centers[profileIndices, 0]
- pathY = centers[profileIndices, 1]
+ depths = centers[ np.array( profileIndices ), 2 ]
+ profileValues = values[ np.array( profileIndices ) ]
+ pathX = centers[ np.array( profileIndices ), 0 ]
+ pathY = centers[ np.array( profileIndices ), 1 ]
# ===================================================================
# STATISTIQUES
# ===================================================================
if verbose:
- zRange = np.max(centers[:, 2]) - np.min(centers[:, 2])
- depthCoverage = (depths.max() - depths.min()) / zRange * 100 if zRange > 0 else 0
- xyDisplacement = np.sqrt((pathX[-1] - pathX[0])**2 + (pathY[-1] - pathY[0])**2)
+ zRange = np.max( centers[ :, 2 ] ) - np.min( centers[ :, 2 ] )
+ depthCoverage = ( depths.max() - depths.min() ) / zRange * 100 if zRange > 0 else 0
+ xyDisplacement = np.sqrt( ( pathX[ -1 ] - pathX[ 0 ] )**2 + ( pathY[ -1 ] - pathY[ 0 ] )**2 )
- print(f" ✅ {len(profileIndices)} points extracted")
- print(f" Depth range: [{depths.max():.1f}, {depths.min():.1f}]m")
- print(f" Coverage: {depthCoverage:.1f}% of fault depth")
- print(f" XY displacement: {xyDisplacement:.1f}m")
+ print( f" ✅ {len(profileIndices)} points extracted" )
+ print( f" Depth range: [{depths.max():.1f}, {depths.min():.1f}]m" )
+ print( f" Coverage: {depthCoverage:.1f}% of fault depth" )
+ print( f" XY displacement: {xyDisplacement:.1f}m" )
- return (depths, profileValues, pathX, pathY)
+ return ( depths, profileValues, pathX, pathY )
# -------------------------------------------------------------------
@staticmethod
- def plotProfilePath3D(surface, pathX, pathY, pathZ, profileValues=None,
- scalarName='SCU', savePath=None, show=True):
- """
- Visualize the extracted profile path on the fault surface in 3D using PyVista.
+ def plotProfilePath3D( surface: pv.DataSet,
+ pathX: npt.NDArray[ np.float64 ],
+ pathY: npt.NDArray[ np.float64 ],
+ pathZ: npt.NDArray[ np.float64 ],
+ profileValues: npt.NDArray[ np.float64 ] | None = None,
+ scalarName: str = 'SCU',
+ savePath: str | None = None,
+ show: bool = True ) -> None:
+ """Visualize the extracted profile path on the fault surface in 3D using PyVista.
Parameters
----------
@@ -605,127 +619,109 @@ def plotProfilePath3D(surface, pathX, pathY, pathZ, profileValues=None,
show : bool
Whether to display the plot interactively
"""
- if len(pathX) == 0:
- print(" ⚠️ No path to plot (empty profile)")
+ if len( pathX ) == 0:
+ print( " ⚠️ No path to plot (empty profile)" )
return
- print(f" 📊 Creating 3D visualization of profile path ({len(pathX)} points)")
+ print( f" 📊 Creating 3D visualization of profile path ({len(pathX)} points)" )
# Create plotter
- plotter = pv.Plotter(window_size=[1600, 1200])
+ plotter = pv.Plotter( window_size=[ 1600, 1200 ] )
# Add fault surface with scalar field
if scalarName in surface.cell_data:
- plotter.add_mesh(
- surface,
- scalars=scalarName,
- cmap='RdYlGn_r',
- opacity=0.7,
- show_edges=False,
- lighting=True,
- smooth_shading=True,
- scalar_bar_args={
- 'title': scalarName,
- 'title_font_size': 20,
- 'label_font_size': 16,
- 'n_labels': 5,
- 'italic': False,
- 'fmt': '%.2f',
- 'font_family': 'arial',
- }
- )
+ plotter.add_mesh( surface,
+ scalars=scalarName,
+ cmap='RdYlGn_r',
+ opacity=0.7,
+ show_edges=False,
+ lighting=True,
+ smooth_shading=True,
+ scalar_bar_args={
+ 'title': scalarName,
+ 'title_font_size': 20,
+ 'label_font_size': 16,
+ 'n_labels': 5,
+ 'italic': False,
+ 'fmt': '%.2f',
+ 'font_family': 'arial',
+ } )
else:
- plotter.add_mesh(
- surface,
- color='lightgray',
- opacity=0.5,
- show_edges=True
- )
+ plotter.add_mesh( surface, color='lightgray', opacity=0.5, show_edges=True )
# Create path as a polyline
- pathPoints = np.column_stack([pathX, pathY, pathZ])
- pathPolyline = pv.PolyData(pathPoints)
+ pathPoints = np.column_stack( [ pathX, pathY, pathZ ] )
+ pathPolyline = pv.PolyData( pathPoints )
# Add connectivity for line
- nPoints = len(pathPoints)
- lines = np.full((nPoints - 1, 3), 2, dtype=np.int_)
- lines[:, 1] = np.arange(nPoints - 1)
- lines[:, 2] = np.arange(1, nPoints)
+ nPoints = len( pathPoints )
+ lines = np.full( ( nPoints - 1, 3 ), 2, dtype=np.int_ )
+ lines[ :, 1 ] = np.arange( nPoints - 1 )
+ lines[ :, 2 ] = np.arange( 1, nPoints )
pathPolyline.lines = lines.ravel()
# Color the path by profile values or depth
if profileValues is not None:
- pathPolyline['profile_value'] = profileValues
+ pathPolyline[ 'profile_value' ] = profileValues
colorField = 'profile_value'
cmapPath = 'viridis'
else:
- pathPolyline['depth'] = pathZ
+ pathPolyline[ 'depth' ] = pathZ
colorField = 'depth'
cmapPath = 'turbo_r'
# Add path as thick tube
- pathTube = pathPolyline.tube(radius=10.0) # Adjust radius as needed
- plotter.add_mesh(
- pathTube,
- scalars=colorField,
- cmap=cmapPath,
- line_width=8,
- render_lines_as_tubes=True,
- lighting=True,
- scalar_bar_args={
- 'title': 'Path ' + colorField,
- 'title_font_size': 20,
- 'label_font_size': 16,
- 'position_x': 0.85,
- 'position_y': 0.05,
- }
- )
+ pathTube = pathPolyline.tube( radius=10.0 ) # Adjust radius as needed
+ plotter.add_mesh( pathTube,
+ scalars=colorField,
+ cmap=cmapPath,
+ line_width=8,
+ render_lines_as_tubes=True,
+ lighting=True,
+ scalar_bar_args={
+ 'title': 'Path ' + colorField,
+ 'title_font_size': 20,
+ 'label_font_size': 16,
+ 'position_x': 0.85,
+ 'position_y': 0.05,
+ } )
# Add start and end markers
- startPoint = pv.Sphere(radius=30, center=pathPoints[0])
- endPoint = pv.Sphere(radius=30, center=pathPoints[-1])
+ startPoint = pv.Sphere( radius=30, center=pathPoints[ 0 ] )
+ endPoint = pv.Sphere( radius=30, center=pathPoints[ -1 ] )
- plotter.add_mesh(startPoint, color='lime', label='Start (Top)')
- plotter.add_mesh(endPoint, color='red', label='End (Bottom)')
+ plotter.add_mesh( startPoint, color='lime', label='Start (Top)' )
+ plotter.add_mesh( endPoint, color='red', label='End (Bottom)' )
# Add axes and labels
- plotter.add_axes(
- xlabel='X [m]',
- ylabel='Y [m]',
- zlabel='Z [m]',
- line_width=3,
- labels_off=False
- )
+ plotter.add_axes( xlabel='X [m]', ylabel='Y [m]', zlabel='Z [m]', line_width=3, labels_off=False )
# Add legend
- plotter.add_legend(
- labels=[('Start (Top)', 'lime'), ('End (Bottom)', 'red')],
- bcolor='white',
- border=True,
- size=(0.15, 0.1),
- loc='upper left'
- )
+ plotter.add_legend( labels=[ ( 'Start (Top)', 'lime' ), ( 'End (Bottom)', 'red' ) ],
+ bcolor='white',
+ border=True,
+ size=( 0.15, 0.1 ),
+ loc='upper left' )
# Set camera and lighting
plotter.camera_position = 'iso'
- plotter.add_light(pv.Light(position=(1, 1, 1), intensity=0.8))
+ plotter.add_light( pv.Light( position=( 1, 1, 1 ), intensity=0.8 ) )
# Add title
- pathLength = np.sum(np.sqrt(np.sum(np.diff(pathPoints, axis=0)**2, axis=1)))
+ pathLength = np.sum( np.sqrt( np.sum( np.diff( pathPoints, axis=0 )**2, axis=1 ) ) )
depthRange = pathZ.max() - pathZ.min()
- title = f'Profile Path Extraction\n'
+ title = 'Profile Path Extraction\n'
title += f'Points: {len(pathX)} | Length: {pathLength:.1f}m | Depth range: {depthRange:.1f}m'
- plotter.add_text(title, position='upper_edge', font_size=14, color='black')
+ plotter.add_text( title, position='upper_edge', font_size=14, color='black' )
# Save screenshot
# if savePath is not None:
- # screenshot_path = savePath / 'profile_path_3d.png'
- # plotter.screenshot(str(screenshot_path))
- # print(f" 💾 Screenshot saved: {screenshot_path}")
+ # screenshot_path = savePath / 'profile_path_3d.png'
+ # plotter.screenshot(str(screenshot_path))
+ # print(f" 💾 Screenshot saved: {screenshot_path}")
# Show
if show:
plotter.show()
else:
plotter.close()
-
diff --git a/geos-processing/src/geos/processing/post_processing/SensitivityAnalyzer.py b/geos-processing/src/geos/processing/post_processing/SensitivityAnalyzer.py
index 6541d11d..b0bcb0ea 100644
--- a/geos-processing/src/geos/processing/post_processing/SensitivityAnalyzer.py
+++ b/geos-processing/src/geos/processing/post_processing/SensitivityAnalyzer.py
@@ -5,107 +5,121 @@
# SENSITIVITY ANALYSIS
# ============================================================================
import pandas as pd
+from pathlib import Path
+import numpy as np
from matplotlib.colors import Normalize
from matplotlib.cm import ScalarMappable
+import matplotlib.pyplot as plt
+import pyvista as pv
+from typing_extensions import Any, Self
+
+from geos.processing.post_processing.FaultStabilityAnalysis import ( Config, MohrCoulomb )
+from geos.processing.post_processing.ProfileExtractor import ProfileExtractor
+
+
class SensitivityAnalyzer:
- """Performs sensitivity analysis on Mohr-Coulomb parameters"""
+ """Performs sensitivity analysis on Mohr-Coulomb parameters."""
# -------------------------------------------------------------------
- def __init__(self, config):
+ def __init__( self: Self, config: Config ) -> None:
+ """Init."""
self.config = config
- self.outputDir = Path(config.SENSITIVITY_OUTPUT_DIR)
- self.outputDir.mkdir(exist_ok=True)
- self.results = []
+ self.outputDir = Path( config.SENSITIVITY_OUTPUT_DIR )
+ self.outputDir.mkdir( exist_ok=True )
+ self.results: list[ dict[ str, Any ] ] = []
# -------------------------------------------------------------------
- def runAnalysis(self, surfaceWithStress, time):
- """Run sensitivity analysis for multiple friction angles and cohesions"""
+ def runAnalysis( self: Self, surfaceWithStress: pv.DataSet, time: float ) -> list[ dict[ str, Any ] ]:
+ """Run sensitivity analysis for multiple friction angles and cohesions."""
frictionAngles = self.config.SENSITIVITY_FRICTION_ANGLES
cohesions = self.config.SENSITIVITY_COHESIONS
- print("\n" + "=" * 60)
- print("SENSITIVITY ANALYSIS")
- print("=" * 60)
- print(f"Friction angles: {frictionAngles}")
- print(f"Cohesions: {cohesions}")
- print(f"Total combinations: {len(frictionAngles) * len(cohesions)}")
+ print( "\n" + "=" * 60 )
+ print( "SENSITIVITY ANALYSIS" )
+ print( "=" * 60 )
+ print( f"Friction angles: {frictionAngles}" )
+ print( f"Cohesions: {cohesions}" )
+ print( f"Total combinations: {len(frictionAngles) * len(cohesions)}" )
results = []
for frictionAngle in frictionAngles:
for cohesion in cohesions:
- print(f"\n→ Testing φ={frictionAngle}°, C={cohesion} bar")
+ print( f"\n→ Testing φ={frictionAngle}°, C={cohesion} bar" )
surfaceCopy = surfaceWithStress.copy()
surfaceAnalyzed = MohrCoulomb.analyze(
- surfaceCopy, cohesion, frictionAngle, time, verbose=False)
+ # surfaceCopy, cohesion, frictionAngle, time, verbose=False)
+ surfaceCopy,
+ cohesion,
+ frictionAngle,
+ verbose=False )
- stats = self._extractStatistics(surfaceAnalyzed, frictionAngle, cohesion)
- results.append(stats)
+ stats = self._extractStatistics( surfaceAnalyzed, frictionAngle, cohesion )
+ results.append( stats )
- print(f" Unstable: {stats['nUnstable']}, "
- f"Critical: {stats['nCritical']}, "
- f"Stable: {stats['nStable']}")
+ print( f" Unstable: {stats['nUnstable']}, "
+ f"Critical: {stats['nCritical']}, "
+ f"Stable: {stats['nStable']}" )
self.results = results
# Generate plots
- self._plotSensitivityResults(results, time)
+ self._plotSensitivityResults( results, time )
# Plot SCU vs depth
- self._plotSCUDepthProfiles(results, time, surfaceWithStress)
+ self._plotSCUDepthProfiles( results, time, surfaceWithStress )
return results
# -------------------------------------------------------------------
- def _extractStatistics(self, surface, frictionAngle, cohesion):
- """Extract statistical metrics from analyzed surface"""
- stability = surface.cell_data["stabilityState"]
- SCU = surface.cell_data["SCU"]
- failureProba = surface.cell_data["failureProbability"]
- safetyMargin = surface.cell_data["safetyMargin"]
+ def _extractStatistics( self: Self, surface: pv.DataSet, frictionAngle: float,
+ cohesion: float ) -> dict[ str, Any ]:
+ """Extract statistical metrics from analyzed surface."""
+ stability = surface.cell_data[ "stabilityState" ]
+ SCU = surface.cell_data[ "SCU" ]
+ failureProba = surface.cell_data[ "failureProbability" ]
+ safetyMargin = surface.cell_data[ "safetyMargin" ]
stats = {
'frictionAngle': frictionAngle,
'cohesion': cohesion,
'nCells': surface.n_cells,
- 'nStable': np.sum(stability == 0),
- 'nCritical': np.sum(stability == 1),
- 'nUnstable': np.sum(stability == 2),
- 'pctUnstable': np.sum(stability == 2) / surface.n_cells * 100,
- 'pctCritical': np.sum(stability == 1) / surface.n_cells * 100,
- 'pctStable': np.sum(stability == 0) / surface.n_cells * 100,
- 'meanSCU': np.mean(SCU),
- 'maxSCU': np.max(SCU),
- 'meanFailureProb': np.mean(failureProba),
- 'meanSafetyMargin': np.mean(safetyMargin),
- 'minSafetyMargin': np.min(safetyMargin)
+ 'nStable': np.sum( stability == 0 ),
+ 'nCritical': np.sum( stability == 1 ),
+ 'nUnstable': np.sum( stability == 2 ),
+ 'pctUnstable': np.sum( stability == 2 ) / surface.n_cells * 100,
+ 'pctCritical': np.sum( stability == 1 ) / surface.n_cells * 100,
+ 'pctStable': np.sum( stability == 0 ) / surface.n_cells * 100,
+ 'meanSCU': np.mean( SCU ),
+ 'maxSCU': np.max( SCU ),
+ 'meanFailureProb': np.mean( failureProba ),
+ 'meanSafetyMargin': np.mean( safetyMargin ),
+ 'minSafetyMargin': np.min( safetyMargin )
}
return stats
# -------------------------------------------------------------------
- def _plotSensitivityResults(self, results, time):
- """Create comprehensive sensitivity analysis plots"""
- import pandas as pd
+ def _plotSensitivityResults( self: Self, results: list[ dict[ str, Any ] ], time: float ) -> None:
+ """Create comprehensive sensitivity analysis plots."""
+ df = pd.DataFrame( results )
- df = pd.DataFrame(results)
-
- fig, axes = plt.subplots(2, 2, figsize=(16, 12))
+ fig, axes = plt.subplots( 2, 2, figsize=( 16, 12 ) )
# Plot heatmaps
- self._plotHeatMap(df, 'pctUnstable', 'Unstable Cells [%]', axes[0, 0])
- self._plotHeatMap(df, 'pctCritical', 'Critical Cells [%]', axes[0, 1])
- self._plotHeatMap(df, 'meanSCU', 'Mean SCU [-]', axes[1, 0])
- self._plotHeatMap(df, 'meanSafetyMargin', 'Mean Safety Margin [bar]', axes[1, 1])
+ self._plotHeatMap( df, 'pctUnstable', 'Unstable Cells [%]', axes[ 0, 0 ] )
+ self._plotHeatMap( df, 'pctCritical', 'Critical Cells [%]', axes[ 0, 1 ] )
+ self._plotHeatMap( df, 'meanSCU', 'Mean SCU [-]', axes[ 1, 0 ] )
+ self._plotHeatMap( df, 'meanSafetyMargin', 'Mean Safety Margin [bar]', axes[ 1, 1 ] )
plt.tight_layout()
- years = time / (365.25 * 24 * 3600)
+ years = time / ( 365.25 * 24 * 3600 )
filename = f'sensitivity_analysis_{years:.0f}y.png'
- plt.savefig(self.outputDir / filename, dpi=300, bbox_inches='tight')
- print(f"\n📊 Sensitivity plot saved: {filename}")
+ plt.savefig( self.outputDir / filename, dpi=300, bbox_inches='tight' )
+ print( f"\n📊 Sensitivity plot saved: {filename}" )
if self.config.SHOW_PLOTS:
plt.show()
@@ -113,171 +127,170 @@ def _plotSensitivityResults(self, results, time):
plt.close()
# -------------------------------------------------------------------
- def _plotHeatMap(self, df, column, title, ax):
- """Create a single heatmap for sensitivity analysis"""
- pivot = df.pivot(index='cohesion', columns='frictionAngle', values=column)
+ def _plotHeatMap( self: Self, df: pd.DataFrame, column: str, title: str, ax: plt.Axes ) -> None:
+ """Create a single heatmap for sensitivity analysis."""
+ pivot = df.pivot( index='cohesion', columns='frictionAngle', values=column )
- im = ax.imshow(pivot.values, cmap='RdYlGn_r', aspect='auto', origin='lower')
+ im = ax.imshow( pivot.values, cmap='RdYlGn_r', aspect='auto', origin='lower' )
- ax.set_xticks(np.arange(len(pivot.columns)))
- ax.set_yticks(np.arange(len(pivot.index)))
- ax.set_xticklabels(pivot.columns)
- ax.set_yticklabels(pivot.index)
+ ax.set_xticks( np.arange( len( pivot.columns ) ) )
+ ax.set_yticks( np.arange( len( pivot.index ) ) )
+ ax.set_xticklabels( pivot.columns )
+ ax.set_yticklabels( pivot.index )
- ax.set_xlabel('Friction Angle [°]')
- ax.set_ylabel('Cohesion [bar]')
- ax.set_title(title)
+ ax.set_xlabel( 'Friction Angle [°]' )
+ ax.set_ylabel( 'Cohesion [bar]' )
+ ax.set_title( title )
# Add values in cells
- for i in range(len(pivot.index)):
- for j in range(len(pivot.columns)):
- value = pivot.values[i, j]
+ for i in range( len( pivot.index ) ):
+ for j in range( len( pivot.columns ) ):
+ value = pivot.values[ i, j ]
textColor = 'white' if value > pivot.values.max() * 0.5 else 'black'
- ax.text(j, i, f'{value:.1f}', ha='center', va='center',
- color=textColor, fontsize=9)
+ ax.text( j, i, f'{value:.1f}', ha='center', va='center', color=textColor, fontsize=9 )
- plt.colorbar(im, ax=ax)
+ plt.colorbar( im, ax=ax )
# -------------------------------------------------------------------
- def _plotSCUDepthProfiles(self, results, time, surfaceWithStress):
- """
- Plot SCU depth profiles for all parameter combinations
+ def _plotSCUDepthProfiles( self: Self, results: list[ dict[ str, Any ] ], time: float,
+ surfaceWithStress: pv.DataSet ) -> None:
+ """Plot SCU depth profiles for all parameter combinations.
+
Each (cohesion, friction) pair gets a unique color
- Uses profile points from config.PROFILE_START_POINTS
+ Uses profile points from config.PROFILE_START_POINTS.
"""
-
- print("\n 📊 Creating SCU sensitivity depth profiles...")
+ print( "\n 📊 Creating SCU sensitivity depth profiles..." )
# Extract depth data
- centers = surfaceWithStress.cell_data['elementCenter']
- depth = centers[:, 2]
+ centers = surfaceWithStress.cell_data[ 'elementCenter' ]
+ centers[ :, 2 ]
# Get profile points from config
profileStartPoints = self.config.PROFILE_START_POINTS
# Auto-generate if not provided
if profileStartPoints is None:
- print(" ⚠️ No PROFILE_START_POINTS in config, auto-generating...")
- xMin, xMax = np.min(centers[:, 0]), np.max(centers[:, 0])
- yMin, yMax = np.min(centers[:, 1]), np.max(centers[:, 1])
+ print( " ⚠️ No PROFILE_START_POINTS in config, auto-generating..." )
+ xMin, xMax = np.min( centers[ :, 0 ] ), np.max( centers[ :, 0 ] )
+ yMin, yMax = np.min( centers[ :, 1 ] ), np.max( centers[ :, 1 ] )
xRange = xMax - xMin
yRange = yMax - yMin
if xRange > yRange:
# Fault oriented in X, sample at mid-Y
- xPos = (xMin + xMax) / 2
- yPos = (yMin + yMax) / 2
+ xPos = ( xMin + xMax ) / 2
+ yPos = ( yMin + yMax ) / 2
else:
# Fault oriented in Y, sample at mid-X
- xPos = (xMin + xMax) / 2
- yPos = (yMin + yMax) / 2
+ xPos = ( xMin + xMax ) / 2
+ yPos = ( yMin + yMax ) / 2
- profileStartPoints = [(xPos, yPos)]
+ profileStartPoints = [ ( xPos, yPos ) ]
# Get search radius from config or auto-compute
- searchRadius = getattr(self.config, 'PROFILE_SEARCH_RADIUS', None)
+ searchRadius = getattr( self.config, 'PROFILE_SEARCH_RADIUS', None )
if searchRadius is None:
- xMin, xMax = np.min(centers[:, 0]), np.max(centers[:, 0])
- yMin, yMax = np.min(centers[:, 1]), np.max(centers[:, 1])
+ xMin, xMax = np.min( centers[ :, 0 ] ), np.max( centers[ :, 0 ] )
+ yMin, yMax = np.min( centers[ :, 1 ] ), np.max( centers[ :, 1 ] )
xRange = xMax - xMin
yRange = yMax - yMin
- searchRadius = min(xRange, yRange) * 0.15
+ searchRadius = min( xRange, yRange ) * 0.15
- print(f" 📍 Using {len(profileStartPoints)} profile point(s) from config")
- print(f" Search radius: {searchRadius:.1f}m")
+ print( f" 📍 Using {len(profileStartPoints)} profile point(s) from config" )
+ print( f" Search radius: {searchRadius:.1f}m" )
# Create colormap for parameter combinations
- nCombinations = len(results)
- cmap = plt.cm.viridis
- norm = Normalize(vmin=0, vmax=nCombinations-1)
- sm = ScalarMappable(norm=norm, cmap=cmap)
+ nCombinations = len( results )
+ cmap = plt.cm.viridis # type: ignore [attr-defined]
+ norm = Normalize( vmin=0, vmax=nCombinations - 1 )
+ ScalarMappable( norm=norm, cmap=cmap )
# Create figure with subplots for each profile point
- nProfiles = len(profileStartPoints)
- fig, axes = plt.subplots(1, nProfiles, figsize=(8*nProfiles, 10))
+ nProfiles = len( profileStartPoints )
+ fig, axes = plt.subplots( 1, nProfiles, figsize=( 8 * nProfiles, 10 ) )
# Handle single subplot case
if nProfiles == 1:
- axes = [axes]
+ axes = [ axes ]
# Plot each profile point
- for profileIdx, (xPos, yPos, zPos) in enumerate(profileStartPoints):
- ax = axes[profileIdx]
-
- print(f"\n → Profile {profileIdx+1} at ({xPos:.1f}, {yPos:.1f}, {zPos:.1f}):")
+ for profileIdx, ( xPos, yPos, zPos ) in enumerate( profileStartPoints ):
+ ax = axes[ profileIdx ]
+ print( f"\n → Profile {profileIdx+1} at ({xPos:.1f}, {yPos:.1f}, {zPos:.1f}):" )
# Plot each parameter combination
- for idx, params in enumerate(results):
- frictionAngle = params['frictionAngle']
- cohesion = params['cohesion']
+ for idx, params in enumerate( results ):
+ frictionAngle = params[ 'frictionAngle' ]
+ cohesion = params[ 'cohesion' ]
# Re-analyze surface with these parameters
surfaceCopy = surfaceWithStress.copy()
surfaceAnalyzed = MohrCoulomb.analyze(
- surfaceCopy, cohesion, frictionAngle, time, verbose=False
- )
+ # surfaceCopy, cohesion, frictionAngle, time, verbose=False
+ surfaceCopy,
+ cohesion,
+ frictionAngle,
+ verbose=False )
# Extract SCU
- SCU = np.abs(surfaceAnalyzed.cell_data["SCU"])
+ SCU = np.abs( surfaceAnalyzed.cell_data[ "SCU" ] )
# Extract profile using adaptive method
# depthsSCU, profileSCU, _, _ = ProfileExtractor.extractVerticalProfileTopologyBased(
# surfaceAnalyzed, 'SCU', xPos, yPos, zPos, verbose=False)
depthsSCU, profileSCU, _, _ = ProfileExtractor.extractAdaptiveProfile(
- centers, SCU, xPos, yPos, searchRadius)
+ centers, SCU, xPos, yPos, searchRadius )
- if len(depthsSCU) >= 3:
- color = cmap(norm(idx))
+ if len( depthsSCU ) >= 3:
+ color = cmap( norm( idx ) )
label = f'φ={frictionAngle}°, C={cohesion} bar'
- ax.plot(profileSCU, depthsSCU,
- color=color, label=label,
- linewidth=2, alpha=0.8)
+ ax.plot( profileSCU, depthsSCU, color=color, label=label, linewidth=2, alpha=0.8 )
if idx == 0: # Print info only once per profile
- print(f" ✅ {len(depthsSCU)} points extracted")
+ print( f" ✅ {len(depthsSCU)} points extracted" )
else:
if idx == 0:
- print(f" ⚠️ Insufficient points ({len(depthsSCU)})")
+ print( f" ⚠️ Insufficient points ({len(depthsSCU)})" )
# Add critical lines
- ax.axvline(x=0.8, color='forestgreen', linestyle='--',
- linewidth=2, label='Critical (SCU=0.8)', zorder=100)
- ax.axvline(x=1.0, color='red', linestyle='--',
- linewidth=2, label='Failure (SCU=1.0)', zorder=100)
+ ax.axvline( x=0.8,
+ color='forestgreen',
+ linestyle='--',
+ linewidth=2,
+ label='Critical (SCU=0.8)',
+ zorder=100 )
+ ax.axvline( x=1.0, color='red', linestyle='--', linewidth=2, label='Failure (SCU=1.0)', zorder=100 )
# Configure plot
- ax.set_xlabel('Shear Capacity Utilization (SCU) [-]', fontsize=14, weight='bold')
- ax.set_ylabel('Depth [m]', fontsize=14, weight='bold')
- ax.set_title(f'Profile {profileIdx+1} @ ({xPos:.0f}, {yPos:.0f})',
- fontsize=14, weight='bold')
- ax.grid(True, alpha=0.3, linestyle='--')
- ax.set_xlim(left=0)
+ ax.set_xlabel( 'Shear Capacity Utilization (SCU) [-]', fontsize=14, weight='bold' )
+ ax.set_ylabel( 'Depth [m]', fontsize=14, weight='bold' )
+ ax.set_title( f'Profile {profileIdx+1} @ ({xPos:.0f}, {yPos:.0f})', fontsize=14, weight='bold' )
+ ax.grid( True, alpha=0.3, linestyle='--' )
+ ax.set_xlim( left=0 )
# Change verticale scale
- if hasattr(self.config, 'MAX_DEPTH_PROFILES') and self.config.MAX_DEPTH_PROFILES is not None:
- ax.set_ylim(bottom=self.config.MAX_DEPTH_PROFILES)
+ if hasattr( self.config, 'MAX_DEPTH_PROFILES' ) and self.config.MAX_DEPTH_PROFILES is not None:
+ ax.set_ylim( bottom=self.config.MAX_DEPTH_PROFILES )
# Légende en dehors à droite
- ax.legend(loc='center left', bbox_to_anchor=(1, 0.5), fontsize=9, ncol=1)
+ ax.legend( loc='center left', bbox_to_anchor=( 1, 0.5 ), fontsize=9, ncol=1 )
- ax.tick_params(labelsize=12)
+ ax.tick_params( labelsize=12 )
# Overall title
- years = time / (365.25 * 24 * 3600)
- fig.suptitle('SCU Depth Profiles - Sensitivity Analysis',
- fontsize=16, weight='bold', y=0.98)
+ years = time / ( 365.25 * 24 * 3600 )
+ fig.suptitle( 'SCU Depth Profiles - Sensitivity Analysis', fontsize=16, weight='bold', y=0.98 )
- plt.tight_layout(rect=[0, 0, 1, 0.96])
+ plt.tight_layout( rect=( 0, 0, 1, 0.96 ) )
# Save
filename = f'sensitivity_scu_profiles_{years:.0f}y.png'
- plt.savefig(self.outputDir / filename, dpi=300, bbox_inches='tight')
- print(f"\n 💾 SCU sensitivity profiles saved: {filename}")
+ plt.savefig( self.outputDir / filename, dpi=300, bbox_inches='tight' )
+ print( f"\n 💾 SCU sensitivity profiles saved: {filename}" )
if self.config.SHOW_PLOTS:
plt.show()
else:
plt.close()
-
diff --git a/geos-processing/src/geos/processing/post_processing/StressProjector.py b/geos-processing/src/geos/processing/post_processing/StressProjector.py
index 1f9d4034..59cf7f18 100644
--- a/geos-processing/src/geos/processing/post_processing/StressProjector.py
+++ b/geos-processing/src/geos/processing/post_processing/StressProjector.py
@@ -1,20 +1,28 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright 2023-2026 TotalEnergies.
# SPDX-FileContributor: Nicolas Pillardou, Paloma Martinez
+from pathlib import Path
import numpy as np
-from geos.geomechanics.model.StressTensor import StressTensor
+import numpy.typing as npt
+from typing_extensions import Self, Any
from scipy.spatial import cKDTree
+from xml.etree.ElementTree import ElementTree, Element, SubElement
+import pyvista as pv
+
+from geos.geomechanics.model.StressTensor import StressTensor
+from geos.geomechanics.model.FaultStabilityAnalysis import Config
+
# ============================================================================
# STRESS PROJECTION
# ============================================================================
class StressProjector:
- """Projects volume stress onto fault surfaces and tracks principal stresses in VTU"""
+ """Projects volume stress onto fault surfaces and tracks principal stresses in VTU."""
# -------------------------------------------------------------------
- def __init__(self, config, adjacencyMapping, geometricProperties):
- """
- Initialize with pre-computed adjacency mapping and geometric properties
+ def __init__( self: Self, config: Config, adjacencyMapping: dict[ int, list[ pv.DataSet ] ],
+ geometricProperties: dict[ str, Any ] ) -> None:
+ """Initialize with pre-computed adjacency mapping and geometric properties.
Parameters
----------
@@ -32,36 +40,40 @@ def __init__(self, config, adjacencyMapping, geometricProperties):
self.adjacencyMapping = adjacencyMapping
# Store pre-computed geometric properties
- self.volumeCellVolumes = geometricProperties['volumes']
- self.volumeCenters = geometricProperties['centers']
- self.distanceToFault = geometricProperties['distances']
- self.faultTree = geometricProperties['faultTree']
+ self.volumeCellVolumes = geometricProperties[ 'volumes' ]
+ self.volumeCenters = geometricProperties[ 'centers' ]
+ self.distanceToFault = geometricProperties[ 'distances' ]
+ self.faultTree = geometricProperties[ 'faultTree' ]
# Storage for time series metadata
- self.timestepInfo = []
+ self.timestepInfo: list[ dict[ str, Any ] ] = []
# Track which cells to monitor (optional)
- self.monitoredCells = None
+ self.monitoredCells: set[ int ] | None = None
# Output directory for VTU files
- self.vtuOutputDir = None
+ self.vtuOutputDir = Path( self.config.OUTPUT_DIR ) / "principal_stresses"
# -------------------------------------------------------------------
- def setMonitoredCells(self, cellIndices):
- """
- Set specific cells to monitor (optional)
+ def setMonitoredCells( self: Self, cellIndices: list[ int ] | None = None ) -> None:
+ """Set specific cells to monitor (optional).
Parameters:
cellIndices: list of volume cell indices to track
If None, all contributing cells are tracked
"""
- self.monitoredCells = set(cellIndices) if cellIndices is not None else None
+ self.monitoredCells = set( cellIndices ) if cellIndices is not None else None
# -------------------------------------------------------------------
- def projectStressToFault(self, volumeData, volumeInitial, faultSurface,
- time=None, timestep=None, weightingScheme="arithmetic"):
- """
- Project stress and save principal stresses to VTU
+ def projectStressToFault(
+ self: Self,
+ volumeData: pv.UnstructuredGrid,
+ volumeInitial: pv.UnstructuredGrid,
+ faultSurface: pv.PolyData,
+ time: float | None = None,
+ timestep: int | None = None,
+ weightingScheme: str = "arithmetic" ) -> tuple[ pv.PolyData, pv.UnstructuredGrid, pv.UnstructuredGrid ]:
+ """Project stress and save principal stresses to VTU.
Now uses pre-computed geometric properties for efficiency
"""
@@ -69,41 +81,40 @@ def projectStressToFault(self, volumeData, volumeInitial, faultSurface,
biotName = self.config.BIOT_NAME
if stressName not in volumeData.array_names:
- raise ValueError(f"No stress data '{stressName}' in dataset")
+ raise ValueError( f"No stress data '{stressName}' in dataset" )
# =====================================================================
# 1. EXTRACT STRESS DATA
# =====================================================================
- pressure = volumeData["pressure"] / 1e5
- pressureFault = volumeInitial["pressure"] / 1e5
- pressureInitial = volumeInitial["pressure"] / 1e5
- biot = volumeData[biotName]
+ pressure = volumeData[ "pressure" ] / 1e5
+ pressureFault = volumeInitial[ "pressure" ] / 1e5
+ pressureInitial = volumeInitial[ "pressure" ] / 1e5
+ biot = volumeData[ biotName ]
- stressEffective = StressTensor.buildFromArray(volumeData[stressName] / 1e5)
- stressEffectiveInitial = StressTensor.buildFromArray(volumeInitial[stressName] / 1e5)
+ stressEffective = StressTensor.buildFromArray( volumeData[ stressName ] / 1e5 )
+ stressEffectiveInitial = StressTensor.buildFromArray( volumeInitial[ stressName ] / 1e5 )
# Convert effective stress to total stress
- I = np.eye(3)[None, :, :]
- stressTotal = stressEffective - biot[:, None, None] * pressure[:, None, None] * I
- stressTotalInitial = stressEffectiveInitial - biot[:, None, None] * pressureInitial[:, None, None] * I
+ arrI = np.eye( 3 )[ None, :, : ]
+ stressTotal = stressEffective - biot[ :, None, None ] * pressure[ :, None, None ] * arrI
+ stressTotalInitial = stressEffectiveInitial - biot[ :, None, None ] * pressureInitial[ :, None, None ] * arrI
# =====================================================================
# 2. USE PRE-COMPUTED ADJACENCY
# =====================================================================
- mapping = self.adjacencyMapping
+ # mapping = self.adjacencyMapping
# =====================================================================
# 3. PREPARE FAULT GEOMETRY
# =====================================================================
- normals = faultSurface.cell_data["Normals"]
- tangent1 = faultSurface.cell_data["tangent1"]
- tangent2 = faultSurface.cell_data["tangent2"]
+ normals = faultSurface.cell_data[ "Normals" ]
+ tangent1 = faultSurface.cell_data[ "tangent1" ]
+ tangent2 = faultSurface.cell_data[ "tangent2" ]
faultCenters = faultSurface.cell_centers().points
- faultSurface.cell_data['elementCenter'] = faultCenters
+ faultSurface.cell_data[ 'elementCenter' ] = faultCenters
nFault = faultSurface.n_cells
- nVolume = volumeData.n_cells
# =====================================================================
# 4. COMPUTE PRINCIPAL STRESSES FOR CONTRIBUTING CELLS
@@ -112,28 +123,25 @@ def projectStressToFault(self, volumeData, volumeInitial, faultSurface,
# Collect all unique contributing cells
allContributingCells = set()
- for faultIdx, neighbors in mapping.items():
- allContributingCells.update(neighbors['plus'])
- allContributingCells.update(neighbors['minus'])
+ # for _faultIdx, neighbors in mapping.items():
+ for _faultIdx, neighbors in self.adjacencyMapping.items():
+ allContributingCells.update( neighbors[ 'plus' ] )
+ allContributingCells.update( neighbors[ 'minus' ] )
# Filter by monitored cells if specified
if self.monitoredCells is not None:
- cellsToTrack = allContributingCells.intersection(self.monitoredCells)
+ cellsToTrack = allContributingCells.intersection( self.monitoredCells )
else:
cellsToTrack = allContributingCells
- print(f" 📊 Computing principal stresses for {len(cellsToTrack)} contributing cells...")
+ print( f" 📊 Computing principal stresses for {len(cellsToTrack)} contributing cells..." )
# Create mesh with only contributing cells
- contributingMesh = self._createVolumicContribMesh(
- volumeData, faultSurface, cellsToTrack, mapping
- )
-
- # Save to VTU
- if self.vtuOutputDir is None:
- self.vtuOutputDir = Path(self.config.OUTPUT_DIR) / "principal_stresses"
+ contributingMesh = self._createVolumicContribMesh( volumeData, faultSurface, cellsToTrack,
+ self.adjacencyMapping )
+ # contributingMesh = self._createVolumicContribMesh( volumeData, faultSurface, cellsToTrack, mapping )
- self._savePrincipalStressVTU(contributingMesh, time, timestep)
+ self._savePrincipalStressVTU( contributingMesh, time, timestep )
else:
contributingMesh = None
@@ -141,68 +149,72 @@ def projectStressToFault(self, volumeData, volumeInitial, faultSurface,
# =====================================================================
# 6. PROJECT STRESS FOR EACH FAULT CELL
# =====================================================================
- sigmaNArr = np.zeros(nFault)
- tauArr = np.zeros(nFault)
- tauDipArr = np.zeros(nFault)
- tauStrikeArr = np.zeros(nFault)
- deltaSigmaNArr = np.zeros(nFault)
- deltaTauArr = np.zeros(nFault)
- nContributors = np.zeros(nFault, dtype=int)
-
- print(f" 🔄 Projecting stress to {nFault} fault cells...")
- print(f" Weighting scheme: {weightingScheme}")
-
- for faultIdx in range(nFault):
- if faultIdx not in mapping:
+ sigmaNArr = np.zeros( nFault )
+ tauArr = np.zeros( nFault )
+ tauDipArr = np.zeros( nFault )
+ tauStrikeArr = np.zeros( nFault )
+ deltaSigmaNArr = np.zeros( nFault )
+ deltaTauArr = np.zeros( nFault )
+ nContributors = np.zeros( nFault, dtype=int )
+
+ print( f" 🔄 Projecting stress to {nFault} fault cells..." )
+ print( f" Weighting scheme: {weightingScheme}" )
+
+ for faultIdx in range( nFault ):
+ if faultIdx not in self.adjacencyMapping:
continue
- volPlus = mapping[faultIdx]['plus']
- volMinus = mapping[faultIdx]['minus']
+ volPlus = self.adjacencyMapping[ faultIdx ][ 'plus' ]
+ volMinus = self.adjacencyMapping[ faultIdx ][ 'minus' ]
allVol = volPlus + volMinus
+ # for faultIdx in range( nFault ):
+ # if faultIdx not in mapping:
+ # continue
- if len(allVol) == 0:
+ # volPlus = mapping[ faultIdx ][ 'plus' ]
+ # volMinus = mapping[ faultIdx ][ 'minus' ]
+ # allVol = volPlus + volMinus
+
+ if len( allVol ) == 0:
continue
# ===================================================================
# CALCULATE WEIGHTS (using pre-computed properties)
# ===================================================================
- if weightingScheme == 'arithmetic':
- weights = np.ones(len(allVol)) / len(allVol)
-
- elif weightingScheme == 'harmonic':
- weights = np.ones(len(allVol)) / len(allVol)
+ if weightingScheme == 'arithmetic' or weightingScheme == 'harmonic':
+ weights = np.ones( len( allVol ) ) / len( allVol )
elif weightingScheme == 'distance':
# Use pre-computed distances
- dists = np.array([self.distanceToFault[v] for v in allVol])
- dists = np.maximum(dists, 1e-6)
+ dists = np.array( [ self.distanceToFault[ v ] for v in allVol ] )
+ dists = np.maximum( dists, 1e-6 )
invDists = 1.0 / dists
- weights = invDists / np.sum(invDists)
+ weights = invDists / np.sum( invDists )
elif weightingScheme == 'volume':
# Use pre-computed volumes
- vols = np.array([self.volumeCellVolumes[v] for v in allVol])
- weights = vols / np.sum(vols)
+ vols = np.array( [ self.volumeCellVolumes[ v ] for v in allVol ] )
+ weights = vols / np.sum( vols )
elif weightingScheme == 'distanceVolume':
# Use pre-computed volumes and distances
- vols = np.array([self.volumeCellVolumes[v] for v in allVol])
- dists = np.array([self.distanceToFault[v] for v in allVol])
- dists = np.maximum(dists, 1e-6)
+ vols = np.array( [ self.volumeCellVolumes[ v ] for v in allVol ] )
+ dists = np.array( [ self.distanceToFault[ v ] for v in allVol ] )
+ dists = np.maximum( dists, 1e-6 )
weights = vols / dists
- weights = weights / np.sum(weights)
+ weights = weights / np.sum( weights )
elif weightingScheme == 'inverseSquareDistance':
# Use pre-computed distances
- dists = np.array([self.distanceToFault[v] for v in allVol])
- dists = np.maximum(dists, 1e-6)
- invSquareDistance = 1.0 / (dists ** 2)
- weights = invSquareDistance / np.sum(invSquareDistance)
+ dists = np.array( [ self.distanceToFault[ v ] for v in allVol ] )
+ dists = np.maximum( dists, 1e-6 )
+ invSquareDistance = 1.0 / ( dists**2 )
+ weights = invSquareDistance / np.sum( invSquareDistance )
else:
- raise ValueError(f"Unknown weighting scheme: {weightingScheme}")
+ raise ValueError( f"Unknown weighting scheme: {weightingScheme}" )
# ===================================================================
# ACCUMULATE WEIGHTED CONTRIBUTIONS
@@ -215,67 +227,64 @@ def projectStressToFault(self, volumeData, volumeInitial, faultSurface,
deltaSigmaN = 0.0
deltaTau = 0.0
- for volIdx, w in zip(allVol, weights):
+ for volIdx, w in zip( allVol, weights ):
# Total stress (with pressure)
- sigmaFinal = stressTotal[volIdx] + pressureFault[volIdx] * np.eye(3)
- sigmaInit = stressTotalInitial[volIdx] + pressureInitial[volIdx] * np.eye(3)
+ sigmaFinal = stressTotal[ volIdx ] + pressureFault[ volIdx ] * np.eye( 3 )
+ sigmaInit = stressTotalInitial[ volIdx ] + pressureInitial[ volIdx ] * np.eye( 3 )
# Rotate to fault frame
- resFinal = StressTensor.rotateToFaultFrame(
- sigmaFinal, normals[faultIdx], tangent1[faultIdx], tangent2[faultIdx]
- )
+ resFinal = StressTensor.rotateToFaultFrame( sigmaFinal, normals[ faultIdx ], tangent1[ faultIdx ],
+ tangent2[ faultIdx ] )
- resInitial = StressTensor.rotateToFaultFrame(
- sigmaInit, normals[faultIdx], tangent1[faultIdx], tangent2[faultIdx]
- )
+ resInitial = StressTensor.rotateToFaultFrame( sigmaInit, normals[ faultIdx ], tangent1[ faultIdx ],
+ tangent2[ faultIdx ] )
# Accumulate weighted contributions
- sigmaN += w * resFinal['normalStress']
- tau += w * resFinal['shearStress']
- tauDip += w * resFinal['shearDip']
- tauStrike += w * resFinal['shearStrike']
- deltaSigmaN += w * (resFinal['normalStress'] - resInitial['normalStress'])
- deltaTau += w * (resFinal['shearStress'] - resInitial['shearStress'])
-
- sigmaNArr[faultIdx] = sigmaN
- tauArr[faultIdx] = tau
- tauDipArr[faultIdx] = tauDip
- tauStrikeArr[faultIdx] = tauStrike
- deltaSigmaNArr[faultIdx] = deltaSigmaN
- deltaTauArr[faultIdx] = deltaTau
- nContributors[faultIdx] = len(allVol)
+ sigmaN += w * resFinal[ 'normalStress' ]
+ tau += w * resFinal[ 'shearStress' ]
+ tauDip += w * resFinal[ 'shearDip' ]
+ tauStrike += w * resFinal[ 'shearStrike' ]
+ deltaSigmaN += w * ( resFinal[ 'normalStress' ] - resInitial[ 'normalStress' ] )
+ deltaTau += w * ( resFinal[ 'shearStress' ] - resInitial[ 'shearStress' ] )
+
+ sigmaNArr[ faultIdx ] = sigmaN
+ tauArr[ faultIdx ] = tau
+ tauDipArr[ faultIdx ] = tauDip
+ tauStrikeArr[ faultIdx ] = tauStrike
+ deltaSigmaNArr[ faultIdx ] = deltaSigmaN
+ deltaTauArr[ faultIdx ] = deltaTau
+ nContributors[ faultIdx ] = len( allVol )
# =====================================================================
# 7. STORE RESULTS ON FAULT SURFACE
# =====================================================================
- faultSurface.cell_data["sigmaNEffective"] = sigmaNArr
- faultSurface.cell_data["tauEffective"] = tauDipArr
- faultSurface.cell_data["tauStrike"] = tauStrikeArr
- faultSurface.cell_data["tauDip"] = tauDipArr
- faultSurface.cell_data["deltaSigmaNEffective"] = deltaSigmaNArr
- faultSurface.cell_data["deltaTauEffective"] = deltaTauArr
+ faultSurface.cell_data[ "sigmaNEffective" ] = sigmaNArr
+ faultSurface.cell_data[ "tauEffective" ] = tauDipArr
+ faultSurface.cell_data[ "tauStrike" ] = tauStrikeArr
+ faultSurface.cell_data[ "tauDip" ] = tauDipArr
+ faultSurface.cell_data[ "deltaSigmaNEffective" ] = deltaSigmaNArr
+ faultSurface.cell_data[ "deltaTauEffective" ] = deltaTauArr
# =====================================================================
# 8. STATISTICS
# =====================================================================
valid = nContributors > 0
- nValid = np.sum(valid)
+ nValid = np.sum( valid )
- print(f" ✅ Stress projected: {nValid}/{nFault} fault cells ({nValid/nFault*100:.1f}%)")
+ print( f" ✅ Stress projected: {nValid}/{nFault} fault cells ({nValid/nFault*100:.1f}%)" )
- if np.sum(valid) > 0:
- print(f" Contributors per fault cell: min={np.min(nContributors[valid])}, "
- f"max={np.max(nContributors[valid])}, "
- f"mean={np.mean(nContributors[valid]):.1f}")
+ if np.sum( valid ) > 0:
+ print( f" Contributors per fault cell: min={np.min(nContributors[valid])}, "
+ f"max={np.max(nContributors[valid])}, "
+ f"mean={np.mean(nContributors[valid]):.1f}" )
return faultSurface, volumeData, contributingMesh
# -------------------------------------------------------------------
@staticmethod
- def computePrincipalStresses(stressTensor):
- """
- Compute principal stresses and directions
+ def computePrincipalStresses( stressTensor: StressTensor ) -> dict[ str, npt.NDArray[ np.float64 ] ]:
+ """Compute principal stresses and directions.
Convention: Compression is NEGATIVE
- σ1 = most compressive (most negative)
@@ -284,30 +293,30 @@ def computePrincipalStresses(stressTensor):
Returns:
dict with eigenvalues, eigenvectors, meanStress, deviatoricStress
"""
- eigenvalues, eigenvectors = np.linalg.eigh(stressTensor)
+ eigenvalues, eigenvectors = np.linalg.eigh( stressTensor )
# Sort from MOST NEGATIVE to LEAST NEGATIVE (most compressive to least)
# Example: -600 < -450 < -200, so -600 is σ1 (most compressive)
- idx = np.argsort(eigenvalues) # Ascending order (most negative first)
- eigenvaluesSorted = eigenvalues[idx]
- eigenVectorsSorted = eigenvectors[:, idx]
+ idx = np.argsort( eigenvalues ) # Ascending order (most negative first)
+ eigenvaluesSorted = eigenvalues[ idx ]
+ eigenVectorsSorted = eigenvectors[ :, idx ]
return {
- 'sigma1': eigenvaluesSorted[0], # Most compressive (most negative)
- 'sigma2': eigenvaluesSorted[1], # Intermediate
- 'sigma3': eigenvaluesSorted[2], # Least compressive (least negative)
- 'meanStress': np.mean(eigenvaluesSorted),
- 'deviatoricStress': eigenvaluesSorted[0] - eigenvaluesSorted[2], # σ1 - σ3 (negative - more negative = positive or less negative)
- 'direction1': eigenVectorsSorted[:, 0], # Direction of σ1
- 'direction2': eigenVectorsSorted[:, 1], # Direction of σ2
- 'direction3': eigenVectorsSorted[:, 2] # Direction of σ3
+ 'sigma1': eigenvaluesSorted[ 0 ], # Most compressive (most negative)
+ 'sigma2': eigenvaluesSorted[ 1 ], # Intermediate
+ 'sigma3': eigenvaluesSorted[ 2 ], # Least compressive (least negative)
+ 'meanStress': np.mean( eigenvaluesSorted ),
+ 'deviatoricStress': eigenvaluesSorted[ 0 ] -
+ eigenvaluesSorted[ 2 ], # σ1 - σ3 (negative - more negative = positive or less negative)
+ 'direction1': eigenVectorsSorted[ :, 0 ], # Direction of σ1
+ 'direction2': eigenVectorsSorted[ :, 1 ], # Direction of σ2
+ 'direction3': eigenVectorsSorted[ :, 2 ] # Direction of σ3
}
# -------------------------------------------------------------------
- def _createVolumicContribMesh(self, volumeData, faultSurface, cellsToTrack, mapping):
- """
- Create a mesh containing only contributing cells with principal stress data
- and compute analytical normal/shear stresses based on fault dip angle
+ def _createVolumicContribMesh( self: Self, volumeData: pv.UnstructuredGrid, faultSurface: pv.PolyData,
+ cellsToTrack: set[ int ], mapping: dict[ int, list[ pv.DataSet ] ] ) -> pv.DataSet:
+ """Create a mesh containing only contributing cells with principal stress data and compute analytical normal/shear stresses based on fault dip angle.
Parameters
----------
@@ -320,7 +329,6 @@ def _createVolumicContribMesh(self, volumeData, faultSurface, cellsToTrack, mapp
mapping : dict
Adjacency mapping {faultIdx: {'plus': [...], 'minus': [...]}}
"""
-
# ===================================================================
# EXTRACT STRESS DATA FROM VOLUME
# ===================================================================
@@ -328,180 +336,178 @@ def _createVolumicContribMesh(self, volumeData, faultSurface, cellsToTrack, mapp
biotName = self.config.BIOT_NAME
if stressName not in volumeData.array_names:
- raise ValueError(f"No stress data '{stressName}' in volume dataset")
+ raise ValueError( f"No stress data '{stressName}' in volume dataset" )
- print(f" 📊 Extracting stress from field: '{stressName}'")
+ print( f" 📊 Extracting stress from field: '{stressName}'" )
# Extract effective stress and pressure
- pressure = volumeData["pressure"] / 1e5 # Convert to bar
- biot = volumeData[biotName]
+ pressure = volumeData[ "pressure" ] / 1e5 # Convert to bar
+ biot = volumeData[ biotName ]
- stressEffective = StressTensor.buildFromArray(volumeData[stressName] / 1e5)
+ stressEffective = StressTensor.buildFromArray( volumeData[ stressName ] / 1e5 )
# Convert effective stress to total stress
- I = np.eye(3)[None, :, :]
- stressTotal = stressEffective - biot[:, None, None] * pressure[:, None, None] * I
+ arrI = np.eye( 3 )[ None, :, : ]
+ stressTotal = stressEffective - biot[ :, None, None ] * pressure[ :, None, None ] * arrI
# ===================================================================
# EXTRACT SUBSET OF CELLS
# ===================================================================
- cellIndices = sorted(list(cellsToTrack))
- cellMask = np.zeros(volumeData.n_cells, dtype=bool)
- cellMask[cellIndices] = True
+ cellIndices = sorted( cellsToTrack )
+ cellMask = np.zeros( volumeData.n_cells, dtype=bool )
+ cellMask[ cellIndices ] = True
- subsetMesh = volumeData.extract_cells(cellMask)
+ subsetMesh = volumeData.extract_cells( cellMask )
# ===================================================================
# REBUILD MAPPING: subsetIdx -> originalIdx
# ===================================================================
- originalCenters = volumeData.cell_centers().points[cellIndices]
+ originalCenters = volumeData.cell_centers().points[ cellIndices ]
subsetCenters = subsetMesh.cell_centers().points
- tree = cKDTree(originalCenters)
+ tree = cKDTree( originalCenters )
- subsetToOriginal = np.zeros(subsetMesh.n_cells, dtype=int)
- for subsetIdx in range(subsetMesh.n_cells):
- dist, idx = tree.query(subsetCenters[subsetIdx])
+ subsetToOriginal = np.zeros( subsetMesh.n_cells, dtype=int )
+ for subsetIdx in range( subsetMesh.n_cells ):
+ dist, idx = tree.query( subsetCenters[ subsetIdx ] )
if dist > 1e-6:
- print(f" WARNING: Cell {subsetIdx} not matched (dist={dist})")
- subsetToOriginal[subsetIdx] = cellIndices[idx]
+ print( f" WARNING: Cell {subsetIdx} not matched (dist={dist})" )
+ subsetToOriginal[ subsetIdx ] = cellIndices[ idx ]
# ===================================================================
# MAP VOLUME CELLS TO FAULT DIP/STRIKE ANGLES
# ===================================================================
- print(f" 📐 Mapping volume cells to fault dip/strike angles...")
+ print( " 📐 Mapping volume cells to fault dip/strike angles..." )
# Check if fault surface has required data
if 'dipAngle' not in faultSurface.cell_data:
- print(f" ⚠️ WARNING: 'dipAngle' not found in faultSurface")
- print(f" Available fields: {list(faultSurface.cell_data.keys())}")
+ print( " ⚠️ WARNING: 'dipAngle' not found in faultSurface" )
+ print( f" Available fields: {list(faultSurface.cell_data.keys())}" )
return None
if 'strikeAngle' not in faultSurface.cell_data:
- print(f" ⚠️ WARNING: 'strikeAngle' not found in faultSurface")
+ print( " ⚠️ WARNING: 'strikeAngle' not found in faultSurface" )
# Create mapping: volume_cell_id -> [dip_angles, strike_angles]
- volumeToDip = {}
- volumeToStrike = {}
+ volumeToDip: dict[ int, npt.NDArray[ np.float64 ] ] = {}
+ volumeToStrike: dict[ int, npt.NDArray[ np.float64 ] ] = {}
for faultIdx, neighbors in mapping.items():
# Get dip and strike angle from fault cell
- faultDip = faultSurface.cell_data['dipAngle'][faultIdx]
+ faultDip = faultSurface.cell_data[ 'dipAngle' ][ faultIdx ]
# Strike is optional
if 'strikeAngle' in faultSurface.cell_data:
- faultStrike = faultSurface.cell_data['strikeAngle'][faultIdx]
+ faultStrike = faultSurface.cell_data[ 'strikeAngle' ][ faultIdx ]
else:
faultStrike = np.nan
# Assign to all contributing volume cells (plus and minus)
- for volIdx in neighbors['plus'] + neighbors['minus']:
+ for volIdx in neighbors[ 'plus' ] + neighbors[ 'minus' ]:
if volIdx not in volumeToDip:
- volumeToDip[volIdx] = []
- volumeToStrike[volIdx] = []
- volumeToDip[volIdx].append(faultDip)
- volumeToStrike[volIdx].append(faultStrike)
+ volumeToDip[ volIdx ] = []
+ volumeToStrike[ volIdx ] = []
+ volumeToDip[ volIdx ].append( faultDip )
+ volumeToStrike[ volIdx ].append( faultStrike )
# Average if a volume cell contributes to multiple fault cells
- volumeToDipAvg = {volIdx: np.mean(dips)
- for volIdx, dips in volumeToDip.items()}
- volumeToStrikeAvg = {volIdx: np.mean(strikes)
- for volIdx, strikes in volumeToStrike.items()}
+ volumeToDipAvg = { volIdx: np.mean( dips ) for volIdx, dips in volumeToDip.items() }
+ volumeToStrikeAvg = { volIdx: np.mean( strikes ) for volIdx, strikes in volumeToStrike.items() }
- print(f" ✅ Mapped {len(volumeToDipAvg)} volume cells to fault angles")
+ print( f" ✅ Mapped {len(volumeToDipAvg)} volume cells to fault angles" )
# Statistics
- allDips = [np.mean(dips) for dips in volumeToDip.values()]
- if len(allDips) > 0:
- print(f" Dip angle range: [{np.min(allDips):.1f}, {np.max(allDips):.1f}]°")
+ allDips = [ np.mean( dips ) for dips in volumeToDip.values() ]
+ if len( allDips ) > 0:
+ print( f" Dip angle range: [{np.min(allDips):.1f}, {np.max(allDips):.1f}]°" )
# ===================================================================
# COMPUTE PRINCIPAL STRESSES AND ANALYTICAL FAULT STRESSES
# ===================================================================
- n_cells = subsetMesh.n_cells
+ nCells = subsetMesh.n_cells
- sigma1Arr = np.zeros(nCells)
- sigma2Arr = np.zeros(nCells)
- sigma3Arr = np.zeros(nCells)
- meanStressArr = np.zeros(nCells)
- deviatoricStressArr = np.zeros(nCells)
- pressureArr = np.zeros(nCells)
+ sigma1Arr = np.zeros( nCells )
+ sigma2Arr = np.zeros( nCells )
+ sigma3Arr = np.zeros( nCells )
+ meanStressArr = np.zeros( nCells )
+ deviatoricStressArr = np.zeros( nCells )
+ pressureArr = np.zeros( nCells )
- direction1Arr = np.zeros((nCells, 3))
- direction2Arr = np.zeros((nCells, 3))
- direction3Arr = np.zeros((nCells, 3))
+ direction1Arr = np.zeros( ( nCells, 3 ) )
+ direction2Arr = np.zeros( ( nCells, 3 ) )
+ direction3Arr = np.zeros( ( nCells, 3 ) )
# NEW: Analytical fault stresses
- sigmaNAnalyticalArr = np.zeros(nCells)
- tauAnalyticalArr = np.zeros(nCells)
- dipAngleArr = np.zeros(nCells)
- strikeAngleArr = np.zeros(nCells)
- deltaArr = np.zeros(nCells)
+ sigmaNAnalyticalArr = np.zeros( nCells )
+ tauAnalyticalArr = np.zeros( nCells )
+ dipAngleArr = np.zeros( nCells )
+ strikeAngleArr = np.zeros( nCells )
+ deltaArr = np.zeros( nCells )
- sideArr = np.zeros(nCells, dtype=int)
- nFaultCellsArr = np.zeros(nCells, dtype=int)
+ sideArr = np.zeros( nCells, dtype=int )
+ nFaultCellsArr = np.zeros( nCells, dtype=int )
- print(f" 🔢 Computing principal stresses and analytical projections...")
+ print( " 🔢 Computing principal stresses and analytical projections..." )
- for subsetIdx in range(nCells):
- origIdx = subsetToOriginal[subsetIdx]
+ for subsetIdx in range( nCells ):
+ origIdx = subsetToOriginal[ subsetIdx ]
# ===============================================================
# COMPUTE PRINCIPAL STRESSES
# ===============================================================
# Total stress = effective stress + pore pressure
- sigmaTotalCell = stressTotal[origIdx] + pressure[origIdx] * np.eye(3)
- principal = self.computePrincipalStresses(sigmaTotalCell)
+ sigmaTotalCell = stressTotal[ origIdx ] + pressure[ origIdx ] * np.eye( 3 )
+ principal = self.computePrincipalStresses( sigmaTotalCell )
- sigma1Arr[subsetIdx] = principal['sigma1']
- sigma2Arr[subsetIdx] = principal['sigma2']
- sigma3Arr[subsetIdx] = principal['sigma3']
- meanStressArr[subsetIdx] = principal['meanStress']
- deviatoricStressArr[subsetIdx] = principal['deviatoricStress']
- pressureArr[subsetIdx] = pressure[origIdx]
+ sigma1Arr[ subsetIdx ] = principal[ 'sigma1' ]
+ sigma2Arr[ subsetIdx ] = principal[ 'sigma2' ]
+ sigma3Arr[ subsetIdx ] = principal[ 'sigma3' ]
+ meanStressArr[ subsetIdx ] = principal[ 'meanStress' ]
+ deviatoricStressArr[ subsetIdx ] = principal[ 'deviatoricStress' ]
+ pressureArr[ subsetIdx ] = pressure[ origIdx ]
- direction1Arr[subsetIdx] = principal['direction1']
- direction2Arr[subsetIdx] = principal['direction2']
- direction3Arr[subsetIdx] = principal['direction3']
+ direction1Arr[ subsetIdx ] = principal[ 'direction1' ]
+ direction2Arr[ subsetIdx ] = principal[ 'direction2' ]
+ direction3Arr[ subsetIdx ] = principal[ 'direction3' ]
# ===============================================================
# COMPUTE ANALYTICAL FAULT STRESSES (Anderson formulas)
# ===============================================================
if origIdx in volumeToDipAvg:
- dipDeg = volumeToDipAvg[origIdx]
- dipAngleArr[subsetIdx] = dipDeg
+ dipDeg = volumeToDipAvg[ origIdx ]
+ dipAngleArr[ subsetIdx ] = dipDeg
- strikeDeg = volumeToStrikeAvg.get(origIdx, np.nan)
- strikeAngleArr[subsetIdx] = strikeDeg
+ strikeDeg = volumeToStrikeAvg.get( origIdx, np.nan )
+ strikeAngleArr[ subsetIdx ] = strikeDeg
# δ = 90° - dip (angle from horizontal)
deltaDeg = 90.0 - dipDeg
- deltaRad = np.radians(deltaDeg)
- deltaArr[subsetIdx] = deltaDeg
+ deltaRad = np.radians( deltaDeg )
+ deltaArr[ subsetIdx ] = deltaDeg
# Extract principal stresses (compression negative)
- sigma1 = principal['sigma1'] # Most compressive (most negative)
- sigma3 = principal['sigma3'] # Least compressive (least negative)
+ sigma1 = principal[ 'sigma1' ] # Most compressive (most negative)
+ sigma3 = principal[ 'sigma3' ] # Least compressive (least negative)
# Anderson formulas (1951)
# σ_n = (σ1 + σ3)/2 - (σ1 - σ3)/2 * cos(2δ)
# τ = |(σ1 - σ3)/2 * sin(2δ)|
- sigmaMean = (sigma1 + sigma3) / 2.0
- sigmaDiff = (sigma1 - sigma3) / 2.0
+ sigmaMean = ( sigma1 + sigma3 ) / 2.0
+ sigmaDiff = ( sigma1 - sigma3 ) / 2.0
- sigmaNAnalytical = sigmaMean - sigmaDiff * np.cos(2 * deltaRad)
- tauAnalytical = sigmaDiff * np.sin(2 * deltaRad)
+ sigmaNAnalytical = sigmaMean - sigmaDiff * np.cos( 2 * deltaRad )
+ tauAnalytical = sigmaDiff * np.sin( 2 * deltaRad )
- sigmaNAnalyticalArr[subsetIdx] = sigmaNAnalytical
- tauAnalyticalArr[subsetIdx] = np.abs(tauAnalytical)
+ sigmaNAnalyticalArr[ subsetIdx ] = sigmaNAnalytical
+ tauAnalyticalArr[ subsetIdx ] = np.abs( tauAnalytical )
else:
# No fault association - set to NaN
- dipAngleArr[subsetIdx] = np.nan
- strikeAngleArr[subsetIdx] = np.nan
- deltaArr[subsetIdx] = np.nan
- sigmaNAnalyticalArr[subsetIdx] = np.nan
- tauAnalyticalArr[subsetIdx] = np.nan
+ dipAngleArr[ subsetIdx ] = np.nan
+ strikeAngleArr[ subsetIdx ] = np.nan
+ deltaArr[ subsetIdx ] = np.nan
+ sigmaNAnalyticalArr[ subsetIdx ] = np.nan
+ tauAnalyticalArr[ subsetIdx ] = np.nan
# ===============================================================
# DETERMINE SIDE (plus/minus/both)
@@ -510,51 +516,51 @@ def _createVolumicContribMesh(self, volumeData, faultSurface, cellsToTrack, mapp
isMinus = False
faultCellCount = 0
- for faultIdx, neighbors in mapping.items():
- if origIdx in neighbors['plus']:
+ for _faultIdx, neighbors in mapping.items():
+ if origIdx in neighbors[ 'plus' ]:
isPlus = True
faultCellCount += 1
- if origIdx in neighbors['minus']:
+ if origIdx in neighbors[ 'minus' ]:
isMinus = True
faultCellCount += 1
if isPlus and isMinus:
- sideArr[subsetIdx] = 3 # both
+ sideArr[ subsetIdx ] = 3 # both
elif isPlus:
- sideArr[subsetIdx] = 1 # plus
+ sideArr[ subsetIdx ] = 1 # plus
elif isMinus:
- sideArr[subsetIdx] = 2 # minus
+ sideArr[ subsetIdx ] = 2 # minus
else:
- sideArr[subsetIdx] = 0 # none (should not happen)
+ sideArr[ subsetIdx ] = 0 # none (should not happen)
- nFaultCellsArr[subsetIdx] = faultCellCount
+ nFaultCellsArr[ subsetIdx ] = faultCellCount
# ===================================================================
# ADD DATA TO MESH
# ===================================================================
- subsetMesh.cell_data['sigma1'] = sigma1Arr
- subsetMesh.cell_data['sigma2'] = sigma2Arr
- subsetMesh.cell_data['sigma3'] = sigma3Arr
- subsetMesh.cell_data['meanStress'] = meanStressArr
- subsetMesh.cell_data['deviatoricStress'] = deviatoricStressArr
- subsetMesh.cell_data['pressure_bar'] = pressureArr
+ subsetMesh.cell_data[ 'sigma1' ] = sigma1Arr
+ subsetMesh.cell_data[ 'sigma2' ] = sigma2Arr
+ subsetMesh.cell_data[ 'sigma3' ] = sigma3Arr
+ subsetMesh.cell_data[ 'meanStress' ] = meanStressArr
+ subsetMesh.cell_data[ 'deviatoricStress' ] = deviatoricStressArr
+ subsetMesh.cell_data[ 'pressure_bar' ] = pressureArr
- subsetMesh.cell_data['sigma1Direction'] = direction1Arr
- subsetMesh.cell_data['sigma2Direction'] = direction2Arr
- subsetMesh.cell_data['sigma3Direction'] = direction3Arr
+ subsetMesh.cell_data[ 'sigma1Direction' ] = direction1Arr
+ subsetMesh.cell_data[ 'sigma2Direction' ] = direction2Arr
+ subsetMesh.cell_data[ 'sigma3Direction' ] = direction3Arr
# Analytical fault stresses
- subsetMesh.cell_data['sigmaNAnalytical'] = sigmaNAnalyticalArr
- subsetMesh.cell_data['tauAnalytical'] = tauAnalyticalArr
- subsetMesh.cell_data['dipAngle'] = dipAngleArr
- subsetMesh.cell_data['strikeAngle'] = strikeAngleArr
- subsetMesh.cell_data['deltaAngle'] = deltaArr
+ subsetMesh.cell_data[ 'sigmaNAnalytical' ] = sigmaNAnalyticalArr
+ subsetMesh.cell_data[ 'tauAnalytical' ] = tauAnalyticalArr
+ subsetMesh.cell_data[ 'dipAngle' ] = dipAngleArr
+ subsetMesh.cell_data[ 'strikeAngle' ] = strikeAngleArr
+ subsetMesh.cell_data[ 'deltaAngle' ] = deltaArr
# ===================================================================
# COMPUTE SCU ANALYTICALLY (Mohr-Coulomb)
# ===================================================================
- if hasattr(self.config, 'FRICTION_ANGLE') and hasattr(self.config, 'COHESION'):
- mu = np.tan(np.radians(self.config.FRICTION_ANGLE))
+ if hasattr( self.config, 'FRICTION_ANGLE' ) and hasattr( self.config, 'COHESION' ):
+ mu = np.tan( np.radians( self.config.FRICTION_ANGLE ) )
cohesion = self.config.COHESION
# τ_crit = C - σ_n * μ
@@ -562,51 +568,51 @@ def _createVolumicContribMesh(self, volumeData, faultSurface, cellsToTrack, mapp
tauCriticalArr = cohesion - sigmaNAnalyticalArr * mu
# SCU = τ / τ_crit
- SCUAnalyticalArr = np.divide(
- tauAnalyticalArr,
- tauCriticalArr,
- out=np.zeros_like(tauAnalyticalArr),
- where=tauCriticalArr != 0
- )
+ SCUAnalyticalArr = np.divide( tauAnalyticalArr,
+ tauCriticalArr,
+ out=np.zeros_like( tauAnalyticalArr ),
+ where=tauCriticalArr != 0 )
- subsetMesh.cell_data['tauCriticalAnalytical'] = tauCriticalArr
- subsetMesh.cell_data['SCUAnalytical'] = SCUAnalyticalArr
+ subsetMesh.cell_data[ 'tauCriticalAnalytical' ] = tauCriticalArr
+ subsetMesh.cell_data[ 'SCUAnalytical' ] = SCUAnalyticalArr
# CFS (Coulomb Failure Stress)
- CFSAnalyticalArr = tauAnalyticalArr - mu * (-sigmaNAnalyticalArr)
- subsetMesh.cell_data['CFSAnalytical'] = CFSAnalyticalArr
+ CFSAnalyticalArr = tauAnalyticalArr - mu * ( -sigmaNAnalyticalArr )
+ subsetMesh.cell_data[ 'CFSAnalytical' ] = CFSAnalyticalArr
- subsetMesh.cell_data['side'] = sideArr
- subsetMesh.cell_data['nFaultCells'] = nFaultCellsArr
- subsetMesh.cell_data['originalCellId'] = subsetToOriginal
+ subsetMesh.cell_data[ 'side' ] = sideArr
+ subsetMesh.cell_data[ 'nFaultCells' ] = nFaultCellsArr
+ subsetMesh.cell_data[ 'originalCellId' ] = subsetToOriginal
# ===================================================================
# STATISTICS
# ===================================================================
- validAnalytical = ~np.isnan(sigmaNAnalyticalArr)
- nValid = np.sum(validAnalytical)
+ validAnalytical = ~np.isnan( sigmaNAnalyticalArr )
+ nValid = np.sum( validAnalytical )
if nValid > 0:
- print(f" 📊 Analytical fault stresses computed for {nValid}/{nCells} cells")
- print(f" σ_n range: [{np.nanmin(sigmaNAnalyticalArr):.1f}, {np.nanmax(sigmaNAnalyticalArr):.1f}] bar")
- print(f" τ range: [{np.nanmin(tauAnalyticalArr):.1f}, {np.nanmax(tauAnalyticalArr):.1f}] bar")
- print(f" Dip angle range: [{np.nanmin(dipAngleArr):.1f}, {np.nanmax(dipAngleArr):.1f}]°")
-
- if hasattr(self.config, 'FRICTION_ANGLE') and hasattr(self.config, 'COHESION'):
- print(f" SCU range: [{np.nanmin(SCUAnalyticalArr[validAnalytical]):.2f}, {np.nanmax(SCUAnalyticalArr[validAnalytical]):.2f}]")
- nCritical = np.sum((SCUAnalyticalArr >= 0.8) & (SCUAnalyticalArr < 1.0))
- nUnstable = np.sum(SCUAnalyticalArr >= 1.0)
- print(f" Critical cells (SCU≥0.8): {nCritical} ({nCritical/nValid*100:.1f}%)")
- print(f" Unstable cells (SCU≥1.0): {nUnstable} ({nUnstable/nValid*100:.1f}%)")
+ print( f" 📊 Analytical fault stresses computed for {nValid}/{nCells} cells" )
+ print(
+ f" σ_n range: [{np.nanmin(sigmaNAnalyticalArr):.1f}, {np.nanmax(sigmaNAnalyticalArr):.1f}] bar" )
+ print( f" τ range: [{np.nanmin(tauAnalyticalArr):.1f}, {np.nanmax(tauAnalyticalArr):.1f}] bar" )
+ print( f" Dip angle range: [{np.nanmin(dipAngleArr):.1f}, {np.nanmax(dipAngleArr):.1f}]°" )
+
+ if hasattr( self.config, 'FRICTION_ANGLE' ) and hasattr( self.config, 'COHESION' ):
+ print(
+ f" SCU range: [{np.nanmin(SCUAnalyticalArr[validAnalytical]):.2f}, {np.nanmax(SCUAnalyticalArr[validAnalytical]):.2f}]"
+ )
+ nCritical = np.sum( ( SCUAnalyticalArr >= 0.8 ) & ( SCUAnalyticalArr < 1.0 ) )
+ nUnstable = np.sum( SCUAnalyticalArr >= 1.0 )
+ print( f" Critical cells (SCU≥0.8): {nCritical} ({nCritical/nValid*100:.1f}%)" )
+ print( f" Unstable cells (SCU≥1.0): {nUnstable} ({nUnstable/nValid*100:.1f}%)" )
else:
- print(f" ⚠️ No analytical stresses computed (no fault mapping)")
+ print( " ⚠️ No analytical stresses computed (no fault mapping)" )
return subsetMesh
# -------------------------------------------------------------------
- def _savePrincipalStressVTU(self, mesh, time, timestep):
- """
- Save principal stress mesh to VTU file
+ def _savePrincipalStressVTU( self: Self, mesh: pv.DataSet, time: float, timestep: int ) -> None:
+ """Save principal stress mesh to VTU file.
Parameters:
mesh: PyVista mesh with principal stress data
@@ -614,65 +620,63 @@ def _savePrincipalStressVTU(self, mesh, time, timestep):
timestep: Timestep index
"""
# Create output directory
- self.vtuOutputDir.mkdir(parents=True, exist_ok=True)
+ self.vtuOutputDir.mkdir( parents=True, exist_ok=True )
# Generate filename
vtuFilename = f"principal_stresses_{timestep:05d}.vtu"
vtuPath = self.vtuOutputDir / vtuFilename
# Save mesh
- mesh.save(str(vtuPath))
+ mesh.save( str( vtuPath ) )
# Store metadata for PVD
- self.timestepInfo.append({
+ self.timestepInfo.append( {
'time': time if time is not None else timestep,
'timestep': timestep,
'file': vtuFilename
- })
+ } )
- print(f" 💾 Saved principal stresses: {vtuFilename}")
+ print( f" 💾 Saved principal stresses: {vtuFilename}" )
# -------------------------------------------------------------------
- def savePVDCollection(self, filename="principal_stresses.pvd"):
- """
- Create PVD file for time series visualization in ParaView
+ def savePVDCollection( self: Self, filename: str = "principal_stresses.pvd" ) -> None:
+ """Create PVD file for time series visualization in ParaView.
Parameters:
filename: Name of PVD file
"""
- if len(self.timestepInfo) == 0:
- print("⚠️ No timestep data to save in PVD")
+ if len( self.timestepInfo ) == 0:
+ print( "⚠️ No timestep data to save in PVD" )
return
pvdPath = self.vtuOutputDir / filename
- print(f"\n💾 Creating PVD collection: {pvdPath}")
- print(f" Timesteps: {len(self.timestepInfo)}")
+ print( f"\n💾 Creating PVD collection: {pvdPath}" )
+ print( f" Timesteps: {len(self.timestepInfo)}" )
# Create XML structure
- root = Element('VTKFile')
- root.set('type', 'Collection')
- root.set('version', '0.1')
- root.set('byte_order', 'LittleEndian')
+ root = Element( 'VTKFile' )
+ root.set( 'type', 'Collection' )
+ root.set( 'version', '0.1' )
+ root.set( 'byte_order', 'LittleEndian' )
- collection = SubElement(root, 'Collection')
+ collection = SubElement( root, 'Collection' )
for info in self.timestepInfo:
- dataset = SubElement(collection, 'DataSet')
- dataset.set('timestep', str(info['time']))
- dataset.set('group', '')
- dataset.set('part', '0')
- dataset.set('file', info['file'])
+ dataset = SubElement( collection, 'DataSet' )
+ dataset.set( 'timestep', str( info[ 'time' ] ) )
+ dataset.set( 'group', '' )
+ dataset.set( 'part', '0' )
+ dataset.set( 'file', info[ 'file' ] )
# Write to file
- tree = ElementTree(root)
- tree.write(str(pvdPath), encoding='utf-8', xml_declaration=True)
-
- print(f" ✅ PVD file created successfully")
- print(f" 📂 Output directory: {self.vtuOutputDir}")
- print(f"\n 🎨 To visualize in ParaView:")
- print(f" 1. Open: {pvdPath}")
- print(f" 2. Apply")
- print(f" 3. Color by: sigma1, sigma2, sigma3, meanStress, etc.")
- print(f" 4. Use 'side' filter to show plus/minus/both")
-
+ tree = ElementTree( root )
+ tree.write( str( pvdPath ), encoding='utf-8', xml_declaration=True )
+
+ print( " ✅ PVD file created successfully" )
+ print( f" 📂 Output directory: {self.vtuOutputDir}" )
+ print( "\n 🎨 To visualize in ParaView:" )
+ print( f" 1. Open: {pvdPath}" )
+ print( " 2. Apply" )
+ print( " 3. Color by: sigma1, sigma2, sigma3, meanStress, etc." )
+ print( " 4. Use 'side' filter to show plus/minus/both" )
diff --git a/geos-processing/src/geos/processing/tools/FaultVisualizer.py b/geos-processing/src/geos/processing/tools/FaultVisualizer.py
index b0b85a55..b0544dc5 100644
--- a/geos-processing/src/geos/processing/tools/FaultVisualizer.py
+++ b/geos-processing/src/geos/processing/tools/FaultVisualizer.py
@@ -1,63 +1,81 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright 2023-2026 TotalEnergies.
+# SPDX-FileContributor: Nicolas Pillardou, Paloma Martinez
+import os
import pandas as pd
+import numpy as np
+import numpy.typing as npt
+from pathlib import Path
from matplotlib.lines import Line2D
+import matplotlib.pyplot as plt
+import pyvista as pv
+from typing_extensions import Self
+
+from geos.processing.post_processing.ProfileExtractor import ProfileExtractor
+from geos.processing.post_processing.FaultStabilityAnalysis import Config
+
+
# ============================================================================
# VISUALIZATION
# ============================================================================
class Visualizer:
- """Visualization utilities"""
+ """Visualization utilities."""
# -------------------------------------------------------------------
- def __init__(self, config):
+ def __init__( self, config: Config ) -> None:
+ """Init."""
self.config = config
# -------------------------------------------------------------------
@staticmethod
- def plotMohrCoulombDiagram(surface, time, path, show=True, save=True):
- """Create Mohr-Coulomb diagram with depth coloring"""
-
- sigmaN = -surface.cell_data["sigmaNEffective"]
- tau = np.abs(surface.cell_data["tauEffective"])
- SCU = np.abs(surface.cell_data["SCU"])
- depth = surface.cell_data['elementCenter'][:, 2]
-
- cohesion = surface.cell_data["mohrCohesion"][0]
- mu = surface.cell_data["mohrFrictionCoefficient"][0]
- phi = surface.cell_data['mohrFrictionAngle'][0]
-
- fig, axes = plt.subplots(1, 2, figsize=(16, 8))
+ def plotMohrCoulombDiagram( surface: pv.PolyData,
+ time: float,
+ path: Path,
+ show: bool = True,
+ save: bool = True ) -> None:
+ """Create Mohr-Coulomb diagram with depth coloring."""
+ sigmaN = -surface.cell_data[ "sigmaNEffective" ]
+ tau = np.abs( surface.cell_data[ "tauEffective" ] )
+ SCU = np.abs( surface.cell_data[ "SCU" ] )
+ depth = surface.cell_data[ 'elementCenter' ][ :, 2 ]
+
+ cohesion = surface.cell_data[ "mohrCohesion" ][ 0 ]
+ mu = surface.cell_data[ "mohrFrictionCoefficient" ][ 0 ]
+ phi = surface.cell_data[ 'mohrFrictionAngle' ][ 0 ]
+
+ fig, axes = plt.subplots( 1, 2, figsize=( 16, 8 ) )
# Plot 1: τ vs σ_n
- ax1 = axes[0]
- sc1 = ax1.scatter(sigmaN, tau, c=depth, cmap='turbo_r', s=20, alpha=0.8)
- sigmaRange = np.linspace(0, np.max(sigmaN), 100)
+ ax1 = axes[ 0 ]
+ ax1.scatter( sigmaN, tau, c=depth, cmap='turbo_r', s=20, alpha=0.8 )
+ sigmaRange = np.linspace( 0, np.max( sigmaN ), 100 )
tauCritical = cohesion + mu * sigmaRange
- ax1.plot(sigmaRange, tauCritical, 'k--', linewidth=2,
- label=f'M-C (C={cohesion} bar, φ={phi}°)')
- ax1.set_xlabel('Normal Stress [bar]')
- ax1.set_ylabel('Shear Stress [bar]')
+ ax1.plot( sigmaRange, tauCritical, 'k--', linewidth=2, label=f'M-C (C={cohesion} bar, φ={phi}°)' )
+ ax1.set_xlabel( 'Normal Stress [bar]' )
+ ax1.set_ylabel( 'Shear Stress [bar]' )
ax1.legend()
- ax1.grid(True, alpha=0.3)
- ax1.set_title('Mohr-Coulomb Diagram')
+ ax1.grid( True, alpha=0.3 )
+ ax1.set_title( 'Mohr-Coulomb Diagram' )
# Plot 2: SCU vs σ_n
- ax2 = axes[1]
- sc2 = ax2.scatter(sigmaN, SCU, c=depth, cmap='turbo_r', s=20, alpha=0.8)
- ax2.axhline(y=1.0, color='r', linestyle='--', label='Failure (SCU=1)')
- ax2.set_xlabel('Normal Stress [bar]')
- ax2.set_ylabel('SCU [-]')
+ ax2 = axes[ 1 ]
+ sc2 = ax2.scatter( sigmaN, SCU, c=depth, cmap='turbo_r', s=20, alpha=0.8 )
+ ax2.axhline( y=1.0, color='r', linestyle='--', label='Failure (SCU=1)' )
+ ax2.set_xlabel( 'Normal Stress [bar]' )
+ ax2.set_ylabel( 'SCU [-]' )
ax2.legend()
- ax2.grid(True, alpha=0.3)
- ax2.set_title('Shear Capacity Utilization')
- ax2.set_ylim(bottom=0)
+ ax2.grid( True, alpha=0.3 )
+ ax2.set_title( 'Shear Capacity Utilization' )
+ ax2.set_ylim( bottom=0 )
- plt.colorbar(sc2, ax=ax2, label='Depth [m]')
+ plt.colorbar( sc2, ax=ax2, label='Depth [m]' )
plt.tight_layout()
if save:
- years = time / (365.25 * 24 * 3600)
+ years = time / ( 365.25 * 24 * 3600 )
filename = f'mohr_coulomb_phi{phi}_c{cohesion}_{years:.0f}y.png'
- plt.savefig(path / filename, dpi=300, bbox_inches='tight')
- print(f" 📊 Plot saved: {filename}")
+ plt.savefig( path / filename, dpi=300, bbox_inches='tight' )
+ print( f" 📊 Plot saved: {filename}" )
if show:
plt.show()
@@ -66,9 +84,10 @@ def plotMohrCoulombDiagram(surface, time, path, show=True, save=True):
# -------------------------------------------------------------------
@staticmethod
- def loadReferenceData(time, scriptDir=None, profileId=1):
- """
- Load GEOS and analytical reference data for comparison
+ def loadReferenceData( time: float,
+ scriptDir: str | Path | None = None,
+ profileId: int = 1 ) -> dict[ str, npt.NDArray | None ]:
+ """Load GEOS and analytical reference data for comparison.
Parameters
----------
@@ -79,7 +98,7 @@ def loadReferenceData(time, scriptDir=None, profileId=1):
profileId : int, optional
Profile ID to extract from Excel (default: 1)
- Returns
+ Returns:
-------
dict
Dictionary with keys 'geos' and 'analytical', each containing numpy arrays or None
@@ -89,9 +108,9 @@ def loadReferenceData(time, scriptDir=None, profileId=1):
[Depth_m, Normal_Stress_bar, Shear_Stress_bar, SCU, X_coordinate_m, Y_coordinate_m]
"""
if scriptDir is None:
- scriptDir = os.path.dirname(os.path.abspath(__file__))
+ scriptDir = os.path.dirname( os.path.abspath( __file__ ) )
- result = {'geos': None, 'analytical': None}
+ result: dict[ str, None | npt.NDArray ] = { 'geos': None, 'analytical': None }
# ===================================================================
# LOAD GEOS DATA - Try Excel first, then CSV
@@ -101,246 +120,241 @@ def loadReferenceData(time, scriptDir=None, profileId=1):
geosFileCSV = 'geos_data_numerical.csv'
# Try Excel format with time-based sheets
- geosXLSVPath = os.path.join(scriptDir, geosFileXLSV)
+ geosXLSVPath = os.path.join( scriptDir, geosFileXLSV )
- if os.path.exists(geosXLSVPath):
+ if os.path.exists( geosXLSVPath ):
try:
# Generate sheet name based on current time
# Format: t_1.00e+02s
sheetName = f"t_{time:.2e}s"
- print(f" 📂 Loading GEOS data from Excel sheet: '{sheetName}'")
+ print( f" 📂 Loading GEOS data from Excel sheet: '{sheetName}'" )
# Try to read the specific sheet
try:
- df = pd.read_excel(geosXLSVPath, sheet_name=sheetName)
+ df = pd.read_excel( geosXLSVPath, sheet_name=sheetName )
# Filter by ProfileID if column exists
if 'ProfileID' in df.columns:
- dfProfile = df[df['ProfileID'] == profileId]
+ dfProfile = df[ df[ 'ProfileID' ] == profileId ]
- if len(dfProfile) == 0:
- print(f" ⚠️ ProfileID {profileId} not found in sheet '{sheetName}'")
- print(f" Available Profile_IDs: {sorted(df['ProfileID'].unique())}")
+ if len( dfProfile ) == 0:
+ print( f" ⚠️ ProfileID {profileId} not found in sheet '{sheetName}'" )
+ print( f" Available Profile_IDs: {sorted(df['ProfileID'].unique())}" )
# Take first profile as fallback
- availableIds = sorted(df['ProfileID'].unique())
- if len(availableIds) > 0:
- fallbackId = availableIds[0]
- print(f" → Using ProfileID {fallbackId} instead")
- dfProfile = df[df['ProfileID'] == fallbackId]
+ availableIds = sorted( df[ 'ProfileID' ].unique() )
+ if len( availableIds ) > 0:
+ fallbackId = availableIds[ 0 ]
+ print( f" → Using ProfileID {fallbackId} instead" )
+ dfProfile = df[ df[ 'ProfileID' ] == fallbackId ]
else:
- print(f" ✅ Loaded ProfileID {profileId}: {len(dfProfile)} points")
+ print( f" ✅ Loaded ProfileID {profileId}: {len(dfProfile)} points" )
# Extract relevant columns in the expected order
# Expected: [Depth, Normal_Stress, Shear_Stress, SCU, ...]
- columnsToExtract = ['Depth_m', 'Normal_Stress_bar', 'Shear_Stress_bar', 'SCU']
+ columnsToExtract = [ 'Depth_m', 'Normal_Stress_bar', 'Shear_Stress_bar', 'SCU' ]
# Check which columns exist
- availableColumns = [col for col in columnsToExtract if col in dfProfile.columns]
+ availableColumns = [ col for col in columnsToExtract if col in dfProfile.columns ]
- if len(availableColumns) > 0:
- result['geos'] = dfProfile[availableColumns].values
- print(f" Extracted columns: {availableColumns}")
+ if len( availableColumns ) > 0:
+ result[ 'geos' ] = dfProfile[ availableColumns ].values
+ print( f" Extracted columns: {availableColumns}" )
else:
- print(f" ⚠️ No expected columns found in DataFrame")
- print(f" Available columns: {list(dfProfile.columns)}")
+ print( " ⚠️ No expected columns found in DataFrame" )
+ print( f" Available columns: {list(dfProfile.columns)}" )
else:
# No ProfileID column, use all data
- print(f" ℹ️ No ProfileID column, using all data")
- columnsToExtract = ['Depth_m', 'Normal_Stress_bar', 'Shear_Stress_bar', 'SCU']
- availableColumns = [col for col in columnsToExtract if col in df.columns]
+ print( " ℹ️ No ProfileID column, using all data" )
+ columnsToExtract = [ 'Depth_m', 'Normal_Stress_bar', 'Shear_Stress_bar', 'SCU' ]
+ availableColumns = [ col for col in columnsToExtract if col in df.columns ]
- if len(availableColumns) > 0:
- result['geos'] = df[availableColumns].values
- print(f" ✅ Loaded {len(result['geos'])} points")
+ if len( availableColumns ) > 0:
+ result[ 'geos' ] = df[ availableColumns ].values
+ print( f" ✅ Loaded {len(result['geos'])} points" )
except ValueError:
# Sheet not found, try to find closest time
- print(f" ⚠️ Sheet '{sheetName}' not found, searching for closest time...")
+ print( f" ⚠️ Sheet '{sheetName}' not found, searching for closest time..." )
# Read all sheet names
- xlFile = pd.ExcelFile(geosXLSVPath)
+ xlFile = pd.ExcelFile( geosXLSVPath )
sheetNames = xlFile.sheetNames
# Extract times from sheet names
sheetTimes = []
for sname in sheetNames:
- if sname.startswith('t_') and sname.endswith('s'):
+ if sname.startswith( 't_' ) and sname.endswith( 's' ):
try:
# Extract time: t_1.00e+02s -> 100.0
- timeStr = sname[2:-1] # Remove 't_' and 's'
- sheetTime = float(timeStr)
- sheetTimes.append((sheetTime, sname))
- except:
+ timeStr = sname[ 2:-1 ] # Remove 't_' and 's'
+ sheetTime = float( timeStr )
+ sheetTimes.append( ( sheetTime, sname ) )
+ except Exception:
continue
if sheetTimes:
# Find closest time
- sheetTimes.sort(key=lambda x: abs(x[0] - time))
- closestTime, closestSheet = sheetTimes[0]
- timeDiff = abs(closestTime - time)
+ sheetTimes.sort( key=lambda x: abs( x[ 0 ] - time ) )
+ closestTime, closestSheet = sheetTimes[ 0 ]
+ timeDiff = abs( closestTime - time )
- print(f" → Using closest sheet: '{closestSheet}' (Δt={timeDiff:.2e}s)")
- df = pd.read_excel(geosXLSVPath, sheet_name=closestSheet)
+ print( f" → Using closest sheet: '{closestSheet}' (Δt={timeDiff:.2e}s)" )
+ df = pd.read_excel( geosXLSVPath, sheet_name=closestSheet )
# Filter by ProfileID
if 'ProfileID' in df.columns:
- dfProfile = df[df['ProfileID'] == profileId]
+ dfProfile = df[ df[ 'ProfileID' ] == profileId ]
- if len(dfProfile) == 0:
+ if len( dfProfile ) == 0:
# Fallback to first profile
- availableIds = sorted(df['ProfileID'].unique())
- if len(availableIds) > 0:
- dfProfile = df[df['ProfileID'] == availableIds[0]]
- print(f" → Using ProfileID {availableIds[0]}")
-
- columnsToExtract = ['Depth_m', 'Normal_Stress_bar', 'Shear_Stress_bar', 'SCU'] # TODO check
- availableColumns = [col for col in columnsToExtract if col in dfProfile.columns]
-
- if len(availableColumns) > 0:
- result['geos'] = dfProfile[availableColumns].values
- print(f" ✅ Loaded {len(result['geos'])} points")
+ availableIds = sorted( df[ 'ProfileID' ].unique() )
+ if len( availableIds ) > 0:
+ dfProfile = df[ df[ 'ProfileID' ] == availableIds[ 0 ] ]
+ print( f" → Using ProfileID {availableIds[0]}" )
+
+ columnsToExtract = [ 'Depth_m', 'Normal_Stress_bar', 'Shear_Stress_bar',
+ 'SCU' ] # TODO check
+ availableColumns = [ col for col in columnsToExtract if col in dfProfile.columns ]
+
+ if len( availableColumns ) > 0:
+ result[ 'geos' ] = dfProfile[ availableColumns ].values
+ print( f" ✅ Loaded {len(result['geos'])} points" )
else:
# Use all data
- columnsToExtract = ['Depth_m', 'Normal_Stress_bar', 'Shear_Stress_bar', 'SCU']
- availableColumns = [col for col in columnsToExtract if col in df.columns]
+ columnsToExtract = [ 'Depth_m', 'Normal_Stress_bar', 'Shear_Stress_bar', 'SCU' ]
+ availableColumns = [ col for col in columnsToExtract if col in df.columns ]
- if len(availableColumns) > 0:
- result['geos'] = df[availableColumns].values
- print(f" ✅ Loaded {len(result['geos'])} points")
+ if len( availableColumns ) > 0:
+ result[ 'geos' ] = df[ availableColumns ].values
+ print( f" ✅ Loaded {len(result['geos'])} points" )
else:
- print(f" ⚠️ No valid time sheets found in Excel file")
+ print( " ⚠️ No valid time sheets found in Excel file" )
except ImportError:
- print(f" ⚠️ pandas not available, cannot read Excel file")
+ print( " ⚠️ pandas not available, cannot read Excel file" )
except Exception as e:
- print(f" ⚠️ Error reading Excel: {e}")
+ print( f" ⚠️ Error reading Excel: {e}" )
import traceback
traceback.print_exc()
# Fallback to CSV if Excel not found or failed
- if result['geos'] is None:
- geosCSVPath = os.path.join(scriptDir, geosFileCSV)
- if os.path.exists(geosCSVPath):
+ if result[ 'geos' ] is None:
+ geosCSVPath = os.path.join( scriptDir, geosFileCSV )
+ if os.path.exists( geosCSVPath ):
try:
- result['geos'] = np.loadtxt(geosCSVPath, delimiter=',', skiprows=1)
- print(f" ✅ GEOS data loaded from CSV: {len(result['geos'])} points")
+ result[ 'geos' ] = np.loadtxt( geosCSVPath, delimiter=',', skiprows=1 )
+ print( f" ✅ GEOS data loaded from CSV: {len(result['geos'])} points" )
except Exception as e:
- print(f" ⚠️ Error reading CSV: {e}")
+ print( f" ⚠️ Error reading CSV: {e}" )
# ===================================================================
# LOAD ANALYTICAL DATA
# ===================================================================
analyticalFile = 'analyticalData.csv'
- analyticalPath = os.path.join(scriptDir, analyticalFile)
+ analyticalPath = os.path.join( scriptDir, analyticalFile )
- if os.path.exists(analyticalPath):
+ if os.path.exists( analyticalPath ):
try:
- result['analytical'] = np.loadtxt(analyticalPath, delimiter=',', skiprows=1)
- print(f" ✅ Analytical data loaded: {len(result['analytical'])} points")
+ result[ 'analytical' ] = np.loadtxt( analyticalPath, delimiter=',', skiprows=1 )
+ print( f" ✅ Analytical data loaded: {len(result['analytical'])} points" )
except Exception as e:
- print(f" ⚠️ Error loading analytical data: {e}")
+ print( f" ⚠️ Error loading analytical data: {e}" )
return result
# -------------------------------------------------------------------
@staticmethod
- def plotDepthProfiles(self, surface, time, path, show=True, save=True,
- profileStartPoints=None,
- maxProfilePoints=1000,
- referenceProfileId=1
- ):
-
- """
- Plot vertical profiles along the fault showing stress and SCU vs depth
- """
-
- print(" 📊 Creating depth profiles ")
+ def plotDepthProfiles( surface: pv.PolyData,
+ time: float,
+ path: Path,
+ show: bool = True,
+ save: bool = True,
+ profileStartPoints: list[ tuple[ float, float ] ] | None = None,
+ maxProfilePoints: int = 1000,
+ referenceProfileId: int = 1 ) -> None:
+ """Plot vertical profiles along the fault showing stress and SCU vs depth."""
+ print( " 📊 Creating depth profiles " )
# Extract data
- centers = surface.cell_data['elementCenter']
- depth = centers[:, 2]
- sigmaN = surface.cell_data['sigmaNEffective']
- tau = surface.cell_data['tauEffective']
- SCU = surface.cell_data['SCU']
- SCU = np.sqrt(SCU**2)
- deltaSCU = surface.cell_data['deltaSCU']
+ centers = surface.cell_data[ 'elementCenter' ]
+ depth = centers[ :, 2 ]
+ sigmaN = surface.cell_data[ 'sigmaNEffective' ]
+ tau = surface.cell_data[ 'tauEffective' ]
+ SCU = surface.cell_data[ 'SCU' ]
+ SCU = np.sqrt( SCU**2 )
+ surface.cell_data[ 'deltaSCU' ]
# Extraire les IDs de faille
faultIds = None
if 'FaultMask' in surface.cell_data:
- faultIds = surface.cell_data['FaultMask']
- print(f" 📋 Detected {len(np.unique(faultIds[faultIds > 0]))} distinct faults")
+ faultIds = surface.cell_data[ 'FaultMask' ]
+ print( f" 📋 Detected {len(np.unique(faultIds[faultIds > 0]))} distinct faults" )
elif 'attribute' in surface.cell_data:
- faultIds = surface.cell_data['attribute']
- print(f" 📋 Using 'attribute' field for fault identification")
+ faultIds = surface.cell_data[ 'attribute' ]
+ print( " 📋 Using 'attribute' field for fault identification" )
else:
- print(f" ⚠️ No fault IDs found - profiles may jump between faults")
+ print( " ⚠️ No fault IDs found - profiles may jump between faults" )
# ===================================================================
# LOAD REFERENCE DATA (GEOS + Analytical)
# ===================================================================
- scriptDir = os.path.dirname(os.path.abspath(__file__))
- referenceData = Visualizer.loadReferenceData(
- time,
- scriptDir,
- profileId=referenceProfileId
- )
+ scriptDir = os.path.dirname( os.path.abspath( __file__ ) )
+ referenceData = Visualizer.loadReferenceData( time, scriptDir, profileId=referenceProfileId )
- geosData = referenceData['geos']
- analyticalData = referenceData['analytical']
+ geosData = referenceData[ 'geos' ]
+ analyticalData = referenceData[ 'analytical' ]
# ===================================================================
# PROFILE EXTRACTION SETUP
# ===================================================================
# Get fault bounds
- xMin, xMax = np.min(centers[:, 0]), np.max(centers[:, 0])
- yMin, yMax = np.min(centers[:, 1]), np.max(centers[:, 1])
- zMin, zMax = np.min(depth), np.max(depth)
+ xMin, xMax = np.min( centers[ :, 0 ] ), np.max( centers[ :, 0 ] )
+ yMin, yMax = np.min( centers[ :, 1 ] ), np.max( centers[ :, 1 ] )
+ zMin, zMax = np.min( depth ), np.max( depth )
# Auto-compute search radius if not provided
xRange = xMax - xMin
yRange = yMax - yMin
- zRange = zMax - zMin
+ zMax - zMin
if self.config.PROFILE_SEARCH_RADIUS is not None:
searchRadius = self.config.PROFILE_SEARCH_RADIUS
else:
- searchRadius = min(xRange, yRange) * 0.15
-
+ searchRadius = min( xRange, yRange ) * 0.15
# Auto-generate profile points if not provided
if profileStartPoints is None:
- print(" ⚠️ No profileStartPoints provided, auto-generating 5 profiles...")
+ print( " ⚠️ No profileStartPoints provided, auto-generating 5 profiles..." )
nProfiles = 5
# Determine dominant fault direction
if xRange > yRange:
coordName = 'X'
- fixedValue = (yMin + yMax) / 2
- samplePositions = np.linspace(xMin, xMax, nProfiles)
- profileStartPoints = [(x, fixedValue) for x in samplePositions]
+ fixedValue = ( yMin + yMax ) / 2
+ samplePositions = np.linspace( xMin, xMax, nProfiles )
+ profileStartPoints = [ ( x, fixedValue ) for x in samplePositions ]
else:
coordName = 'Y'
- fixedValue = (xMin + xMax) / 2
- samplePositions = np.linspace(yMin, yMax, nProfiles)
- profileStartPoints = [(fixedValue, y) for y in samplePositions]
+ fixedValue = ( xMin + xMax ) / 2
+ samplePositions = np.linspace( yMin, yMax, nProfiles )
+ profileStartPoints = [ ( fixedValue, y ) for y in samplePositions ]
- print(f" Auto-generated {nProfiles} profiles along {coordName} direction")
+ print( f" Auto-generated {nProfiles} profiles along {coordName} direction" )
- nProfiles = len(profileStartPoints)
+ nProfiles = len( profileStartPoints )
# ===================================================================
# CREATE FIGURE
# ===================================================================
- fig, axes = plt.subplots(1, 4, figsize=(24, 12))
- colors = plt.cm.RdYlGn(np.linspace(0, 1, nProfiles))
+ fig, axes = plt.subplots( 1, 4, figsize=( 24, 12 ) )
+ colors = plt.cm.RdYlGn( np.linspace( 0, 1, nProfiles ) ) # type: ignore [attr-defined]
- print(f" 📍 Processing {nProfiles} profiles:")
- print(f" Depth range: [{zMin:.1f}, {zMax:.1f}]m")
+ print( f" 📍 Processing {nProfiles} profiles:" )
+ print( f" Depth range: [{zMin:.1f}, {zMax:.1f}]m" )
successfulProfiles = 0
@@ -348,8 +362,8 @@ def plotDepthProfiles(self, surface, time, path, show=True, save=True,
# EXTRACT AND PLOT PROFILES
# ===================================================================
- for i, (xPos, yPos, zPos) in enumerate(profileStartPoints):
- print(f" → Profile {i+1}: starting at ({xPos:.1f}, {yPos:.1f}, {zPos:.1f})")
+ for i, ( xPos, yPos, zPos ) in enumerate( profileStartPoints ):
+ print( f" → Profile {i+1}: starting at ({xPos:.1f}, {yPos:.1f}, {zPos:.1f})" )
# depthsSigma, profileSigmaN, PathXSigma, PathYSigma = ProfileExtractor.extractVerticalProfileTopologyBased(
# surface, 'sigmaNEffective', xPos, yPos, zPos, verbose=True)
@@ -364,72 +378,93 @@ def plotDepthProfiles(self, surface, time, path, show=True, save=True,
# surface, 'deltaSCU', xPos, yPos, zPos, verbose=False)
depthsSigma, profileSigmaN, PathXSigma, PathYSigma = ProfileExtractor.extractAdaptiveProfile(
- centers, sigmaN, xPos, yPos, searchRadius)
+ centers, sigmaN, xPos, yPos, searchRadius )
- depthsTau, profileTau, _, _ = ProfileExtractor.extractAdaptiveProfile(
- centers, tau, xPos, yPos, searchRadius)
+ depthsTau, profileTau, _, _ = ProfileExtractor.extractAdaptiveProfile( centers, tau, xPos, yPos,
+ searchRadius )
- depthsSCU, profileSCU, _, _ = ProfileExtractor.extractAdaptiveProfile(
- centers, SCU, xPos, yPos, searchRadius)
+ depthsSCU, profileSCU, _, _ = ProfileExtractor.extractAdaptiveProfile( centers, SCU, xPos, yPos,
+ searchRadius )
depthsDeltaSCU, profileDeltaSCU, _, _ = ProfileExtractor.extractAdaptiveProfile(
- centers, SCU, xPos, yPos, searchRadius)
+ centers, SCU, xPos, yPos, searchRadius )
# Calculate path length
- if len(PathXSigma) > 1:
- pathLength = np.sum(np.sqrt(
- np.diff(PathXSigma)**2 +
- np.diff(PathYSigma)**2 +
- np.diff(depthsSigma)**2
- ))
- print(f" Path length: {pathLength:.1f}m (horizontal displacement: {np.abs(PathXSigma[-1] - PathXSigma[0]):.1f}m)")
+ if len( PathXSigma ) > 1:
+ pathLength = np.sum(
+ np.sqrt( np.diff( PathXSigma )**2 + np.diff( PathYSigma )**2 + np.diff( depthsSigma )**2 ) )
+ print(
+ f" Path length: {pathLength:.1f}m (horizontal displacement: {np.abs(PathXSigma[-1] - PathXSigma[0]):.1f}m)"
+ )
if self.config.SHOW_PROFILE_EXTRACTOR:
- ProfileExtractor.plotProfilePath3D(
- surface=surface,
- pathX=PathXSigma,
- pathY=PathYSigma,
- pathZ=depthsSigma,
- profileValues=profileSigmaN,
- scalarName='SCU',
- savePath=path,
- show=show
- )
+ ProfileExtractor.plotProfilePath3D( surface=surface,
+ pathX=PathXSigma,
+ pathY=PathYSigma,
+ pathZ=depthsSigma,
+ profileValues=profileSigmaN,
+ scalarName='SCU',
+ savePath=path,
+ show=show )
# Check if we have enough points
minPoints = 3
- nPoints = len(depthsSigma)
+ nPoints = len( depthsSigma )
if nPoints >= minPoints:
label = f'Profile {i+1} → ({xPos:.0f}, {yPos:.0f})'
# Plot 1: Normal stress vs depth
- axes[0].plot(profileSigmaN, depthsSigma,
- color=colors[i], label=label, linewidth=2.5, alpha=0.8,
- marker='o', markersize=3, markevery=2)
+ axes[ 0 ].plot( profileSigmaN,
+ depthsSigma,
+ color=colors[ i ],
+ label=label,
+ linewidth=2.5,
+ alpha=0.8,
+ marker='o',
+ markersize=3,
+ markevery=2 )
# Plot 2: Shear stress vs depth
- axes[1].plot(profileTau, depthsTau,
- color=colors[i], label=label, linewidth=2.5, alpha=0.8,
- marker='o', markersize=3, markevery=2)
+ axes[ 1 ].plot( profileTau,
+ depthsTau,
+ color=colors[ i ],
+ label=label,
+ linewidth=2.5,
+ alpha=0.8,
+ marker='o',
+ markersize=3,
+ markevery=2 )
# Plot 3: SCU vs depth
- axes[2].plot(profileSCU, depthsSCU,
- color=colors[i], label=label, linewidth=2.5, alpha=0.8,
- marker='o', markersize=3, markevery=2)
+ axes[ 2 ].plot( profileSCU,
+ depthsSCU,
+ color=colors[ i ],
+ label=label,
+ linewidth=2.5,
+ alpha=0.8,
+ marker='o',
+ markersize=3,
+ markevery=2 )
# Plot 4: Detla SCU vs depth
- axes[3].plot(profileDeltaSCU, depthsDeltaSCU,
- color=colors[i], label=label, linewidth=2.5, alpha=0.8,
- marker='o', markersize=3, markevery=2)
+ axes[ 3 ].plot( profileDeltaSCU,
+ depthsDeltaSCU,
+ color=colors[ i ],
+ label=label,
+ linewidth=2.5,
+ alpha=0.8,
+ marker='o',
+ markersize=3,
+ markevery=2 )
successfulProfiles += 1
- print(f" ✅ {nPoints} points found")
+ print( f" ✅ {nPoints} points found" )
else:
- print(f" ⚠️ Insufficient points ({nPoints}), skipping")
+ print( f" ⚠️ Insufficient points ({nPoints}), skipping" )
if successfulProfiles == 0:
- print(" ❌ No valid profiles found!")
+ print( " ❌ No valid profiles found!" )
plt.close()
return
@@ -441,26 +476,57 @@ def plotDepthProfiles(self, surface, time, path, show=True, save=True,
# Colonnes: [Depth_m, Normal_Stress_bar, Shear_Stress_bar, SCU]
# Index: [0, 1, 2, 3]
- axes[0].plot(geosData[:, 1] *10, geosData[:, 0], 'o',
- color='blue', markersize=6, label='GEOS Contact Solver',
- alpha=0.7, mec='k', mew=1, fillstyle='none')
-
- axes[1].plot(geosData[:, 2] *10, geosData[:, 0], 'o',
- color='blue', markersize=6, label='GEOS Contact Solver',
- alpha=0.7, mec='k', mew=1, fillstyle='none')
-
- if geosData.shape[1] > 3: # SCU column exists
- axes[2].plot(geosData[:, 3], geosData[:, 0], 'o',
- color='blue', markersize=6, label='GEOS Contact Solver',
- alpha=0.7, mec='k', mew=1, fillstyle='none')
+ axes[ 0 ].plot( geosData[ :, 1 ] * 10,
+ geosData[ :, 0 ],
+ 'o',
+ color='blue',
+ markersize=6,
+ label='GEOS Contact Solver',
+ alpha=0.7,
+ mec='k',
+ mew=1,
+ fillstyle='none' )
+
+ axes[ 1 ].plot( geosData[ :, 2 ] * 10,
+ geosData[ :, 0 ],
+ 'o',
+ color='blue',
+ markersize=6,
+ label='GEOS Contact Solver',
+ alpha=0.7,
+ mec='k',
+ mew=1,
+ fillstyle='none' )
+
+ if geosData.shape[ 1 ] > 3: # SCU column exists
+ axes[ 2 ].plot( geosData[ :, 3 ],
+ geosData[ :, 0 ],
+ 'o',
+ color='blue',
+ markersize=6,
+ label='GEOS Contact Solver',
+ alpha=0.7,
+ mec='k',
+ mew=1,
+ fillstyle='none' )
if analyticalData is not None:
# Format analytique (peut varier)
- axes[0].plot(analyticalData[:, 1] * 10, analyticalData[:, 0], '--',
- color='darkorange', linewidth=2, label='Analytical', alpha=0.8)
- if analyticalData.shape[1] > 2:
- axes[1].plot(analyticalData[:, 2] * 10, analyticalData[:, 0], '--',
- color='darkorange', linewidth=2, label='Analytical', alpha=0.8)
+ axes[ 0 ].plot( analyticalData[ :, 1 ] * 10,
+ analyticalData[ :, 0 ],
+ '--',
+ color='darkorange',
+ linewidth=2,
+ label='Analytical',
+ alpha=0.8 )
+ if analyticalData.shape[ 1 ] > 2:
+ axes[ 1 ].plot( analyticalData[ :, 2 ] * 10,
+ analyticalData[ :, 0 ],
+ '--',
+ color='darkorange',
+ linewidth=2,
+ label='Analytical',
+ alpha=0.8 )
# ===================================================================
# CONFIGURE PLOTS
@@ -469,62 +535,61 @@ def plotDepthProfiles(self, surface, time, path, show=True, save=True,
fsize = 14
# Plot 1: Normal Stress
- axes[0].set_xlabel('Normal Stress σₙ [bar]', fontsize=fsize, weight="bold")
- axes[0].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
- axes[0].set_title('Normal Stress Profile', fontsize=fsize+2, weight="bold")
- axes[0].grid(True, alpha=0.3, linestyle='--')
- axes[0].legend(loc='upper left', fontsize=fsize-2)
- axes[0].tick_params(labelsize=fsize-2)
+ axes[ 0 ].set_xlabel( 'Normal Stress σₙ [bar]', fontsize=fsize, weight="bold" )
+ axes[ 0 ].set_ylabel( 'Depth [m]', fontsize=fsize, weight="bold" )
+ axes[ 0 ].set_title( 'Normal Stress Profile', fontsize=fsize + 2, weight="bold" )
+ axes[ 0 ].grid( True, alpha=0.3, linestyle='--' )
+ axes[ 0 ].legend( loc='upper left', fontsize=fsize - 2 )
+ axes[ 0 ].tick_params( labelsize=fsize - 2 )
# Plot 2: Shear Stress
- axes[1].set_xlabel('Shear Stress τ [bar]', fontsize=fsize, weight="bold")
- axes[1].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
- axes[1].set_title('Shear Stress Profile', fontsize=fsize+2, weight="bold")
- axes[1].grid(True, alpha=0.3, linestyle='--')
- axes[1].legend(loc='upper left', fontsize=fsize-2)
- axes[1].tick_params(labelsize=fsize-2)
+ axes[ 1 ].set_xlabel( 'Shear Stress τ [bar]', fontsize=fsize, weight="bold" )
+ axes[ 1 ].set_ylabel( 'Depth [m]', fontsize=fsize, weight="bold" )
+ axes[ 1 ].set_title( 'Shear Stress Profile', fontsize=fsize + 2, weight="bold" )
+ axes[ 1 ].grid( True, alpha=0.3, linestyle='--' )
+ axes[ 1 ].legend( loc='upper left', fontsize=fsize - 2 )
+ axes[ 1 ].tick_params( labelsize=fsize - 2 )
# Plot 3: SCU
- axes[2].set_xlabel('SCU [-]', fontsize=fsize, weight="bold")
- axes[2].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
- axes[2].set_title('Shear Capacity Utilization', fontsize=fsize+2, weight="bold")
- axes[2].axvline(x=0.8, color='forestgreen', linestyle='--', linewidth=2, label='Critical (0.8)')
- axes[2].axvline(x=1.0, color='red', linestyle='--', linewidth=2, label='Failure (1.0)')
- axes[2].grid(True, alpha=0.3, linestyle='--')
- axes[2].legend(loc='upper right', fontsize=fsize-2)
- axes[2].tick_params(labelsize=fsize-2)
- axes[2].set_xlim(left=0)
+ axes[ 2 ].set_xlabel( 'SCU [-]', fontsize=fsize, weight="bold" )
+ axes[ 2 ].set_ylabel( 'Depth [m]', fontsize=fsize, weight="bold" )
+ axes[ 2 ].set_title( 'Shear Capacity Utilization', fontsize=fsize + 2, weight="bold" )
+ axes[ 2 ].axvline( x=0.8, color='forestgreen', linestyle='--', linewidth=2, label='Critical (0.8)' )
+ axes[ 2 ].axvline( x=1.0, color='red', linestyle='--', linewidth=2, label='Failure (1.0)' )
+ axes[ 2 ].grid( True, alpha=0.3, linestyle='--' )
+ axes[ 2 ].legend( loc='upper right', fontsize=fsize - 2 )
+ axes[ 2 ].tick_params( labelsize=fsize - 2 )
+ axes[ 2 ].set_xlim( left=0 )
# Plot 4: Delta SCU
- axes[3].set_xlabel('Δ SCU [-]', fontsize=fsize, weight="bold")
- axes[3].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
- axes[3].set_title('Delta SCU', fontsize=fsize+2, weight="bold")
- axes[3].grid(True, alpha=0.3, linestyle='--')
- axes[3].legend(loc='upper right', fontsize=fsize-2)
- axes[3].tick_params(labelsize=fsize-2)
- axes[3].set_xlim(left=0, right=2)
+ axes[ 3 ].set_xlabel( 'Δ SCU [-]', fontsize=fsize, weight="bold" )
+ axes[ 3 ].set_ylabel( 'Depth [m]', fontsize=fsize, weight="bold" )
+ axes[ 3 ].set_title( 'Delta SCU', fontsize=fsize + 2, weight="bold" )
+ axes[ 3 ].grid( True, alpha=0.3, linestyle='--' )
+ axes[ 3 ].legend( loc='upper right', fontsize=fsize - 2 )
+ axes[ 3 ].tick_params( labelsize=fsize - 2 )
+ axes[ 3 ].set_xlim( left=0, right=2 )
# Change verticale scale
- if self.config.MAX_DEPTH_PROFILES != None :
- for i in range(len(axes)):
- axes[i].set_ylim(bottom=self.config.MAX_DEPTH_PROFILES)
+ if self.config.MAX_DEPTH_PROFILES is not None:
+ for i in range( len( axes ) ):
+ axes[ i ].set_ylim( bottom=self.config.MAX_DEPTH_PROFILES )
- if self.config.MIN_DEPTH_PROFILES != None :
- for i in range(len(axes)):
- axes[i].set_ylim(top=self.config.MIN_DEPTH_PROFILES)
+ if self.config.MIN_DEPTH_PROFILES is not None:
+ for i in range( len( axes ) ):
+ axes[ i ].set_ylim( top=self.config.MIN_DEPTH_PROFILES )
# Overall title
- years = time / (365.25 * 24 * 3600)
- fig.suptitle(f'Fault Depth Profiles - t={years:.1f} years',
- fontsize=fsize+2, fontweight='bold', y=0.98)
+ years = time / ( 365.25 * 24 * 3600 )
+ fig.suptitle( f'Fault Depth Profiles - t={years:.1f} years', fontsize=fsize + 2, fontweight='bold', y=0.98 )
- plt.tight_layout(rect=[0, 0, 1, 0.96])
+ plt.tight_layout( rect=( 0, 0, 1, 0.96 ) )
# Save
if save:
filename = f'depth_profiles_{years:.0f}y.png'
- plt.savefig(path / filename, dpi=300, bbox_inches='tight')
- print(f" 💾 Depth profiles saved: {filename}")
+ plt.savefig( path / filename, dpi=300, bbox_inches='tight' )
+ print( f" 💾 Depth profiles saved: {filename}" )
# Show
if show:
@@ -533,147 +598,151 @@ def plotDepthProfiles(self, surface, time, path, show=True, save=True,
plt.close()
# -------------------------------------------------------------------
- def plotVolumeStressProfiles(self, volumeMesh, faultSurface, time, path,
- show=True, save=True,
- profileStartPoints=None,
- maxProfilePoints=1000):
- """
- Plot stress profiles in volume cells adjacent to the fault
+ def plotVolumeStressProfiles( self: Self,
+ volumeMesh: pv.DataSet,
+ faultSurface: pv.PolyData,
+ time: float,
+ path: Path,
+ show: bool = True,
+ save: bool = True,
+ profileStartPoints: list[ tuple[ float, float, float ] ] | None = None,
+ maxProfilePoints: int = 1000 ) -> None:
+ """Plot stress profiles in volume cells adjacent to the fault.
+
Extracts profiles through contributing cells on BOTH sides of the fault
- Shows plus side and minus side on the same plots for comparison
+ Shows plus side and minus side on the same plots for comparison.
NOTE: Cette fonction utilise extractAdaptiveProfile pour les VOLUMES
car volumeMesh n'est PAS un maillage surfacique.
La méthode topologique (extractVerticalProfileTopologyBased)
est réservée aux maillages SURFACIQUES (faultSurface).
"""
-
- print(" 📊 Creating volume stress profiles (both sides)")
+ print( " 📊 Creating volume stress profiles (both sides)" )
# ===================================================================
# CHECK IF REQUIRED DATA EXISTS
# ===================================================================
- requiredFields = ['sigma1', 'sigma2', 'sigma3', 'side', 'elementCenter']
+ requiredFields = [ 'sigma1', 'sigma2', 'sigma3', 'side', 'elementCenter' ]
for field in requiredFields:
if field not in volumeMesh.cell_data:
- print(f" ⚠️ Missing required field: {field}")
+ print( f" ⚠️ Missing required field: {field}" )
return
# Check for pressure
if 'pressure_bar' in volumeMesh.cell_data:
pressureField = 'pressure_bar'
- pressure = volumeMesh.cell_data[pressureField]
+ pressure = volumeMesh.cell_data[ pressureField ]
elif 'pressure' in volumeMesh.cell_data:
pressureField = 'pressure'
- pressure = volumeMesh.cell_data[pressureField] / 1e5
- print(" ℹ️ Converting pressure from Pa to bar")
+ pressure = volumeMesh.cell_data[ pressureField ] / 1e5
+ print( " ℹ️ Converting pressure from Pa to bar" )
else:
- print(" ⚠️ No pressure field found")
+ print( " ⚠️ No pressure field found" )
pressure = None
# Extract volume data
- centers = volumeMesh.cell_data['elementCenter']
- sigma1 = volumeMesh.cell_data['sigma1']
- sigma2 = volumeMesh.cell_data['sigma2']
- sigma3 = volumeMesh.cell_data['sigma3']
- sideData = volumeMesh.cell_data['side']
+ centers = volumeMesh.cell_data[ 'elementCenter' ]
+ sigma1 = volumeMesh.cell_data[ 'sigma1' ]
+ sigma2 = volumeMesh.cell_data[ 'sigma2' ]
+ sigma3 = volumeMesh.cell_data[ 'sigma3' ]
+ sideData = volumeMesh.cell_data[ 'side' ]
# ===================================================================
# FILTER CELLS BY SIDE (BOTH PLUS AND MINUS)
# ===================================================================
# Plus side (side = 1 or 3)
- maskPlus = (sideData == 1) | (sideData == 3)
- centersPlus = centers[maskPlus]
- sigma1Plus = sigma1[maskPlus]
- sigma2Plus = sigma2[maskPlus]
- sigma3Plus = sigma3[maskPlus]
+ maskPlus = ( sideData == 1 ) | ( sideData == 3 )
+ centersPlus = centers[ maskPlus ]
+ sigma1Plus = sigma1[ maskPlus ]
+ sigma2Plus = sigma2[ maskPlus ]
+ sigma3Plus = sigma3[ maskPlus ]
if pressure is not None:
- pressurePlus = pressure[maskPlus]
+ pressurePlus = pressure[ maskPlus ]
# Créer subset de cellData pour le côté plus
cellDataPlus = {}
- for key in volumeMesh.cell_data.keys():
- cellDataPlus[key] = volumeMesh.cell_data[key][maskPlus]
+ for key in volumeMesh.cell_data:
+ cellDataPlus[ key ] = volumeMesh.cell_data[ key ][ maskPlus ]
# Minus side (side = 2 or 3)
- maskMinus = (sideData == 2) | (sideData == 3)
- centersMinus = centers[maskMinus]
- sigma1Minus = sigma1[maskMinus]
- sigma2Minus = sigma2[maskMinus]
- sigma3Minus = sigma3[maskMinus]
+ maskMinus = ( sideData == 2 ) | ( sideData == 3 )
+ centersMinus = centers[ maskMinus ]
+ sigma1Minus = sigma1[ maskMinus ]
+ sigma2Minus = sigma2[ maskMinus ]
+ sigma3Minus = sigma3[ maskMinus ]
if pressure is not None:
- pressureMinus = pressure[maskMinus]
+ pressureMinus = pressure[ maskMinus ]
# Créer subset de cellData pour le côté minus
cellDataMinus = {}
- for key in volumeMesh.cell_data.keys():
- cellDataMinus[key] = volumeMesh.cell_data[key][maskMinus]
+ for key in volumeMesh.cell_data:
+ cellDataMinus[ key ] = volumeMesh.cell_data[ key ][ maskMinus ]
- print(f" 📍 Plus side: {len(centersPlus):,} cells")
- print(f" 📍 Minus side: {len(centersMinus):,} cells")
+ print( f" 📍 Plus side: {len(centersPlus):,} cells" )
+ print( f" 📍 Minus side: {len(centersMinus):,} cells" )
- if len(centersPlus) == 0 and len(centersMinus) == 0:
- print(" ⚠️ No contributing cells found!")
+ if len( centersPlus ) == 0 and len( centersMinus ) == 0:
+ print( " ⚠️ No contributing cells found!" )
return
# ===================================================================
# GET FAULT BOUNDS
# ===================================================================
- faultCenters = faultSurface.cell_data['elementCenter']
+ faultCenters = faultSurface.cell_data[ 'elementCenter' ]
- xMin, xMax = np.min(faultCenters[:, 0]), np.max(faultCenters[:, 0])
- yMin, yMax = np.min(faultCenters[:, 1]), np.max(faultCenters[:, 1])
- zMin, zMax = np.min(faultCenters[:, 2]), np.max(faultCenters[:, 2])
+ xMin, xMax = np.min( faultCenters[ :, 0 ] ), np.max( faultCenters[ :, 0 ] )
+ yMin, yMax = np.min( faultCenters[ :, 1 ] ), np.max( faultCenters[ :, 1 ] )
+ zMin, zMax = np.min( faultCenters[ :, 2 ] ), np.max( faultCenters[ :, 2 ] )
xRange = xMax - xMin
yRange = yMax - yMin
- zRange = zMax - zMin
+ zMax - zMin
# Search radius (pour extractAdaptiveProfile sur volumes)
if self.config.PROFILE_SEARCH_RADIUS is not None:
searchRadius = self.config.PROFILE_SEARCH_RADIUS
else:
- searchRadius = min(xRange, yRange) * 0.2
+ searchRadius = min( xRange, yRange ) * 0.2
# ===================================================================
# AUTO-GENERATE PROFILE POINTS IF NOT PROVIDED
# ===================================================================
if profileStartPoints is None:
- print(" ⚠️ No profileStartPoints provided, auto-generating...")
+ print( " ⚠️ No profileStartPoints provided, auto-generating..." )
nProfiles = 3
if xRange > yRange:
coordName = 'X'
- fixedValue = (yMin + yMax) / 2
- samplePositions = np.linspace(xMin, xMax, nProfiles)
- profileStartPoints = [(x, fixedValue, zMax) for x in samplePositions]
+ fixedValue = ( yMin + yMax ) / 2
+ samplePositions = np.linspace( xMin, xMax, nProfiles )
+ profileStartPoints = [ ( x, fixedValue, zMax ) for x in samplePositions ]
else:
coordName = 'Y'
- fixedValue = (xMin + xMax) / 2
- samplePositions = np.linspace(yMin, yMax, nProfiles)
- profileStartPoints = [(fixedValue, y, zMax) for y in samplePositions]
+ fixedValue = ( xMin + xMax ) / 2
+ samplePositions = np.linspace( yMin, yMax, nProfiles )
+ profileStartPoints = [ ( fixedValue, y, zMax ) for y in samplePositions ]
- print(f" Auto-generated {nProfiles} profiles along {coordName}")
+ print( f" Auto-generated {nProfiles} profiles along {coordName}" )
- nProfiles = len(profileStartPoints)
+ nProfiles = len( profileStartPoints )
# ===================================================================
# CREATE FIGURE WITH 5 SUBPLOTS
# ===================================================================
- fig, axes = plt.subplots(1, 5, figsize=(22, 10))
+ fig, axes = plt.subplots( 1, 5, figsize=( 22, 10 ) )
# Colors: different for plus and minus sides
- colorsPlus = plt.cm.Reds(np.linspace(0.4, 0.9, nProfiles))
- colorsMinus = plt.cm.Blues(np.linspace(0.4, 0.9, nProfiles))
+ colorsPlus = plt.cm.Reds( np.linspace( 0.4, 0.9, nProfiles ) ) # type: ignore [attr-defined]
+ colorsMinus = plt.cm.Blues( np.linspace( 0.4, 0.9, nProfiles ) ) # type: ignore [attr-defined]
- print(f" 📍 Processing {nProfiles} volume profiles:")
- print(f" Depth range: [{zMin:.1f}, {zMax:.1f}]m")
+ print( f" 📍 Processing {nProfiles} volume profiles:" )
+ print( f" Depth range: [{zMin:.1f}, {zMax:.1f}]m" )
successfulProfiles = 0
@@ -681,135 +750,225 @@ def plotVolumeStressProfiles(self, volumeMesh, faultSurface, time, path,
# EXTRACT AND PLOT PROFILES FOR BOTH SIDES
# ===================================================================
- for i, (xPos, yPos, zPos) in enumerate(profileStartPoints):
- print(f"\n → Profile {i+1}: starting at ({xPos:.1f}, {yPos:.1f}, {zPos:.1f})")
+ for i, ( xPos, yPos, zPos ) in enumerate( profileStartPoints ):
+ print( f"\n → Profile {i+1}: starting at ({xPos:.1f}, {yPos:.1f}, {zPos:.1f})" )
# ================================================================
# PLUS SIDE
# ================================================================
- if len(centersPlus) > 0:
- print(f" Processing PLUS side...")
+ if len( centersPlus ) > 0:
+ print( " Processing PLUS side..." )
# Pour VOLUMES, utiliser extractAdaptiveProfile avec cellData
depthsSigma1Plus, profileSigma1Plus, _, _ = ProfileExtractor.extractAdaptiveProfile(
- centersPlus, sigma1Plus, xPos, yPos, zPos,
- searchRadius, verbose=True, cellData=cellDataPlus)
+ centersPlus, sigma1Plus, xPos, yPos, zPos, searchRadius, verbose=True, cellData=cellDataPlus )
depthsSigma2Plus, profileSigma2Plus, _, _ = ProfileExtractor.extractAdaptiveProfile(
- centersPlus, sigma2Plus, xPos, yPos, zPos,
- searchRadius, verbose=False, cellData=cellDataPlus)
+ centersPlus, sigma2Plus, xPos, yPos, zPos, searchRadius, verbose=False, cellData=cellDataPlus )
depthsSigma3Plus, profileSigma3Plus, _, _ = ProfileExtractor.extractAdaptiveProfile(
- centersPlus, sigma3Plus, xPos, yPos, zPos,
- searchRadius, verbose=False, cellData=cellDataPlus)
+ centersPlus, sigma3Plus, xPos, yPos, zPos, searchRadius, verbose=False, cellData=cellDataPlus )
if pressure is not None:
depthsPressurePlus, profilePressurePlus, _, _ = ProfileExtractor.extractAdaptiveProfile(
- centersPlus, pressurePlus, xPos, yPos, zPos,
- searchRadius, verbose=False, cellData=cellDataPlus)
-
- if len(depthsSigma1Plus) >= 3:
- labelPlus = f'Plus side'
+ centersPlus,
+ pressurePlus,
+ xPos,
+ yPos,
+ zPos,
+ searchRadius,
+ verbose=False,
+ cellData=cellDataPlus )
+
+ if len( depthsSigma1Plus ) >= 3:
+ labelPlus = 'Plus side'
# Plot Pressure
if pressure is not None:
- axes[0].plot(profilePressurePlus, depthsPressurePlus,
- color=colorsPlus[i], label=labelPlus if i == 0 else '',
- linewidth=2.5, alpha=0.8, marker='o', markersize=3, markevery=2)
+ axes[ 0 ].plot( profilePressurePlus,
+ depthsPressurePlus,
+ color=colorsPlus[ i ],
+ label=labelPlus if i == 0 else '',
+ linewidth=2.5,
+ alpha=0.8,
+ marker='o',
+ markersize=3,
+ markevery=2 )
# Plot σ1
- axes[1].plot(profileSigma1Plus, depthsSigma1Plus,
- color=colorsPlus[i], label=labelPlus if i == 0 else '',
- linewidth=2.5, alpha=0.8, marker='o', markersize=3, markevery=2)
+ axes[ 1 ].plot( profileSigma1Plus,
+ depthsSigma1Plus,
+ color=colorsPlus[ i ],
+ label=labelPlus if i == 0 else '',
+ linewidth=2.5,
+ alpha=0.8,
+ marker='o',
+ markersize=3,
+ markevery=2 )
# Plot σ2
- axes[2].plot(profileSigma2Plus, depthsSigma2Plus,
- color=colorsPlus[i], label=labelPlus if i == 0 else '',
- linewidth=2.5, alpha=0.8, marker='o', markersize=3, markevery=2)
+ axes[ 2 ].plot( profileSigma2Plus,
+ depthsSigma2Plus,
+ color=colorsPlus[ i ],
+ label=labelPlus if i == 0 else '',
+ linewidth=2.5,
+ alpha=0.8,
+ marker='o',
+ markersize=3,
+ markevery=2 )
# Plot σ3
- axes[3].plot(profileSigma3Plus, depthsSigma3Plus,
- color=colorsPlus[i], label=labelPlus if i == 0 else '',
- linewidth=2.5, alpha=0.8, marker='o', markersize=3, markevery=2)
+ axes[ 3 ].plot( profileSigma3Plus,
+ depthsSigma3Plus,
+ color=colorsPlus[ i ],
+ label=labelPlus if i == 0 else '',
+ linewidth=2.5,
+ alpha=0.8,
+ marker='o',
+ markersize=3,
+ markevery=2 )
# Plot All stresses
- axes[4].plot(profileSigma1Plus, depthsSigma1Plus,
- color=colorsPlus[i], linewidth=2.5, alpha=0.8,
- linestyle='-', marker="o", markersize=2, markevery=2)
- axes[4].plot(profileSigma2Plus, depthsSigma2Plus,
- color=colorsPlus[i], linewidth=2.0, alpha=0.6,
- linestyle='-', marker="s", markersize=2, markevery=2)
- axes[4].plot(profileSigma3Plus, depthsSigma3Plus,
- color=colorsPlus[i], linewidth=2.5, alpha=0.8,
- linestyle='-', marker="v", markersize=2, markevery=2)
-
- print(f" ✅ PLUS: {len(depthsSigma1Plus)} points")
+ axes[ 4 ].plot( profileSigma1Plus,
+ depthsSigma1Plus,
+ color=colorsPlus[ i ],
+ linewidth=2.5,
+ alpha=0.8,
+ linestyle='-',
+ marker="o",
+ markersize=2,
+ markevery=2 )
+ axes[ 4 ].plot( profileSigma2Plus,
+ depthsSigma2Plus,
+ color=colorsPlus[ i ],
+ linewidth=2.0,
+ alpha=0.6,
+ linestyle='-',
+ marker="s",
+ markersize=2,
+ markevery=2 )
+ axes[ 4 ].plot( profileSigma3Plus,
+ depthsSigma3Plus,
+ color=colorsPlus[ i ],
+ linewidth=2.5,
+ alpha=0.8,
+ linestyle='-',
+ marker="v",
+ markersize=2,
+ markevery=2 )
+
+ print( f" ✅ PLUS: {len(depthsSigma1Plus)} points" )
successfulProfiles += 1
# ================================================================
# MINUS SIDE
# ================================================================
- if len(centersMinus) > 0:
- print(f" Processing MINUS side...")
+ if len( centersMinus ) > 0:
+ print( " Processing MINUS side..." )
# Pour VOLUMES, utiliser extractAdaptiveProfile avec cellData
depthsSigma1Minus, profileSigma1Minus, _, _ = ProfileExtractor.extractAdaptiveProfile(
- centersMinus, sigma1Minus, xPos, yPos, zPos,
- searchRadius, verbose=True, cellData=cellDataMinus)
+ centersMinus, sigma1Minus, xPos, yPos, zPos, searchRadius, verbose=True, cellData=cellDataMinus )
depthsSigma2Minus, profileSigma2Minus, _, _ = ProfileExtractor.extractAdaptiveProfile(
- centersMinus, sigma2Minus, xPos, yPos, zPos,
- searchRadius, verbose=False, cellData=cellDataMinus)
+ centersMinus, sigma2Minus, xPos, yPos, zPos, searchRadius, verbose=False, cellData=cellDataMinus )
depthsSigma3Minus, profileSigma3Minus, _, _ = ProfileExtractor.extractAdaptiveProfile(
- centersMinus, sigma3Minus, xPos, yPos, zPos,
- searchRadius, verbose=False, cellData=cellDataMinus)
+ centersMinus, sigma3Minus, xPos, yPos, zPos, searchRadius, verbose=False, cellData=cellDataMinus )
if pressure is not None:
depthsPressureMinus, profilePressureMinus, _, _ = ProfileExtractor.extractAdaptiveProfile(
- centersMinus, pressureMinus, xPos, yPos, zPos,
- searchRadius, verbose=False, cellData=cellDataMinus)
-
- if len(depthsSigma1Minus) >= 3:
- labelMinus = f'Minus side'
+ centersMinus,
+ pressureMinus,
+ xPos,
+ yPos,
+ zPos,
+ searchRadius,
+ verbose=False,
+ cellData=cellDataMinus )
+
+ if len( depthsSigma1Minus ) >= 3:
+ labelMinus = 'Minus side'
# Plot Pressure
if pressure is not None:
- axes[0].plot(profilePressureMinus, depthsPressureMinus,
- color=colorsMinus[i], label=labelMinus if i == 0 else '',
- linewidth=2.5, alpha=0.8, marker='s', markersize=3, markevery=2)
+ axes[ 0 ].plot( profilePressureMinus,
+ depthsPressureMinus,
+ color=colorsMinus[ i ],
+ label=labelMinus if i == 0 else '',
+ linewidth=2.5,
+ alpha=0.8,
+ marker='s',
+ markersize=3,
+ markevery=2 )
# Plot σ1
- axes[1].plot(profileSigma1Minus, depthsSigma1Minus,
- color=colorsMinus[i], label=labelMinus if i == 0 else '',
- linewidth=2.5, alpha=0.8, marker='s', markersize=3, markevery=2)
+ axes[ 1 ].plot( profileSigma1Minus,
+ depthsSigma1Minus,
+ color=colorsMinus[ i ],
+ label=labelMinus if i == 0 else '',
+ linewidth=2.5,
+ alpha=0.8,
+ marker='s',
+ markersize=3,
+ markevery=2 )
# Plot σ2
- axes[2].plot(profileSigma2Minus, depthsSigma2Minus,
- color=colorsMinus[i], label=labelMinus if i == 0 else '',
- linewidth=2.5, alpha=0.8, marker='s', markersize=3, markevery=2)
+ axes[ 2 ].plot( profileSigma2Minus,
+ depthsSigma2Minus,
+ color=colorsMinus[ i ],
+ label=labelMinus if i == 0 else '',
+ linewidth=2.5,
+ alpha=0.8,
+ marker='s',
+ markersize=3,
+ markevery=2 )
# Plot σ3
- axes[3].plot(profileSigma3Minus, depthsSigma3Minus,
- color=colorsMinus[i], label=labelMinus if i == 0 else '',
- linewidth=2.5, alpha=0.8, marker='s', markersize=3, markevery=2)
+ axes[ 3 ].plot( profileSigma3Minus,
+ depthsSigma3Minus,
+ color=colorsMinus[ i ],
+ label=labelMinus if i == 0 else '',
+ linewidth=2.5,
+ alpha=0.8,
+ marker='s',
+ markersize=3,
+ markevery=2 )
# Plot All stresses
- axes[4].plot(profileSigma1Minus, depthsSigma1Minus,
- color=colorsMinus[i], linewidth=2.5, alpha=0.8,
- linestyle='-', marker="o", markersize=2, markevery=2)
- axes[4].plot(profileSigma2Minus, depthsSigma2Minus,
- color=colorsMinus[i], linewidth=2.0, alpha=0.6,
- linestyle='-', marker="s", markersize=2, markevery=2)
- axes[4].plot(profileSigma3Minus, depthsSigma3Minus,
- color=colorsMinus[i], linewidth=2.5, alpha=0.8,
- linestyle='-', marker='v', markersize=2, markevery=2)
-
- print(f" ✅ MINUS: {len(depthsSigma1Minus)} points")
+ axes[ 4 ].plot( profileSigma1Minus,
+ depthsSigma1Minus,
+ color=colorsMinus[ i ],
+ linewidth=2.5,
+ alpha=0.8,
+ linestyle='-',
+ marker="o",
+ markersize=2,
+ markevery=2 )
+ axes[ 4 ].plot( profileSigma2Minus,
+ depthsSigma2Minus,
+ color=colorsMinus[ i ],
+ linewidth=2.0,
+ alpha=0.6,
+ linestyle='-',
+ marker="s",
+ markersize=2,
+ markevery=2 )
+ axes[ 4 ].plot( profileSigma3Minus,
+ depthsSigma3Minus,
+ color=colorsMinus[ i ],
+ linewidth=2.5,
+ alpha=0.8,
+ linestyle='-',
+ marker='v',
+ markersize=2,
+ markevery=2 )
+
+ print( f" ✅ MINUS: {len(depthsSigma1Minus)} points" )
successfulProfiles += 1
if successfulProfiles == 0:
- print(" ❌ No valid profiles found!")
+ print( " ❌ No valid profiles found!" )
plt.close()
return
@@ -820,75 +979,83 @@ def plotVolumeStressProfiles(self, volumeMesh, faultSurface, time, path,
fsize = 14
# Plot 0: Pressure
- axes[0].set_xlabel('Pressure [bar]', fontsize=fsize, weight="bold")
- axes[0].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
- axes[0].grid(True, alpha=0.3, linestyle='--')
- axes[0].legend(loc='best', fontsize=fsize-2)
- axes[0].tick_params(labelsize=fsize-2)
+ axes[ 0 ].set_xlabel( 'Pressure [bar]', fontsize=fsize, weight="bold" )
+ axes[ 0 ].set_ylabel( 'Depth [m]', fontsize=fsize, weight="bold" )
+ axes[ 0 ].grid( True, alpha=0.3, linestyle='--' )
+ axes[ 0 ].legend( loc='best', fontsize=fsize - 2 )
+ axes[ 0 ].tick_params( labelsize=fsize - 2 )
if pressure is None:
- axes[0].text(0.5, 0.5, 'No pressure data available',
- ha='center', va='center', transform=axes[0].transAxes,
- fontsize=fsize, style='italic', color='gray')
+ axes[ 0 ].text( 0.5,
+ 0.5,
+ 'No pressure data available',
+ ha='center',
+ va='center',
+ transform=axes[ 0 ].transAxes,
+ fontsize=fsize,
+ style='italic',
+ color='gray' )
# Plot 1: σ1 (Maximum principal stress)
- axes[1].set_xlabel('σ₁ (Max Principal) [bar]', fontsize=fsize, weight="bold")
- axes[1].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
- axes[1].grid(True, alpha=0.3, linestyle='--')
- axes[1].legend(loc='best', fontsize=fsize-2)
- axes[1].tick_params(labelsize=fsize-2)
+ axes[ 1 ].set_xlabel( 'σ₁ (Max Principal) [bar]', fontsize=fsize, weight="bold" )
+ axes[ 1 ].set_ylabel( 'Depth [m]', fontsize=fsize, weight="bold" )
+ axes[ 1 ].grid( True, alpha=0.3, linestyle='--' )
+ axes[ 1 ].legend( loc='best', fontsize=fsize - 2 )
+ axes[ 1 ].tick_params( labelsize=fsize - 2 )
# Plot 2: σ2 (Intermediate principal stress)
- axes[2].set_xlabel('σ₂ (Inter Principal) [bar]', fontsize=fsize, weight="bold")
- axes[2].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
- axes[2].grid(True, alpha=0.3, linestyle='--')
- axes[2].legend(loc='best', fontsize=fsize-2)
- axes[2].tick_params(labelsize=fsize-2)
+ axes[ 2 ].set_xlabel( 'σ₂ (Inter Principal) [bar]', fontsize=fsize, weight="bold" )
+ axes[ 2 ].set_ylabel( 'Depth [m]', fontsize=fsize, weight="bold" )
+ axes[ 2 ].grid( True, alpha=0.3, linestyle='--' )
+ axes[ 2 ].legend( loc='best', fontsize=fsize - 2 )
+ axes[ 2 ].tick_params( labelsize=fsize - 2 )
# Plot 3: σ3 (Min principal stress)
- axes[3].set_xlabel('σ₃ (Min Principal) [bar]', fontsize=fsize, weight="bold")
- axes[3].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
- axes[3].grid(True, alpha=0.3, linestyle='--')
- axes[3].legend(loc='best', fontsize=fsize-2)
- axes[3].tick_params(labelsize=fsize-2)
+ axes[ 3 ].set_xlabel( 'σ₃ (Min Principal) [bar]', fontsize=fsize, weight="bold" )
+ axes[ 3 ].set_ylabel( 'Depth [m]', fontsize=fsize, weight="bold" )
+ axes[ 3 ].grid( True, alpha=0.3, linestyle='--' )
+ axes[ 3 ].legend( loc='best', fontsize=fsize - 2 )
+ axes[ 3 ].tick_params( labelsize=fsize - 2 )
# Plot 4: All stresses together
- axes[4].set_xlabel('Principal Stresses [bar]', fontsize=fsize, weight="bold")
- axes[4].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
- axes[4].grid(True, alpha=0.3, linestyle='--')
- axes[4].tick_params(labelsize=fsize-2)
+ axes[ 4 ].set_xlabel( 'Principal Stresses [bar]', fontsize=fsize, weight="bold" )
+ axes[ 4 ].set_ylabel( 'Depth [m]', fontsize=fsize, weight="bold" )
+ axes[ 4 ].grid( True, alpha=0.3, linestyle='--' )
+ axes[ 4 ].tick_params( labelsize=fsize - 2 )
# Add legend for line styles
customLines = [
- Line2D([0], [0], color='red', linewidth=2.5, marker=None, label='Plus side', alpha=0.5),
- Line2D([0], [0], color='blue', linewidth=2.5, marker=None, label='Minus side', alpha=0.5),
- Line2D([0], [0], color='gray', linewidth=2.5, linestyle='-', marker='o', label='σ₁ (max)'),
- Line2D([0], [0], color='gray', linewidth=2.0, linestyle='-', marker='s', label='σ₂ (inter)'),
- Line2D([0], [0], color='gray', linewidth=2.5, linestyle='-', marker='v', label='σ₃ (min)')
+ Line2D( [ 0 ], [ 0 ], color='red', linewidth=2.5, marker=None, label='Plus side', alpha=0.5 ),
+ Line2D( [ 0 ], [ 0 ], color='blue', linewidth=2.5, marker=None, label='Minus side', alpha=0.5 ),
+ Line2D( [ 0 ], [ 0 ], color='gray', linewidth=2.5, linestyle='-', marker='o', label='σ₁ (max)' ),
+ Line2D( [ 0 ], [ 0 ], color='gray', linewidth=2.0, linestyle='-', marker='s', label='σ₂ (inter)' ),
+ Line2D( [ 0 ], [ 0 ], color='gray', linewidth=2.5, linestyle='-', marker='v', label='σ₃ (min)' )
]
- axes[4].legend(handles=customLines, loc='best', fontsize=fsize-3, ncol=1)
+ axes[ 4 ].legend( handles=customLines, loc='best', fontsize=fsize - 3, ncol=1 )
# Change verticale scale
- if self.config.MAX_DEPTH_PROFILES != None :
- for i in range(len(axes)):
- axes[i].set_ylim(bottom=self.config.MAX_DEPTH_PROFILES)
+ if self.config.MAX_DEPTH_PROFILES is not None:
+ for i in range( len( axes ) ):
+ axes[ i ].set_ylim( bottom=self.config.MAX_DEPTH_PROFILES )
- if self.config.MIN_DEPTH_PROFILES != None :
- for i in range(len(axes)):
- axes[i].set_ylim(top=self.config.MIN_DEPTH_PROFILES)
+ if self.config.MIN_DEPTH_PROFILES is not None:
+ for i in range( len( axes ) ):
+ axes[ i ].set_ylim( top=self.config.MIN_DEPTH_PROFILES )
# Overall title
- years = time / (365.25 * 24 * 3600)
- fig.suptitle(f'Volume Stress Profiles - Both Sides Comparison - t={years:.1f} years',
- fontsize=fsize+2, fontweight='bold', y=0.98)
+ years = time / ( 365.25 * 24 * 3600 )
+ fig.suptitle( f'Volume Stress Profiles - Both Sides Comparison - t={years:.1f} years',
+ fontsize=fsize + 2,
+ fontweight='bold',
+ y=0.98 )
- plt.tight_layout(rect=[0, 0, 1, 0.96])
+ plt.tight_layout( rect=( 0, 0, 1, 0.96 ) )
# Save
if save:
filename = f'volume_stress_profiles_both_sides_{years:.0f}y.png'
- plt.savefig(path / filename, dpi=300, bbox_inches='tight')
- print(f" 💾 Volume profiles saved: {filename}")
+ plt.savefig( path / filename, dpi=300, bbox_inches='tight' )
+ print( f" 💾 Volume profiles saved: {filename}" )
# Show
if show:
@@ -897,13 +1064,16 @@ def plotVolumeStressProfiles(self, volumeMesh, faultSurface, time, path,
plt.close()
# -------------------------------------------------------------------
- def plotAnalyticalVsNumericalComparison(self, volumeMesh, faultSurface, time, path,
- show=True, save=True,
- profileStartPoints=None,
- referenceProfileId=1):
- """
- Plot comparison between analytical fault stresses (Anderson formulas)
- and numerical tensor projection - COMBINED PLOTS ONLY
+ def plotAnalyticalVsNumericalComparison( self: Self,
+ volumeMesh: pv.PolyData,
+ faultSurface: pv.PolyData,
+ time: float,
+ path: Path,
+ show: bool = True,
+ save: bool = True,
+ profileStartPoints: list[ tuple[ int, int, int ] ] | None = None,
+ referenceProfileId: int = 1 ) -> None:
+ """Plot comparison between analytical fault stresses (Anderson formulas) and numerical tensor projection - COMBINED PLOTS ONLY.
Parameters
----------
@@ -924,109 +1094,102 @@ def plotAnalyticalVsNumericalComparison(self, volumeMesh, faultSurface, time, pa
referenceProfileId : int
Which profile ID to load from Excel reference data
"""
-
- print("\n 📊 Creating Analytical vs Numerical Comparison")
+ print( "\n 📊 Creating Analytical vs Numerical Comparison" )
# ===================================================================
# CHECK IF ANALYTICAL DATA EXISTS
# ===================================================================
- requiredAnalytical = ['sigmaNAnalytical', 'tauAnalytical', 'side', 'elementCenter']
+ requiredAnalytical = [ 'sigmaNAnalytical', 'tauAnalytical', 'side', 'elementCenter' ]
for field in requiredAnalytical:
if field not in volumeMesh.cell_data:
- print(f" ⚠️ Missing analytical field: {field}")
- print(f" Analytical stresses not computed in volume mesh")
+ print( f" ⚠️ Missing analytical field: {field}" )
+ print( " Analytical stresses not computed in volume mesh" )
return
# Check numerical data on fault surface
if 'sigmaNEffective' not in faultSurface.cell_data:
- print(f" ⚠️ Missing numerical stress data on fault surface")
+ print( " ⚠️ Missing numerical stress data on fault surface" )
return
# ===================================================================
# LOAD REFERENCE DATA (GEOS Contact Solver)
# ===================================================================
- print(" 📂 Loading GEOS Contact Solver reference data...")
- scriptDir = os.path.dirname(os.path.abspath(__file__))
- referenceData = Visualizer.loadReferenceData(
- time,
- scriptDir,
- profileId=referenceProfileId
- )
+ print( " 📂 Loading GEOS Contact Solver reference data..." )
+ scriptDir = os.path.dirname( os.path.abspath( __file__ ) )
+ referenceData = Visualizer.loadReferenceData( time, scriptDir, profileId=referenceProfileId )
- geosContactData = referenceData.get('geos', None)
+ geosContactData = referenceData.get( 'geos', None )
if geosContactData is not None:
- print(f" ✅ Loaded {len(geosContactData)} reference points from GEOS Contact Solver")
+ print( f" ✅ Loaded {len(geosContactData)} reference points from GEOS Contact Solver" )
else:
- print(f" ⚠️ No GEOS Contact Solver reference data found")
+ print( " ⚠️ No GEOS Contact Solver reference data found" )
# Extraire les IDs de faille
- faultIdsVolume = None
- faultIdsSurface = None
if 'faultId' in volumeMesh.cell_data:
- faultIdsVolume = volumeMesh.cell_data['faultId']
+ volumeMesh.cell_data[ 'faultId' ]
if 'FaultMask' in faultSurface.cell_data:
- faultIdsSurface = faultSurface.cell_data['FaultMask']
+ faultSurface.cell_data[ 'FaultMask' ]
elif 'attribute' in faultSurface.cell_data:
- faultIdsSurface = faultSurface.cell_data['attribute']
+ faultSurface.cell_data[ 'attribute' ]
# ===================================================================
# EXTRACT DATA
# ===================================================================
# Volume analytical data
- centersVolume = volumeMesh.cell_data['elementCenter']
- sideData = volumeMesh.cell_data['side']
- sigmaNAnalytical = volumeMesh.cell_data['sigmaNAnalytical']
- tauAnalytical = volumeMesh.cell_data['tauAnalytical']
+ centersVolume = volumeMesh.cell_data[ 'elementCenter' ]
+ sideData = volumeMesh.cell_data[ 'side' ]
+ sigmaNAnalytical = volumeMesh.cell_data[ 'sigmaNAnalytical' ]
+ tauAnalytical = volumeMesh.cell_data[ 'tauAnalytical' ]
# Optional: SCU if available
hasSCUAnalytical = 'SCUAnalytical' in volumeMesh.cell_data
if hasSCUAnalytical:
- SCUAnalytical = volumeMesh.cell_data['SCUAnalytical']
+ SCUAnalytical = volumeMesh.cell_data[ 'SCUAnalytical' ]
# Fault numerical data
- centersFault = faultSurface.cell_data['elementCenter']
- sigmaNNumerical = faultSurface.cell_data['sigmaNEffective']
- tauNumerical = faultSurface.cell_data['tauEffective']
+ centersFault = faultSurface.cell_data[ 'elementCenter' ]
+ sigmaNNumerical = faultSurface.cell_data[ 'sigmaNEffective' ]
+ tauNumerical = faultSurface.cell_data[ 'tauEffective' ]
# Optional: SCU numerical
hasSCUNumerical = 'SCU' in faultSurface.cell_data
if hasSCUNumerical:
- SCUNumerical = faultSurface.cell_data['SCU']
+ SCUNumerical = faultSurface.cell_data[ 'SCU' ]
# Filter volume by side
- maskPlus = (sideData == 1) | (sideData == 3)
- maskMinus = (sideData == 2) | (sideData == 3)
+ maskPlus = ( sideData == 1 ) | ( sideData == 3 )
+ maskMinus = ( sideData == 2 ) | ( sideData == 3 )
- centersPlus = centersVolume[maskPlus]
- sigmaNAnalyticalPlus = sigmaNAnalytical[maskPlus]
- tauAnalyticalPlus = tauAnalytical[maskPlus]
+ centersPlus = centersVolume[ maskPlus ]
+ sigmaNAnalyticalPlus = sigmaNAnalytical[ maskPlus ]
+ tauAnalyticalPlus = tauAnalytical[ maskPlus ]
if hasSCUAnalytical:
- SCUAnalyticalPlus = SCUAnalytical[maskPlus]
+ SCUAnalyticalPlus = SCUAnalytical[ maskPlus ]
- centersMinus = centersVolume[maskMinus]
- sigmaNAnalyticalMinus = sigmaNAnalytical[maskMinus]
- tauAnalyticalMinus = tauAnalytical[maskMinus]
+ centersMinus = centersVolume[ maskMinus ]
+ sigmaNAnalyticalMinus = sigmaNAnalytical[ maskMinus ]
+ tauAnalyticalMinus = tauAnalytical[ maskMinus ]
if hasSCUAnalytical:
- SCUAnalyticalMinus = SCUAnalytical[maskMinus]
+ SCUAnalyticalMinus = SCUAnalytical[ maskMinus ]
- print(f" 📍 Plus side: {len(centersPlus):,} cells with analytical data")
- print(f" 📍 Minus side: {len(centersMinus):,} cells with analytical data")
- print(f" 📍 Fault surface: {len(centersFault):,} cells with numerical data")
+ print( f" 📍 Plus side: {len(centersPlus):,} cells with analytical data" )
+ print( f" 📍 Minus side: {len(centersMinus):,} cells with analytical data" )
+ print( f" 📍 Fault surface: {len(centersFault):,} cells with numerical data" )
# ===================================================================
# GET FAULT BOUNDS AND PROFILE SETUP
# ===================================================================
- xMin, xMax = np.min(centersFault[:, 0]), np.max(centersFault[:, 0])
- yMin, yMax = np.min(centersFault[:, 1]), np.max(centersFault[:, 1])
- zMin, zMax = np.min(centersFault[:, 2]), np.max(centersFault[:, 2])
+ xMin, xMax = np.min( centersFault[ :, 0 ] ), np.max( centersFault[ :, 0 ] )
+ yMin, yMax = np.min( centersFault[ :, 1 ] ), np.max( centersFault[ :, 1 ] )
+ _zMin, zMax = np.min( centersFault[ :, 2 ] ), np.max( centersFault[ :, 2 ] )
xRange = xMax - xMin
yRange = yMax - yMin
@@ -1035,36 +1198,36 @@ def plotAnalyticalVsNumericalComparison(self, volumeMesh, faultSurface, time, pa
if self.config.PROFILE_SEARCH_RADIUS is not None:
searchRadius = self.config.PROFILE_SEARCH_RADIUS
else:
- searchRadius = min(xRange, yRange) * 0.2
+ searchRadius = min( xRange, yRange ) * 0.2
# Auto-generate profile points if not provided
if profileStartPoints is None:
- print(" ⚠️ No profileStartPoints provided, auto-generating...")
+ print( " ⚠️ No profileStartPoints provided, auto-generating..." )
nProfiles = 3
if xRange > yRange:
coordName = 'X'
- fixedValue = (yMin + yMax) / 2
- samplePositions = np.linspace(xMin, xMax, nProfiles)
- profileStartPoints = [(x, fixedValue, zMax) for x in samplePositions]
+ fixedValue = ( yMin + yMax ) / 2
+ samplePositions = np.linspace( xMin, xMax, nProfiles )
+ profileStartPoints = [ ( x, fixedValue, zMax ) for x in samplePositions ]
else:
coordName = 'Y'
- fixedValue = (xMin + xMax) / 2
- samplePositions = np.linspace(yMin, yMax, nProfiles)
- profileStartPoints = [(fixedValue, y, zMax) for y in samplePositions]
+ fixedValue = ( xMin + xMax ) / 2
+ samplePositions = np.linspace( yMin, yMax, nProfiles )
+ profileStartPoints = [ ( fixedValue, y, zMax ) for y in samplePositions ]
- print(f" Auto-generated {nProfiles} profiles along {coordName}")
+ print( f" Auto-generated {nProfiles} profiles along {coordName}" )
- nProfiles = len(profileStartPoints)
+ nProfiles = len( profileStartPoints )
# ===================================================================
# CREATE FIGURE: COMBINED PLOTS ONLY
# 3 columns (σ_n, τ, SCU) x 1 row
# ===================================================================
- fig, axes = plt.subplots(1, 3, figsize=(18, 10))
+ fig, axes = plt.subplots( 1, 3, figsize=( 18, 10 ) )
- print(f" 📍 Processing {nProfiles} profiles for comparison:")
+ print( f" 📍 Processing {nProfiles} profiles for comparison:" )
successfulProfiles = 0
@@ -1072,152 +1235,243 @@ def plotAnalyticalVsNumericalComparison(self, volumeMesh, faultSurface, time, pa
# EXTRACT AND PLOT PROFILES
# ===================================================================
- for i, (xPos, yPos, zPos) in enumerate(profileStartPoints):
- print(f"\n → Profile {i+1}: starting at ({xPos:.1f}, {yPos:.1f}, {zPos:.1f})")
+ for i, ( xPos, yPos, zPos ) in enumerate( profileStartPoints ):
+ print( f"\n → Profile {i+1}: starting at ({xPos:.1f}, {yPos:.1f}, {zPos:.1f})" )
# ================================================================
# PLUS SIDE - ANALYTICAL
# ================================================================
- if len(centersPlus) > 0:
- depthsSnAnaPlus, profileSnAnaPlus, _, _ = ProfileExtractor.extractAdaptiveProfile(
- centersPlus, sigmaNAnalyticalPlus, xPos, yPos, zPos,
- searchRadius, verbose=False)
-
- depthsTauAnaPlus, profileTauAnaPlus, _, _ = ProfileExtractor.extractAdaptiveProfile(
- centersPlus, tauAnalyticalPlus, xPos, yPos, zPos,
- searchRadius, verbose=False)
+ if len( centersPlus ) > 0:
+ depthsSnAnaPlus, profileSnAnaPlus, _, _ = ProfileExtractor.extractAdaptiveProfile( centersPlus,
+ sigmaNAnalyticalPlus,
+ xPos,
+ yPos,
+ zPos,
+ searchRadius,
+ verbose=False )
+
+ depthsTauAnaPlus, profileTauAnaPlus, _, _ = ProfileExtractor.extractAdaptiveProfile( centersPlus,
+ tauAnalyticalPlus,
+ xPos,
+ yPos,
+ zPos,
+ searchRadius,
+ verbose=False )
if hasSCUAnalytical:
depthsSCUAnaPlus, profileSCUAnaPlus, _, _ = ProfileExtractor.extractAdaptiveProfile(
- centersPlus, SCUAnalyticalPlus, xPos, yPos, zPos,
- searchRadius, verbose=False, )
+ centersPlus,
+ SCUAnalyticalPlus,
+ xPos,
+ yPos,
+ zPos,
+ searchRadius,
+ verbose=False,
+ )
- if len(depthsSnAnaPlus) >= 3:
+ if len( depthsSnAnaPlus ) >= 3:
# Plot σ_n
- axes[0].plot(profileSnAnaPlus, depthsSnAnaPlus,
- color='red', linestyle='-', linewidth=2,
- alpha=0.3, label='Analytical Side +' if i == 0 else '', marker=None)
+ axes[ 0 ].plot( profileSnAnaPlus,
+ depthsSnAnaPlus,
+ color='red',
+ linestyle='-',
+ linewidth=2,
+ alpha=0.3,
+ label='Analytical Side +' if i == 0 else '',
+ marker=None )
# Plot τ
- axes[1].plot(profileTauAnaPlus, depthsTauAnaPlus,
- color='red', linestyle='-', linewidth=2,
- alpha=0.3, label='Analytical Side +' if i == 0 else '', marker=None)
+ axes[ 1 ].plot( profileTauAnaPlus,
+ depthsTauAnaPlus,
+ color='red',
+ linestyle='-',
+ linewidth=2,
+ alpha=0.3,
+ label='Analytical Side +' if i == 0 else '',
+ marker=None )
# Plot SCU if available
- if hasSCUAnalytical and len(depthsSCUAnaPlus) >= 3:
- axes[2].plot(profileSCUAnaPlus, depthsSCUAnaPlus,
- color='red', linestyle='-', linewidth=2,
- alpha=0.3, label='Analytical Side +' if i == 0 else '', marker=None)
+ if hasSCUAnalytical and len( depthsSCUAnaPlus ) >= 3:
+ axes[ 2 ].plot( profileSCUAnaPlus,
+ depthsSCUAnaPlus,
+ color='red',
+ linestyle='-',
+ linewidth=2,
+ alpha=0.3,
+ label='Analytical Side +' if i == 0 else '',
+ marker=None )
# ================================================================
# MINUS SIDE - ANALYTICAL
# ================================================================
- if len(centersMinus) > 0:
+ if len( centersMinus ) > 0:
depthsSigmaNAnaMinus, profileSigmaNAnaMinus, _, _ = ProfileExtractor.extractAdaptiveProfile(
- centersMinus, sigmaNAnalyticalMinus, xPos, yPos, zPos,
- searchRadius, verbose=False)
+ centersMinus, sigmaNAnalyticalMinus, xPos, yPos, zPos, searchRadius, verbose=False )
depthsTauAnaMinus, profileTauAnaMinus, _, _ = ProfileExtractor.extractAdaptiveProfile(
- centersMinus, tauAnalyticalMinus, xPos, yPos, zPos,
- searchRadius, verbose=False)
+ centersMinus, tauAnalyticalMinus, xPos, yPos, zPos, searchRadius, verbose=False )
if hasSCUAnalytical:
depthsSCUAnaMinus, profileSCUAnaMinus, _, _ = ProfileExtractor.extractAdaptiveProfile(
- centersMinus, SCUAnalyticalMinus, xPos, yPos, zPos,
- searchRadius, verbose=False)
+ centersMinus, SCUAnalyticalMinus, xPos, yPos, zPos, searchRadius, verbose=False )
- if len(depthsSigmaNAnaMinus) >= 3:
+ if len( depthsSigmaNAnaMinus ) >= 3:
# Plot σ_n
- axes[0].plot(profileSigmaNAnaMinus, depthsSigmaNAnaMinus,
- color='blue', linestyle='-', linewidth=2,
- alpha=0.3, label='Analytical Side -' if i == 0 else '', marker=None)
+ axes[ 0 ].plot( profileSigmaNAnaMinus,
+ depthsSigmaNAnaMinus,
+ color='blue',
+ linestyle='-',
+ linewidth=2,
+ alpha=0.3,
+ label='Analytical Side -' if i == 0 else '',
+ marker=None )
# Plot τ
- axes[1].plot(profileTauAnaMinus, depthsTauAnaMinus,
- color='blue', linestyle='-', linewidth=2,
- alpha=0.3, label='Analytical Side -' if i == 0 else '', marker=None)
+ axes[ 1 ].plot( profileTauAnaMinus,
+ depthsTauAnaMinus,
+ color='blue',
+ linestyle='-',
+ linewidth=2,
+ alpha=0.3,
+ label='Analytical Side -' if i == 0 else '',
+ marker=None )
# Plot SCU if available
- if hasSCUAnalytical and len(depthsSCUAnaMinus) >= 3:
- axes[2].plot(profileSCUAnaMinus, depthsSCUAnaMinus,
- color='blue', linestyle='-', linewidth=2,
- alpha=0.3, label='Analytical Side -' if i == 0 else '', marker=None)
+ if hasSCUAnalytical and len( depthsSCUAnaMinus ) >= 3:
+ axes[ 2 ].plot( profileSCUAnaMinus,
+ depthsSCUAnaMinus,
+ color='blue',
+ linestyle='-',
+ linewidth=2,
+ alpha=0.3,
+ label='Analytical Side -' if i == 0 else '',
+ marker=None )
# ================================================================
# AVERAGES - ANALYTICAL (only for first profile to avoid clutter)
# ================================================================
- if i == 0 and len(depthsSigmaNAnaMinus) >= 3 and len(depthsSnAnaPlus) >= 3:
+ if i == 0 and len( depthsSigmaNAnaMinus ) >= 3 and len( depthsSnAnaPlus ) >= 3:
# Arithmetic average
- avgSigmaNArith = (profileSigmaNAnaMinus + profileSnAnaPlus) / 2
- avgTauArith = (profileTauAnaMinus + profileTauAnaPlus) / 2
-
- axes[0].plot(avgSigmaNArith, depthsSigmaNAnaMinus,
- color='darkorange', linestyle='-', linewidth=2,
- alpha=0.6, label='Arithmetic average')
-
- axes[1].plot(avgTauArith, depthsSigmaNAnaMinus,
- color='darkorange', linestyle='-', linewidth=2,
- alpha=0.6, label='Arithmetic average')
+ avgSigmaNArith = ( profileSigmaNAnaMinus + profileSnAnaPlus ) / 2
+ avgTauArith = ( profileTauAnaMinus + profileTauAnaPlus ) / 2
+
+ axes[ 0 ].plot( avgSigmaNArith,
+ depthsSigmaNAnaMinus,
+ color='darkorange',
+ linestyle='-',
+ linewidth=2,
+ alpha=0.6,
+ label='Arithmetic average' )
+
+ axes[ 1 ].plot( avgTauArith,
+ depthsSigmaNAnaMinus,
+ color='darkorange',
+ linestyle='-',
+ linewidth=2,
+ alpha=0.6,
+ label='Arithmetic average' )
# Geometric average
- avgTauGeom = np.sqrt(profileTauAnaMinus * profileTauAnaPlus)
+ avgTauGeom = np.sqrt( profileTauAnaMinus * profileTauAnaPlus )
- axes[1].plot(avgTauGeom, depthsSigmaNAnaMinus,
- color='purple', linestyle='-', linewidth=2,
- alpha=0.6, label='Geometric average')
+ axes[ 1 ].plot( avgTauGeom,
+ depthsSigmaNAnaMinus,
+ color='purple',
+ linestyle='-',
+ linewidth=2,
+ alpha=0.6,
+ label='Geometric average' )
# Harmonic average
- AvgSigmaNHarm = 2 / (1/profileSigmaNAnaMinus + 1/profileSnAnaPlus)
- AvgTauHarm = 2 / (1/profileTauAnaMinus + 1/profileTauAnaPlus)
-
- axes[0].plot(AvgSigmaNHarm, depthsSigmaNAnaMinus,
- color='green', linestyle='-', linewidth=2,
- alpha=0.6, label='Harmonic average')
-
- axes[1].plot(AvgTauHarm, depthsSigmaNAnaMinus,
- color='green', linestyle='-', linewidth=2,
- alpha=0.6, label='Harmonic average')
+ AvgSigmaNHarm = 2 / ( 1 / profileSigmaNAnaMinus + 1 / profileSnAnaPlus )
+ AvgTauHarm = 2 / ( 1 / profileTauAnaMinus + 1 / profileTauAnaPlus )
+
+ axes[ 0 ].plot( AvgSigmaNHarm,
+ depthsSigmaNAnaMinus,
+ color='green',
+ linestyle='-',
+ linewidth=2,
+ alpha=0.6,
+ label='Harmonic average' )
+
+ axes[ 1 ].plot( AvgTauHarm,
+ depthsSigmaNAnaMinus,
+ color='green',
+ linestyle='-',
+ linewidth=2,
+ alpha=0.6,
+ label='Harmonic average' )
# ================================================================
# NUMERICAL DATA FROM FAULT SURFACE (Continuum)
# ================================================================
- print(f" Extracting numerical data from fault surface...")
-
- depthsSigmaNNum, profileSigmaNNum, _, _ = ProfileExtractor.extractAdaptiveProfile(
- centersFault, sigmaNNumerical, xPos, yPos, zPos,
- searchRadius, verbose=False)
-
- depthsTauNum, profileTauNum, _, _ = ProfileExtractor.extractAdaptiveProfile(
- centersFault, tauNumerical, xPos, yPos, zPos,
- searchRadius, verbose=False)
+ print( " Extracting numerical data from fault surface..." )
+
+ depthsSigmaNNum, profileSigmaNNum, _, _ = ProfileExtractor.extractAdaptiveProfile( centersFault,
+ sigmaNNumerical,
+ xPos,
+ yPos,
+ zPos,
+ searchRadius,
+ verbose=False )
+
+ depthsTauNum, profileTauNum, _, _ = ProfileExtractor.extractAdaptiveProfile( centersFault,
+ tauNumerical,
+ xPos,
+ yPos,
+ zPos,
+ searchRadius,
+ verbose=False )
if hasSCUNumerical:
- depthsSCUNum, profileSCUNum, _, _ = ProfileExtractor.extractAdaptiveProfile(
- centersFault, SCUNumerical, xPos, yPos, zPos,
- searchRadius, verbose=False)
-
- if len(depthsSigmaNNum) >= 3:
+ depthsSCUNum, profileSCUNum, _, _ = ProfileExtractor.extractAdaptiveProfile( centersFault,
+ SCUNumerical,
+ xPos,
+ yPos,
+ zPos,
+ searchRadius,
+ verbose=False )
+
+ if len( depthsSigmaNNum ) >= 3:
# Plot numerical with distinct style
- axes[0].plot(profileSigmaNNum, depthsSigmaNNum,
- color='black', linestyle='-', linewidth=2,
- alpha=0.7, label='GEOS Continuum' if i == 0 else '',
- marker='x', markersize=5, markevery=3)
-
- axes[1].plot(profileTauNum, depthsTauNum,
- color='black', linestyle='-', linewidth=2,
- alpha=0.7, label='GEOS Continuum' if i == 0 else '',
- marker='x', markersize=5, markevery=3)
-
- if hasSCUNumerical and len(depthsSCUNum) >= 3:
- axes[2].plot(profileSCUNum, depthsSCUNum,
- color='black', linestyle='-', linewidth=2,
- alpha=0.7, label='GEOS Continuum' if i == 0 else '',
- marker='x', markersize=5, markevery=3)
+ axes[ 0 ].plot( profileSigmaNNum,
+ depthsSigmaNNum,
+ color='black',
+ linestyle='-',
+ linewidth=2,
+ alpha=0.7,
+ label='GEOS Continuum' if i == 0 else '',
+ marker='x',
+ markersize=5,
+ markevery=3 )
+
+ axes[ 1 ].plot( profileTauNum,
+ depthsTauNum,
+ color='black',
+ linestyle='-',
+ linewidth=2,
+ alpha=0.7,
+ label='GEOS Continuum' if i == 0 else '',
+ marker='x',
+ markersize=5,
+ markevery=3 )
+
+ if hasSCUNumerical and len( depthsSCUNum ) >= 3:
+ axes[ 2 ].plot( profileSCUNum,
+ depthsSCUNum,
+ color='black',
+ linestyle='-',
+ linewidth=2,
+ alpha=0.7,
+ label='GEOS Continuum' if i == 0 else '',
+ marker='x',
+ markersize=5,
+ markevery=3 )
successfulProfiles += 1
if successfulProfiles == 0:
- print(" ❌ No valid profiles found!")
+ print( " ❌ No valid profiles found!" )
plt.close()
return
@@ -1229,26 +1483,47 @@ def plotAnalyticalVsNumericalComparison(self, volumeMesh, faultSurface, time, pa
# Format: [Depth_m, Normal_Stress_bar, Shear_Stress_bar, SCU]
# Index: [0, 1, 2, 3]
- print(" 📊 Adding GEOS Contact Solver reference data...")
+ print( " 📊 Adding GEOS Contact Solver reference data..." )
# Normal stress
- axes[0].plot(geosContactData[:, 1], geosContactData[:, 0],
- marker='o', color='black', markersize=7,
- label='GEOS Contact Solver', linestyle='none',
- alpha=0.8, mec='black', mew=1.5, fillstyle='none')
+ axes[ 0 ].plot( geosContactData[ :, 1 ],
+ geosContactData[ :, 0 ],
+ marker='o',
+ color='black',
+ markersize=7,
+ label='GEOS Contact Solver',
+ linestyle='none',
+ alpha=0.8,
+ mec='black',
+ mew=1.5,
+ fillstyle='none' )
# Shear stress
- axes[1].plot(geosContactData[:, 2], geosContactData[:, 0],
- marker='o', color='black', markersize=7,
- label='GEOS Contact Solver', linestyle='none',
- alpha=0.8, mec='black', mew=1.5, fillstyle='none')
+ axes[ 1 ].plot( geosContactData[ :, 2 ],
+ geosContactData[ :, 0 ],
+ marker='o',
+ color='black',
+ markersize=7,
+ label='GEOS Contact Solver',
+ linestyle='none',
+ alpha=0.8,
+ mec='black',
+ mew=1.5,
+ fillstyle='none' )
# SCU (if available)
- if geosContactData.shape[1] > 3:
- axes[2].plot(geosContactData[:, 3], geosContactData[:, 0],
- marker='o', color='black', markersize=7,
- label='GEOS Contact Solver', linestyle='none',
- alpha=0.8, mec='black', mew=1.5, fillstyle='none')
+ if geosContactData.shape[ 1 ] > 3:
+ axes[ 2 ].plot( geosContactData[ :, 3 ],
+ geosContactData[ :, 0 ],
+ marker='o',
+ color='black',
+ markersize=7,
+ label='GEOS Contact Solver',
+ linestyle='none',
+ alpha=0.8,
+ mec='black',
+ mew=1.5,
+ fillstyle='none' )
# ===================================================================
# CONFIGURE PLOTS
@@ -1257,56 +1532,55 @@ def plotAnalyticalVsNumericalComparison(self, volumeMesh, faultSurface, time, pa
fsize = 14
# Plot 0: Normal Stress
- axes[0].set_xlabel('Normal Stress σₙ [bar]', fontsize=fsize, weight="bold")
- axes[0].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
- axes[0].grid(True, alpha=0.3, linestyle='--')
- axes[0].legend(loc='best', fontsize=fsize-2)
- axes[0].tick_params(labelsize=fsize-1)
+ axes[ 0 ].set_xlabel( 'Normal Stress σₙ [bar]', fontsize=fsize, weight="bold" )
+ axes[ 0 ].set_ylabel( 'Depth [m]', fontsize=fsize, weight="bold" )
+ axes[ 0 ].grid( True, alpha=0.3, linestyle='--' )
+ axes[ 0 ].legend( loc='best', fontsize=fsize - 2 )
+ axes[ 0 ].tick_params( labelsize=fsize - 1 )
# Plot 1: Shear Stress
- axes[1].set_xlabel('Shear Stress τ [bar]', fontsize=fsize, weight="bold")
- axes[1].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
- axes[1].grid(True, alpha=0.3, linestyle='--')
- axes[1].legend(loc='best', fontsize=fsize-2)
- axes[1].tick_params(labelsize=fsize-1)
+ axes[ 1 ].set_xlabel( 'Shear Stress τ [bar]', fontsize=fsize, weight="bold" )
+ axes[ 1 ].set_ylabel( 'Depth [m]', fontsize=fsize, weight="bold" )
+ axes[ 1 ].grid( True, alpha=0.3, linestyle='--' )
+ axes[ 1 ].legend( loc='best', fontsize=fsize - 2 )
+ axes[ 1 ].tick_params( labelsize=fsize - 1 )
# Plot 2: SCU
- axes[2].set_xlabel('SCU [-]', fontsize=fsize, weight="bold")
- axes[2].set_ylabel('Depth [m]', fontsize=fsize, weight="bold")
- axes[2].axvline(x=0.8, color='forestgreen', linestyle='--', linewidth=2,
- alpha=0.5, label='Critical (0.8)')
- axes[2].axvline(x=1.0, color='red', linestyle='--', linewidth=2,
- alpha=0.5, label='Failure (1.0)')
- axes[2].grid(True, alpha=0.3, linestyle='--')
- axes[2].legend(loc='upper right', fontsize=fsize-2, ncol=1)
- axes[2].tick_params(labelsize=fsize-1)
- axes[2].set_xlim(left=0)
+ axes[ 2 ].set_xlabel( 'SCU [-]', fontsize=fsize, weight="bold" )
+ axes[ 2 ].set_ylabel( 'Depth [m]', fontsize=fsize, weight="bold" )
+ axes[ 2 ].axvline( x=0.8, color='forestgreen', linestyle='--', linewidth=2, alpha=0.5, label='Critical (0.8)' )
+ axes[ 2 ].axvline( x=1.0, color='red', linestyle='--', linewidth=2, alpha=0.5, label='Failure (1.0)' )
+ axes[ 2 ].grid( True, alpha=0.3, linestyle='--' )
+ axes[ 2 ].legend( loc='upper right', fontsize=fsize - 2, ncol=1 )
+ axes[ 2 ].tick_params( labelsize=fsize - 1 )
+ axes[ 2 ].set_xlim( left=0 )
# Overall title
- years = time / (365.25 * 24 * 3600)
- fig.suptitle(f'Analytical (Anderson) vs Numerical (GEOS Continuum & Contact) - t={years:.1f} years',
- fontsize=fsize+2, fontweight='bold', y=0.995)
+ years = time / ( 365.25 * 24 * 3600 )
+ fig.suptitle( f'Analytical (Anderson) vs Numerical (GEOS Continuum & Contact) - t={years:.1f} years',
+ fontsize=fsize + 2,
+ fontweight='bold',
+ y=0.995 )
# Change verticale scale
- if self.config.MAX_DEPTH_PROFILES != None :
- for i in range(len(axes)):
- axes[i].set_ylim(bottom=self.config.MAX_DEPTH_PROFILES)
+ if self.config.MAX_DEPTH_PROFILES is not None:
+ for i in range( len( axes ) ):
+ axes[ i ].set_ylim( bottom=self.config.MAX_DEPTH_PROFILES )
- if self.config.MIN_DEPTH_PROFILES != None :
- for i in range(len(axes)):
- axes[i].set_ylim(top=self.config.MIN_DEPTH_PROFILES)
+ if self.config.MIN_DEPTH_PROFILES is not None:
+ for i in range( len( axes ) ):
+ axes[ i ].set_ylim( top=self.config.MIN_DEPTH_PROFILES )
- plt.tight_layout(rect=[0, 0, 1, 0.99])
+ plt.tight_layout( rect=( 0, 0, 1, 0.99 ) )
# Save
if save:
filename = f'analytical_vs_numerical_comparison_{years:.0f}y.png'
- plt.savefig(path / filename, dpi=300, bbox_inches='tight')
- print(f"\n 💾 Comparison plot saved: {filename}")
+ plt.savefig( path / filename, dpi=300, bbox_inches='tight' )
+ print( f"\n 💾 Comparison plot saved: {filename}" )
# Show
if show:
plt.show()
else:
plt.close()
-
From 7520ebaf41c5298dfde997583f79bb4b838d3345 Mon Sep 17 00:00:00 2001
From: Paloma Martinez <104762252+paloma-martinez@users.noreply.github.com>
Date: Wed, 11 Feb 2026 11:10:40 +0100
Subject: [PATCH 4/5] Removing Config due to problematic circular imports
---
.../post_processing/FaultGeometry.py | 67 ++--
.../post_processing/FaultStabilityAnalysis.py | 299 +++---------------
.../processing/post_processing/MohrCoulomb.py | 102 ++++++
.../post_processing/SensitivityAnalyzer.py | 31 +-
.../post_processing/StressProjector.py | 79 +++--
.../geos/processing/tools/FaultVisualizer.py | 88 +++---
6 files changed, 286 insertions(+), 380 deletions(-)
create mode 100644 geos-processing/src/geos/processing/post_processing/MohrCoulomb.py
diff --git a/geos-processing/src/geos/processing/post_processing/FaultGeometry.py b/geos-processing/src/geos/processing/post_processing/FaultGeometry.py
index 59bc95d2..5dba1941 100644
--- a/geos-processing/src/geos/processing/post_processing/FaultGeometry.py
+++ b/geos-processing/src/geos/processing/post_processing/FaultGeometry.py
@@ -9,24 +9,27 @@
from pathlib import Path
from typing_extensions import Self, Any
from vtkmodules.vtkCommonDataModel import vtkCellLocator
-from vtkmodules.vtkCommonDataModel import vtkIdList
+# from vtkmodules.vtkCommonDataModel import vtkIdList
import numpy.typing as npt
from scipy.spatial import cKDTree
-from geos.processing.FaultStabilityAnalysis import Config
+
+__doc__="""
+
+
+"""
class FaultGeometry:
"""Handles fault surface extraction and normal computation with optimizations."""
# -------------------------------------------------------------------
- def __init__( self: Self, config: Config, mesh: pv.DataSet, faultValues: list[ int ], faultAttribute: str,
- volumeMesh: pv.DataSet ) -> None:
+ def __init__( self: Self, mesh: pv.DataSet, faultValues: list[ int ], faultAttribute: str,
+ volumeMesh: pv.DataSet, outputDir: str = "." ) -> None:
"""Initialize fault geometry with pre-computed topology.
Args:
- config (Config):
- mesh (pv.DataSet): pv.read(path / config.GRID_FILE) -> "mesh_faulted_reservoir_60_mod.vtu"
+ mesh (pv.DataSet):
faultValues (list[int]): Config.FAULT_VALUES
faultAttribute (str): Config.FAULT_ATTRIBUTES
volumeMesh (pv.DataSet): processor._merge_blocks(dataset)
@@ -51,17 +54,23 @@ def __init__( self: Self, config: Config, mesh: pv.DataSet, faultValues: list[ i
self.faultTree = None # KDTree for fault surface
# Config
- self.config = config
+ # self.config = config
+ self.outputDir = Path( outputDir )
+ self.outputDir.mkdir( parents=True, exist_ok=True )
# -------------------------------------------------------------------
def initialize( self: Self,
scaleFactor: float = 50.0,
- processFaultsSeparately: bool = True ) -> tuple[ pv.DataSet, dict[ int, pv.DataSet ] ]:
+ processFaultsSeparately: bool = True,
+ showPlot: bool = True,
+ zscale: float = 1.0,
+ showContributionViz: bool = True,
+ saveContributionCells:bool = True ) -> tuple[ pv.DataSet, dict[ int, pv.DataSet ] ]:
"""One-time initialization: compute normals, adjacency topology, and geometric properties."""
# Extract and compute normals
- self.faultSurface, self.surfaces = self._extractAndComputeNormals( showPlot=self.config.SHOW_NORMAL_PLOTS,
+ self.faultSurface, self.surfaces = self._extractAndComputeNormals( showPlot=showPlot,
scaleFactor=scaleFactor,
- zScale=self.config.Z_SCALE )
+ zScale=zscale )
# Pre-compute adjacency mapping
print( "\n🔍 Pre-computing volume-fault adjacency topology" )
@@ -71,7 +80,7 @@ def initialize( self: Self,
processFaultsSeparately=processFaultsSeparately )
# Mark and optionally save contributing cells
- self._markContributingCells()
+ self._markContributingCells( saveContributionCells )
# NEW: Pre-compute geometric properties
self._precomputeGeometricProperties()
@@ -85,13 +94,13 @@ def initialize( self: Self,
print( f" - {nWithBoth} cells have neighbors on both sides" )
# Visualize contributions if requested
- if self.config.SHOW_CONTRIBUTION_VIZ:
+ if showContributionViz:
self._visualizeContributions()
return self.faultSurface, self.adjacencyMapping
# -------------------------------------------------------------------
- def _markContributingCells( self: Self ) -> None:
+ def _markContributingCells( self: Self, saveContributionCells: bool = True ) -> None:
"""Mark volume cells that contribute to fault stress projection."""
print( "\n📦 Marking contributing volume cells..." )
@@ -143,7 +152,7 @@ def _markContributingCells( self: Self ) -> None:
print( f" Both sides: {nBoth} cells" )
# Save to files if requested
- if self.config.SAVE_CONTRIBUTION_CELLS:
+ if saveContributionCells:
self._saveContributingCells()
# -------------------------------------------------------------------
@@ -153,8 +162,7 @@ def _saveContributingCells( self: Self ) -> None:
Saves three files: all, plus side, minus side.
"""
# Create output directory if it doesn't exist
- outputDir = Path( self.config.OUTPUT_DIR ) if hasattr( self.config, 'OUTPUT_DIR' ) else Path( '.' )
- outputDir.mkdir( parents=True, exist_ok=True )
+ outputDir = self.outputDir
# Save all contributing cells
filenameAll = outputDir / "contributing_cells_all.vtu"
@@ -360,10 +368,7 @@ def _findFaceSharingCells( self: Self, faultSurface: pv.DataSet ) -> pv.DataSet:
# -------------------------------------------------------------------
def _testEpsilon( self: Self, faultSurface: pv.DataSet, locator: vtkCellLocator, epsilon: list[ float ],
- faultCenters, faultNormals: npt.NDArray[ np.float64 ], volCenters ) -> tuple[ dict[
- int,
- dict[ str, list[ vtkIdList ] ],
- ], dict[ str, Any ] ]:
+ faultCenters, faultNormals: npt.NDArray[ np.float64 ], volCenters ):
"""Test a specific epsilon value and return mapping + statistics.
Statistics include:
@@ -391,7 +396,7 @@ def _testEpsilon( self: Self, faultSurface: pv.DataSet, locator: vtkCellLocator,
# Search on PLUS side
pointPlus = fcenter + epsilon * fnormal
cellIdPlus = locator.FindCell( pointPlus )
- print( cellIdPlus )
+ # print( cellIdPlus )
if cellIdPlus >= 0:
plusCells.append( cellIdPlus )
@@ -430,7 +435,7 @@ def _testEpsilon( self: Self, faultSurface: pv.DataSet, locator: vtkCellLocator,
return mapping, stats
# -------------------------------------------------------------------
- def _visualizeContributions( self ) -> None:
+ def _visualizeContributions( self: Self, zscale: float = 1.0, showPlots:bool = True ) -> None:
"""Unified visualization of volume contributions to fault surfaces.
4-panel view combining full context, side classification, clip, and slice.
@@ -452,7 +457,7 @@ def _visualizeContributions( self ) -> None:
plotter.add_legend( loc="upper left" )
plotter.add_axes()
- plotter.set_scale( zscale=self.config.Z_SCALE )
+ plotter.set_scale( zscale=zscale )
# ========== PLOT 2: Contributing cells by side (top-right) ==========
plotter.subplot( 0, 1 )
@@ -480,7 +485,7 @@ def _visualizeContributions( self ) -> None:
plotter.add_legend( loc='upper right' )
plotter.add_axes()
- plotter.set_scale( zscale=self.config.Z_SCALE )
+ plotter.set_scale( zscale=zscale )
# ========== PLOT 3: Clipped view (bottom-left) ==========
plotter.subplot( 1, 0 )
@@ -506,7 +511,7 @@ def _visualizeContributions( self ) -> None:
plotter.add_legend( loc='upper left' )
plotter.add_axes()
- plotter.set_scale( zscale=self.config.Z_SCALE )
+ plotter.set_scale( zscale=zscale )
# ========== PLOT 4: Slice view (bottom-right) ==========
plotter.subplot( 1, 1 )
@@ -562,20 +567,18 @@ def _visualizeContributions( self ) -> None:
plotter.add_legend( loc='upper right' )
plotter.add_axes()
- plotter.set_scale( zscale=self.config.Z_SCALE )
+ plotter.set_scale( zscale=zscale )
plotter.view_xy()
# Link all views for synchronized rotation
plotter.link_views()
# Show or save
- if self.config.SHOW_PLOTS:
+ if showPlots:
plotter.show()
else:
# Save screenshot
-
- outputDir = Path( self.config.OUTPUT_DIR ) if hasattr( self.config, 'OUTPUT_DIR' ) else Path( '.' )
- outputDir.mkdir( parents=True, exist_ok=True )
+ outputDir = self.outputDir
screenshot_path = outputDir / "contribution_visualization.png"
plotter.screenshot( str( screenshot_path ) )
print( f" 💾 Visualization saved: {screenshot_path}" )
@@ -622,7 +625,7 @@ def _extractAndComputeNormals( self: Self,
return merged, surfaces
# -------------------------------------------------------------------
- def _orientNormals( self: Self, surf: pv.DataSet ) -> pv.DataSet:
+ def _orientNormals( self: Self, surf: pv.PolyData, rotateNormals: bool = False ) -> pv.DataSet:
"""Ensure normals point in consistent direction within the fault."""
normals = surf.cell_data[ 'Normals' ]
meanNormal = np.mean( normals, axis=0 )
@@ -638,7 +641,7 @@ def _orientNormals( self: Self, surf: pv.DataSet ) -> pv.DataSet:
if np.dot( normal, meanNormal ) < 0:
normals[ i ] = -normal
- if self.config.ROTATE_NORMALS:
+ if rotateNormals:
normals[ i ] = -normal
# Compute orthogonal tangents
diff --git a/geos-processing/src/geos/processing/post_processing/FaultStabilityAnalysis.py b/geos-processing/src/geos/processing/post_processing/FaultStabilityAnalysis.py
index db1042c0..b0f1584c 100755
--- a/geos-processing/src/geos/processing/post_processing/FaultStabilityAnalysis.py
+++ b/geos-processing/src/geos/processing/post_processing/FaultStabilityAnalysis.py
@@ -4,177 +4,13 @@
from pathlib import Path
import numpy as np
import pyvista as pv
-import pyfiglet
from typing_extensions import Self
from geos.processing.post_processing.FaultGeometry import FaultGeometry
-from geos.processing.post_processing.Visualizer import Visualizer
+from geos.processing.tools.FaultVisualizer import Visualizer
from geos.processing.post_processing.SensitivityAnalyzer import SensitivityAnalyzer
from geos.processing.post_processing.StressProjector import StressProjector
-
-# ============================================================================
-# CONFIGURATION
-# ============================================================================
-
-
-class Config:
- """Configuration parameters for fault analysis."""
-
- # Mechanical parameters
- FRICTION_ANGLE: float = 12 # [degrees]
- COHESION: float = 0 # [bar]
-
- # Normal orientation
- ROTATE_NORMALS: bool = False # Rotate normals and tangents from 180°
-
- # Sensitivity analysis
- RUN_SENSITIVITY: bool = True # Enable sensitivity analysis
- SENSITIVITY_FRICTION_ANGLES: list[ float ] = [ 12, 15, 18, 20, 22, 25 ] # degrees
- SENSITIVITY_COHESIONS: list[ float ] = [ 0, 1, 2, 5, 10 ] # bar
-
- # Visualization
- Z_SCALE = 1.0
- SHOW_NORMAL_PLOTS = True # Show the mesh grid and normals at fault planes
- SHOW_CONTRIBUTION_VIZ = True # Show volume contribution visualization (first timestep only)
- SHOW_DEPTH_PROFILES = True # Active les profils verticaux
- N_DEPTH_PROFILES = 1 # Nombre de lignes verticales
-
- MIN_DEPTH_PROFILES = None
- MAX_DEPTH_PROFILES = None
- SHOW_PLOTS = True # Set to False to skip interactive plots
- SAVE_PLOTS = True # Set to False to skip saving plots
- SAVE_CONTRIBUTION_CELLS = True # Save vtu contributive cells
- WEIGHTING_SCHEME = "arithmetic"
-
- COMPUTE_PRINCIPAL_STRESS = False
- SHOW_PROFILE_EXTRACTOR = True
-
- PROFILE_START_POINTS = [ ( 2282.61, 1040, 0 ) ] # Profile Fault 1
-
- PROFILE_SEARCH_RADIUS = None
-
- # Time series - List of time indices to process (None = all)
- TIME_INDEX = [ 0, -1 ]
-
- # File paths
- PATH = ""
- GRID_FILE = "mesh_faulted_reservoir_60_mod.vtu"
- PVD_FILE = "faultModel.pvd"
-
- # Variable names
- STRESS_NAME = "averageStress"
- BIOT_NAME = "rockPorosity_biotCoefficient"
-
- # Faults attributes
- FAULT_ATTRIBUTE = "Fault"
- FAULT_VALUES = [ 1 ]
-
- # Output
- OUTPUT_DIR = "Processed_Fault_Analysis"
- SENSITIVITY_OUTPUT_DIR = "Processed_Fault_Analysis/Sensitivity_Analysis"
-
-
-# ============================================================================
-# MOHR COULOMB
-# ============================================================================
-class MohrCoulomb:
- """Mohr-Coulomb failure criterion analysis."""
-
- @staticmethod
- # def analyze( surface: pv.DataSet, cohesion: float, frictionAngleDeg: float, time=0, verbose=True ):
- def analyze( surface: pv.DataSet, cohesion: float, frictionAngleDeg: float, verbose: bool = True ) -> pv.DataSet:
- """Perform Mohr-Coulomb stability analysis.
-
- Parameters:
- surface: fault surface with stress data
- cohesion: cohesion in bar
- frictionAngleDeg: friction angle in degrees
- verbose: print statistics
- """
- mu = np.tan( np.radians( frictionAngleDeg ) )
-
- # Extract stress components
- sigmaN = surface.cell_data[ "sigmaNEffective" ]
- tau = surface.cell_data[ "tauEffective" ]
- surface.cell_data[ 'deltaSigmaNEffective' ]
- surface.cell_data[ 'deltaTauEffective' ]
-
- # Mohr-Coulomb failure envelope
- tauCritical = cohesion - sigmaN * mu
-
- # Coulomb Failure Stress
- CFS = tau - mu * sigmaN
- # deltaCFS = deltaTau - mu * deltaSigmaN
-
- # Shear Capacity Utilization: SCU = τ / τ_crit
- SCU = np.divide( tau, tauCritical, out=np.zeros_like( tau ), where=tauCritical != 0 )
-
- if "SCUInitial" not in surface.cell_data:
- # First timestep: store as initial reference
- SCUInitial = SCU.copy()
- CFSInitial = CFS.copy()
- deltaSCU = np.zeros_like( SCU )
- deltaCFS = np.zeros_like( CFS )
-
- surface.cell_data[ "SCUInitial" ] = SCUInitial
- surface.cell_data[ "CFSInitial" ] = CFSInitial
-
- isInitial = True
- else:
- # Subsequent timesteps: calculate change from initial
- SCUInitial = surface.cell_data[ "SCUInitial" ]
- CFSInitial = surface.cell_data[ 'CFSInitial' ]
- deltaSCU = SCU - SCUInitial
- deltaCFS = CFS - CFSInitial
- isInitial = False
-
- # Stability classification
- stability = np.zeros_like( tau, dtype=int )
- stability[ SCU >= 0.8 ] = 1 # Critical
- stability[ SCU >= 1.0 ] = 2 # Unstable
-
- # Failure probability (sigmoid)
- k = 10.0
- failureProba = 1.0 / ( 1.0 + np.exp( -k * ( SCU - 1.0 ) ) )
-
- # Safety margin
- safety = tauCritical - tau
-
- # Store results
- surface.cell_data.update( {
- "mohrCohesion": np.full( surface.n_cells, cohesion ),
- "mohrFrictionAngle": np.full( surface.n_cells, frictionAngleDeg ),
- "mohrFrictionCoefficient": np.full( surface.n_cells, mu ),
- "mohr_critical_shear_stress": tauCritical,
- "SCU": SCU,
- "deltaSCU": deltaSCU,
- "CFS": CFS,
- "deltaCFS": deltaCFS,
- "safetyMargin": safety,
- "stabilityState": stability,
- "failureProbability": failureProba
- } )
-
- if verbose:
- nStable = np.sum( stability == 0 )
- nCritical = np.sum( stability == 1 )
- nUnstable = np.sum( stability == 2 )
-
- # Additional info on deltaSCU
- if not isInitial:
- meanDelta = np.mean( np.abs( deltaSCU ) )
- maxIncrease = np.max( deltaSCU )
- maxDecrease = np.min( deltaSCU )
- print( f" ✅ Mohr-Coulomb: {nUnstable} unstable, {nCritical} critical, "
- f"{nStable} stable cells" )
- print( f" ΔSCU: mean={meanDelta:.3f}, maxIncrease={maxIncrease:.3f}, "
- f"maxDecrease={maxDecrease:.3f}" )
- else:
- print( f" ✅ Mohr-Coulomb (initial): {nUnstable} unstable, {nCritical} critical, "
- f"{nStable} stable cells" )
-
- return surface
-
+from geos.processing.post_processing.MohrCoulomb import MohrCoulomb
# ============================================================================
# TIME SERIES PROCESSING
@@ -183,14 +19,16 @@ class TimeSeriesProcessor:
"""Process multiple time steps from PVD file."""
# -------------------------------------------------------------------
- def __init__( self: Self, config: Config ) -> None:
+ def __init__( self: Self, outputDir: str = ".", showPlots: bool = True, savePlots: bool = True ) -> None:
"""Init."""
- self.config = config
- self.outputDir = Path( config.OUTPUT_DIR )
+ self.outputDir = Path( outputDir )
self.outputDir.mkdir( exist_ok=True )
+ self.showPlots: bool = showPlots
+ self.savePlots: bool = savePlots
+
# -------------------------------------------------------------------
- def process( self: Self, path: Path, faultGeometry: FaultGeometry, pvdFile: str ) -> pv.DataSet:
+ def process( self: Self, path: Path, faultGeometry: FaultGeometry, pvdFile: str, timeIndexes: list[ int ] = [], weightingScheme: str = "arithmetic", cohesion: float = 0, frictionAngle: float = 10, runSensitivity: bool = True, profileStartPoints: list[tuple[ float, ...]] = [], computePrincipalStress: bool = True, showDepthProfiles: bool = True, stressName: str = "averageStress", biotCoefficient: str = "rockPorosity_biotCoefficient", profileSearchRadius=None, minDepthProfiles=None, maxDepthProfiles=None ) -> pv.DataSet:
"""Process all time steps using pre-computed fault geometry.
Parameters:
@@ -199,10 +37,10 @@ def process( self: Self, path: Path, faultGeometry: FaultGeometry, pvdFile: str
pvdFile: PVD file name
"""
pvdReader = pv.PVDReader( path / pvdFile )
- timeValues = np.array( pvdReader.timeValues )
+ timeValues = np.array( pvdReader.time_values )
- if self.config.TIME_INDEX:
- timeValues = timeValues[ self.config.TIME_INDEX ]
+ if timeIndexes:
+ timeValues = timeValues[ timeIndexes ]
outputFiles = []
dataInitial = None
@@ -213,7 +51,7 @@ def process( self: Self, path: Path, faultGeometry: FaultGeometry, pvdFile: str
geometricProperties = faultGeometry.getGeometricProperties()
# Initialize projector with pre-computed topology
- projector = StressProjector( self.config, adjacencyMapping, geometricProperties )
+ projector = StressProjector( adjacencyMapping, geometricProperties, self.outputDir )
print( '\n' )
print( "=" * 60 )
@@ -224,7 +62,7 @@ def process( self: Self, path: Path, faultGeometry: FaultGeometry, pvdFile: str
print( f"\n→ Step {i+1}/{len(timeValues)}: {time/(365.25*24*3600):.2f} years" )
# Read time step
- idx = self.config.TIME_INDEX[ i ] if self.config.TIME_INDEX else i
+ idx = timeIndexes[ i ] if timeIndexes else i
pvdReader.set_active_time_point( idx )
dataset = pvdReader.read()
@@ -244,25 +82,28 @@ def process( self: Self, path: Path, faultGeometry: FaultGeometry, pvdFile: str
surface,
time=timeValues[ i ], # Simulation time
timestep=i, # Timestep index
- weightingScheme=self.config.WEIGHTING_SCHEME )
+ stressName=stressName,
+ biotName=biotCoefficient,
+ weightingScheme=weightingScheme )
# -----------------------------------
# Mohr-Coulomb analysis
# -----------------------------------
- cohesion = self.config.COHESION
- frictionAngle = self.config.FRICTION_ANGLE
+ cohesion = cohesion
+ frictionAngle = frictionAngle
surfaceResult = MohrCoulomb.analyze( surfaceResult, cohesion, frictionAngle ) #, time )
# -----------------------------------
# Visualize
# -----------------------------------
- self._plotResults( surfaceResult, contributingCells, time, self.outputDir )
+ self._plotResults( surfaceResult, contributingCells, time, self.outputDir, profileStartPoints, computePrincipalStress, showDepthProfiles,
+ profileSearchRadius, minDepthProfiles, maxDepthProfiles )
# -----------------------------------
# Sensitivity analysis
# -----------------------------------
- if self.config.RUN_SENSITIVITY:
- analyzer = SensitivityAnalyzer( self.config )
+ if runSensitivity:
+ analyzer = SensitivityAnalyzer( self.outputDir, self.showPlots )
analyzer.runAnalysis( surfaceResult, time )
# Save
@@ -277,7 +118,8 @@ def process( self: Self, path: Path, faultGeometry: FaultGeometry, pvdFile: str
return surfaceResult
# -------------------------------------------------------------------
- def _mergeBlocks( self, dataset: pv.DataSet ) -> pv.DataSet:
+ @staticmethod
+ def _mergeBlocks( dataset: pv.DataSet ) -> pv.UnstructuredGrid:
"""Merge multi-block dataset - descente automatique jusqu'aux données."""
# -----------------------------------------------
@@ -336,43 +178,49 @@ def extractLeafBlocks(
return combined
# -------------------------------------------------------------------
- def _plotResults( self, surface: pv.DataSet, contributingCells: pv.DataSet, time: list[ int ],
- path: str ) -> None: # TODO check type surface
+ def _plotResults( self, surface: pv.PolyData, contributingCells: pv.DataSet, time: list[ int ],
+ path: str, profileStartPoints: list[tuple[float, ...]], computePrincipalStress: bool = True, showDepthProfiles:bool = True,
+ profileSearchRadius: float|None=None, minDepthProfiles: float | None = None,
+ maxDepthProfiles: float | None = None, ) -> None: # TODO check type surface
Visualizer.plotMohrCoulombDiagram( surface,
time,
path,
- show=self.config.SHOW_PLOTS,
- save=self.config.SAVE_PLOTS )
+ show=self.showPlots,
+ save=self.savePlots, )
+
# Profils verticaux automatiques
- if self.config.SHOW_DEPTH_PROFILES:
- Visualizer.plotDepthProfiles( self,
- surface,
- time,
- path,
- show=self.config.SHOW_PLOTS,
- save=self.config.SAVE_PLOTS,
- profileStartPoints=self.config.PROFILE_START_POINTS )
+ if showDepthProfiles:
+ Visualizer( profileSearchRadius).plotDepthProfiles( surface=surface,
+ time=time,
+ path=path,
+ show=self.showPlots,
+ save=self.savePlots,
+ profileStartPoints=profileStartPoints)
+
- visualizer = Visualizer( self.config )
+ visualizer = Visualizer( profileSearchRadius,
+ minDepthProfiles,
+ maxDepthProfiles,
+ showPlots = self.showPlots, savePlots = self.savePlots )
- if self.config.COMPUTE_PRINCIPAL_STRESS:
+ if computePrincipalStress:
# Plot principal stress from volume cells
visualizer.plotVolumeStressProfiles( volumeMesh=contributingCells,
faultSurface=surface,
time=time,
path=path,
- profileStartPoints=self.config.PROFILE_START_POINTS )
+ profileStartPoints=profileStartPoints )
# Visualize comparison analytical/numerical
visualizer.plotAnalyticalVsNumericalComparison( volumeMesh=contributingCells,
faultSurface=surface,
time=time,
path=path,
- show=self.config.SHOW_PLOTS,
- save=self.config.SAVE_PLOTS,
- profileStartPoints=self.config.PROFILE_START_POINTS )
+ show=self.showPlots,
+ save=self.savePlots,
+ profileStartPoints=profileStartPoints )
# -------------------------------------------------------------------
def _createPVD( self, outputFiles: list[ tuple[ int, str ] ] ) -> None:
@@ -388,54 +236,3 @@ def _createPVD( self, outputFiles: list[ tuple[ int, str ] ] ) -> None:
print( f"\n✅ PVD created: {pvdPath}" )
-# ============================================================================
-# MAIN
-# ============================================================================
-def main() -> None:
- """Main execution function."""
- config = Config()
-
- print( "=" * 62 )
- ascii_banner = pyfiglet.figlet_format( "Fault Analysis" )
- print( ascii_banner )
- print( "=" * 62 )
-
- path = Path( config.PATH )
-
- # Load fault geometry
- mesh = pv.read( path / config.GRID_FILE )
- print( f"✅ Mesh loaded: {config.GRID_FILE} | {mesh.n_cells} cells" )
-
- # Read first volume dataset
- pvdReader = pv.PVDReader( path / config.PVD_FILE )
- pvdReader.set_active_time_point( 0 )
- dataset = pvdReader.read()
-
- # IMPORTANT : Utiliser le même merge que dans la boucle
- processor = TimeSeriesProcessor( config )
- volumeMesh = processor._mergeBlocks( dataset )
- print( f"✅ Volume mesh extracted: {volumeMesh.n_cells} cells" )
-
- # Initialize fault geometry with topology pre-computation
- print( "\n📐 Initialize fault geometry" )
- faultGeometry = FaultGeometry( config=config,
- mesh=mesh,
- faultValues=config.FAULT_VALUES,
- faultAttribute=config.FAULT_ATTRIBUTE,
- volumeMesh=volumeMesh )
-
- # Compute normals and adjacency topology (done once!)
- print( "🔧 Computing normals and adjacency topology" )
- faultSurface, adjacencyMapping = faultGeometry.initialize( scaleFactor=50.0 )
-
- # Process time series
- processor = TimeSeriesProcessor( config )
- processor.process( path, faultGeometry, config.PVD_FILE )
-
- print( "\n" + "=" * 60 )
- print( "✅ ANALYSIS COMPLETE" )
- print( "=" * 60 )
-
-
-if __name__ == "__main__":
- main()
diff --git a/geos-processing/src/geos/processing/post_processing/MohrCoulomb.py b/geos-processing/src/geos/processing/post_processing/MohrCoulomb.py
new file mode 100644
index 00000000..ffda01c2
--- /dev/null
+++ b/geos-processing/src/geos/processing/post_processing/MohrCoulomb.py
@@ -0,0 +1,102 @@
+import numpy as np
+import pyvista as pv
+# ============================================================================
+# MOHR COULOMB
+# ============================================================================
+class MohrCoulomb:
+ """Mohr-Coulomb failure criterion analysis."""
+
+ @staticmethod
+ def analyze( surface: pv.DataSet, cohesion: float, frictionAngleDeg: float, verbose: bool = True ) -> pv.DataSet:
+ """Perform Mohr-Coulomb stability analysis.
+
+ Parameters:
+ surface: fault surface with stress data
+ cohesion: cohesion in bar
+ frictionAngleDeg: friction angle in degrees
+ verbose: print statistics
+ """
+ mu = np.tan( np.radians( frictionAngleDeg ) )
+
+ # Extract stress components
+ sigmaN = surface.cell_data[ "sigmaNEffective" ]
+ tau = surface.cell_data[ "tauEffective" ]
+ surface.cell_data[ 'deltaSigmaNEffective' ]
+ surface.cell_data[ 'deltaTauEffective' ]
+
+ # Mohr-Coulomb failure envelope
+ tauCritical = cohesion - sigmaN * mu
+
+ # Coulomb Failure Stress
+ CFS = tau - mu * sigmaN
+ # deltaCFS = deltaTau - mu * deltaSigmaN
+
+ # Shear Capacity Utilization: SCU = τ / τ_crit
+ SCU = np.divide( tau, tauCritical, out=np.zeros_like( tau ), where=tauCritical != 0 )
+
+ if "SCUInitial" not in surface.cell_data:
+ # First timestep: store as initial reference
+ SCUInitial = SCU.copy()
+ CFSInitial = CFS.copy()
+ deltaSCU = np.zeros_like( SCU )
+ deltaCFS = np.zeros_like( CFS )
+
+ surface.cell_data[ "SCUInitial" ] = SCUInitial
+ surface.cell_data[ "CFSInitial" ] = CFSInitial
+
+ isInitial = True
+ else:
+ # Subsequent timesteps: calculate change from initial
+ SCUInitial = surface.cell_data[ "SCUInitial" ]
+ CFSInitial = surface.cell_data[ 'CFSInitial' ]
+ deltaSCU = SCU - SCUInitial
+ deltaCFS = CFS - CFSInitial
+ isInitial = False
+
+ # Stability classification
+ stability = np.zeros_like( tau, dtype=int )
+ stability[ SCU >= 0.8 ] = 1 # Critical
+ stability[ SCU >= 1.0 ] = 2 # Unstable
+
+ # Failure probability (sigmoid)
+ k = 10.0
+ failureProba = 1.0 / ( 1.0 + np.exp( -k * ( SCU - 1.0 ) ) )
+
+ # Safety margin
+ safety = tauCritical - tau
+
+ # Store results
+ surface.cell_data.update( {
+ "mohrCohesion": np.full( surface.n_cells, cohesion ),
+ "mohrFrictionAngle": np.full( surface.n_cells, frictionAngleDeg ),
+ "mohrFrictionCoefficient": np.full( surface.n_cells, mu ),
+ "mohr_critical_shear_stress": tauCritical,
+ "SCU": SCU,
+ "deltaSCU": deltaSCU,
+ "CFS": CFS,
+ "deltaCFS": deltaCFS,
+ "safetyMargin": safety,
+ "stabilityState": stability,
+ "failureProbability": failureProba
+ } )
+
+ if verbose:
+ nStable = np.sum( stability == 0 )
+ nCritical = np.sum( stability == 1 )
+ nUnstable = np.sum( stability == 2 )
+
+ # Additional info on deltaSCU
+ if not isInitial:
+ meanDelta = np.mean( np.abs( deltaSCU ) )
+ maxIncrease = np.max( deltaSCU )
+ maxDecrease = np.min( deltaSCU )
+ print( f" ✅ Mohr-Coulomb: {nUnstable} unstable, {nCritical} critical, "
+ f"{nStable} stable cells" )
+ print( f" ΔSCU: mean={meanDelta:.3f}, maxIncrease={maxIncrease:.3f}, "
+ f"maxDecrease={maxDecrease:.3f}" )
+ else:
+ print( f" ✅ Mohr-Coulomb (initial): {nUnstable} unstable, {nCritical} critical, "
+ f"{nStable} stable cells" )
+
+ return surface
+
diff --git a/geos-processing/src/geos/processing/post_processing/SensitivityAnalyzer.py b/geos-processing/src/geos/processing/post_processing/SensitivityAnalyzer.py
index b0bcb0ea..f4c72d31 100644
--- a/geos-processing/src/geos/processing/post_processing/SensitivityAnalyzer.py
+++ b/geos-processing/src/geos/processing/post_processing/SensitivityAnalyzer.py
@@ -13,7 +13,8 @@
import pyvista as pv
from typing_extensions import Any, Self
-from geos.processing.post_processing.FaultStabilityAnalysis import ( Config, MohrCoulomb )
+from geos.processing.post_processing.MohrCoulomb import ( MohrCoulomb )
+
from geos.processing.post_processing.ProfileExtractor import ProfileExtractor
@@ -21,18 +22,18 @@ class SensitivityAnalyzer:
"""Performs sensitivity analysis on Mohr-Coulomb parameters."""
# -------------------------------------------------------------------
- def __init__( self: Self, config: Config ) -> None:
+ def __init__( self: Self, outputDir: str = ".", showPlots: bool = True ) -> None:
"""Init."""
- self.config = config
- self.outputDir = Path( config.SENSITIVITY_OUTPUT_DIR )
+ self.outputDir = Path( outputDir )
self.outputDir.mkdir( exist_ok=True )
self.results: list[ dict[ str, Any ] ] = []
+ self.showPlots = showPlots
# -------------------------------------------------------------------
- def runAnalysis( self: Self, surfaceWithStress: pv.DataSet, time: float ) -> list[ dict[ str, Any ] ]:
+ def runAnalysis( self: Self, surfaceWithStress: pv.DataSet, time: float, sensitivityFrictionAngles: list[float], sensitivityCohesions: list[float], profileStartPoints: list[tuple[float]], profileSearchRadius: list[tuple[float]] ) -> list[ dict[ str, Any ] ]:
"""Run sensitivity analysis for multiple friction angles and cohesions."""
- frictionAngles = self.config.SENSITIVITY_FRICTION_ANGLES
- cohesions = self.config.SENSITIVITY_COHESIONS
+ frictionAngles = sensitivityFrictionAngles
+ cohesions = sensitivityCohesions
print( "\n" + "=" * 60 )
print( "SENSITIVITY ANALYSIS" )
@@ -50,7 +51,6 @@ def runAnalysis( self: Self, surfaceWithStress: pv.DataSet, time: float ) -> lis
surfaceCopy = surfaceWithStress.copy()
surfaceAnalyzed = MohrCoulomb.analyze(
- # surfaceCopy, cohesion, frictionAngle, time, verbose=False)
surfaceCopy,
cohesion,
frictionAngle,
@@ -69,7 +69,7 @@ def runAnalysis( self: Self, surfaceWithStress: pv.DataSet, time: float ) -> lis
self._plotSensitivityResults( results, time )
# Plot SCU vs depth
- self._plotSCUDepthProfiles( results, time, surfaceWithStress )
+ self._plotSCUDepthProfiles( results, time, surfaceWithStress, profileStartPoints, profileSearchRadius )
return results
@@ -121,7 +121,7 @@ def _plotSensitivityResults( self: Self, results: list[ dict[ str, Any ] ], time
plt.savefig( self.outputDir / filename, dpi=300, bbox_inches='tight' )
print( f"\n📊 Sensitivity plot saved: {filename}" )
- if self.config.SHOW_PLOTS:
+ if self.showPlots:
plt.show()
else:
plt.close()
@@ -153,7 +153,8 @@ def _plotHeatMap( self: Self, df: pd.DataFrame, column: str, title: str, ax: plt
# -------------------------------------------------------------------
def _plotSCUDepthProfiles( self: Self, results: list[ dict[ str, Any ] ], time: float,
- surfaceWithStress: pv.DataSet ) -> None:
+ surfaceWithStress: pv.DataSet, profileStartPoints=None, profileSearchRadius=None,
+ maxDepthProfiles=None ) -> None:
"""Plot SCU depth profiles for all parameter combinations.
Each (cohesion, friction) pair gets a unique color
@@ -166,7 +167,6 @@ def _plotSCUDepthProfiles( self: Self, results: list[ dict[ str, Any ] ], time:
centers[ :, 2 ]
# Get profile points from config
- profileStartPoints = self.config.PROFILE_START_POINTS
# Auto-generate if not provided
if profileStartPoints is None:
@@ -189,7 +189,6 @@ def _plotSCUDepthProfiles( self: Self, results: list[ dict[ str, Any ] ], time:
profileStartPoints = [ ( xPos, yPos ) ]
# Get search radius from config or auto-compute
- searchRadius = getattr( self.config, 'PROFILE_SEARCH_RADIUS', None )
if searchRadius is None:
xMin, xMax = np.min( centers[ :, 0 ] ), np.max( centers[ :, 0 ] )
yMin, yMax = np.min( centers[ :, 1 ] ), np.max( centers[ :, 1 ] )
@@ -271,8 +270,8 @@ def _plotSCUDepthProfiles( self: Self, results: list[ dict[ str, Any ] ], time:
ax.set_xlim( left=0 )
# Change verticale scale
- if hasattr( self.config, 'MAX_DEPTH_PROFILES' ) and self.config.MAX_DEPTH_PROFILES is not None:
- ax.set_ylim( bottom=self.config.MAX_DEPTH_PROFILES )
+ if maxDepthProfiles is not None:
+ ax.set_ylim( bottom=maxDepthProfiles )
# Légende en dehors à droite
ax.legend( loc='center left', bbox_to_anchor=( 1, 0.5 ), fontsize=9, ncol=1 )
@@ -290,7 +289,7 @@ def _plotSCUDepthProfiles( self: Self, results: list[ dict[ str, Any ] ], time:
plt.savefig( self.outputDir / filename, dpi=300, bbox_inches='tight' )
print( f"\n 💾 SCU sensitivity profiles saved: {filename}" )
- if self.config.SHOW_PLOTS:
+ if self.showPlots:
plt.show()
else:
plt.close()
diff --git a/geos-processing/src/geos/processing/post_processing/StressProjector.py b/geos-processing/src/geos/processing/post_processing/StressProjector.py
index 59cf7f18..4c2e7361 100644
--- a/geos-processing/src/geos/processing/post_processing/StressProjector.py
+++ b/geos-processing/src/geos/processing/post_processing/StressProjector.py
@@ -10,7 +10,6 @@
import pyvista as pv
from geos.geomechanics.model.StressTensor import StressTensor
-from geos.geomechanics.model.FaultStabilityAnalysis import Config
# ============================================================================
@@ -20,8 +19,9 @@ class StressProjector:
"""Projects volume stress onto fault surfaces and tracks principal stresses in VTU."""
# -------------------------------------------------------------------
- def __init__( self: Self, config: Config, adjacencyMapping: dict[ int, list[ pv.DataSet ] ],
- geometricProperties: dict[ str, Any ] ) -> None:
+ def __init__( self: Self, adjacencyMapping: dict[ int, list[ pv.DataSet ] ],
+ geometricProperties: dict[ str, Any ],
+ outputDir: str = ".", ) -> None:
"""Initialize with pre-computed adjacency mapping and geometric properties.
Parameters
@@ -36,7 +36,7 @@ def __init__( self: Self, config: Config, adjacencyMapping: dict[ int, list[ pv.
- 'distances': distances to fault
- 'faultTree': KDTree for fault
"""
- self.config = config
+ # self.config = config
self.adjacencyMapping = adjacencyMapping
# Store pre-computed geometric properties
@@ -52,7 +52,7 @@ def __init__( self: Self, config: Config, adjacencyMapping: dict[ int, list[ pv.
self.monitoredCells: set[ int ] | None = None
# Output directory for VTU files
- self.vtuOutputDir = Path( self.config.OUTPUT_DIR ) / "principal_stresses"
+ self.vtuOutputDir = Path( outputDir ) / "principal_stresses"
# -------------------------------------------------------------------
def setMonitoredCells( self: Self, cellIndices: list[ int ] | None = None ) -> None:
@@ -72,14 +72,15 @@ def projectStressToFault(
faultSurface: pv.PolyData,
time: float | None = None,
timestep: int | None = None,
- weightingScheme: str = "arithmetic" ) -> tuple[ pv.PolyData, pv.UnstructuredGrid, pv.UnstructuredGrid ]:
+ weightingScheme: str = "arithmetic",
+ stressName: str = "averageStress",
+ biotName: str = "rockPorosity_biotCoefficient",
+ computePrincipalStresses: bool = False,
+ frictionAngle: float = 10, cohesion: float = 0 ) -> tuple[ pv.PolyData, pv.UnstructuredGrid, pv.UnstructuredGrid ]:
"""Project stress and save principal stresses to VTU.
Now uses pre-computed geometric properties for efficiency
"""
- stressName = self.config.STRESS_NAME
- biotName = self.config.BIOT_NAME
-
if stressName not in volumeData.array_names:
raise ValueError( f"No stress data '{stressName}' in dataset" )
@@ -102,7 +103,6 @@ def projectStressToFault(
# =====================================================================
# 2. USE PRE-COMPUTED ADJACENCY
# =====================================================================
- # mapping = self.adjacencyMapping
# =====================================================================
# 3. PREPARE FAULT GEOMETRY
@@ -119,11 +119,10 @@ def projectStressToFault(
# =====================================================================
# 4. COMPUTE PRINCIPAL STRESSES FOR CONTRIBUTING CELLS
# =====================================================================
- if self.config.COMPUTE_PRINCIPAL_STRESS and timestep is not None:
+ if computePrincipalStresses and timestep is not None:
# Collect all unique contributing cells
allContributingCells = set()
- # for _faultIdx, neighbors in mapping.items():
for _faultIdx, neighbors in self.adjacencyMapping.items():
allContributingCells.update( neighbors[ 'plus' ] )
allContributingCells.update( neighbors[ 'minus' ] )
@@ -138,8 +137,8 @@ def projectStressToFault(
# Create mesh with only contributing cells
contributingMesh = self._createVolumicContribMesh( volumeData, faultSurface, cellsToTrack,
- self.adjacencyMapping )
- # contributingMesh = self._createVolumicContribMesh( volumeData, faultSurface, cellsToTrack, mapping )
+ self.adjacencyMapping, biotName=biotName, computePrincipalStresses=computePrincipalStresses,
+ frictionAngle=frictionAngle, cohesion=cohesion )
self._savePrincipalStressVTU( contributingMesh, time, timestep )
@@ -315,7 +314,8 @@ def computePrincipalStresses( stressTensor: StressTensor ) -> dict[ str, npt.NDA
# -------------------------------------------------------------------
def _createVolumicContribMesh( self: Self, volumeData: pv.UnstructuredGrid, faultSurface: pv.PolyData,
- cellsToTrack: set[ int ], mapping: dict[ int, list[ pv.DataSet ] ] ) -> pv.DataSet:
+ cellsToTrack: set[ int ], mapping: dict[ int, list[ pv.DataSet ] ], biotName: str = "rockPorosity_biotCoefficient",
+ computePrincipalStresses: bool = False, frictionAngle : float = 10, cohesion: float = 0 ) -> pv.DataSet:
"""Create a mesh containing only contributing cells with principal stress data and compute analytical normal/shear stresses based on fault dip angle.
Parameters
@@ -332,8 +332,6 @@ def _createVolumicContribMesh( self: Self, volumeData: pv.UnstructuredGrid, faul
# ===================================================================
# EXTRACT STRESS DATA FROM VOLUME
# ===================================================================
- stressName = self.config.STRESS_NAME
- biotName = self.config.BIOT_NAME
if stressName not in volumeData.array_names:
raise ValueError( f"No stress data '{stressName}' in volume dataset" )
@@ -559,26 +557,23 @@ def _createVolumicContribMesh( self: Self, volumeData: pv.UnstructuredGrid, faul
# ===================================================================
# COMPUTE SCU ANALYTICALLY (Mohr-Coulomb)
# ===================================================================
- if hasattr( self.config, 'FRICTION_ANGLE' ) and hasattr( self.config, 'COHESION' ):
- mu = np.tan( np.radians( self.config.FRICTION_ANGLE ) )
- cohesion = self.config.COHESION
-
- # τ_crit = C - σ_n * μ
- # Note: σ_n is negative (compression), so -σ_n * μ is positive
- tauCriticalArr = cohesion - sigmaNAnalyticalArr * mu
+ mu = np.tan( np.radians( frictionAngle ) )
- # SCU = τ / τ_crit
- SCUAnalyticalArr = np.divide( tauAnalyticalArr,
- tauCriticalArr,
- out=np.zeros_like( tauAnalyticalArr ),
- where=tauCriticalArr != 0 )
+ # τ_crit = C - σ_n * μ
+ # Note: σ_n is negative (compression), so -σ_n * μ is positive
+ tauCriticalArr = cohesion - sigmaNAnalyticalArr * mu
+ # SCU = τ / τ_crit
+ SCUAnalyticalArr = np.divide( tauAnalyticalArr,
+ tauCriticalArr,
+ out=np.zeros_like( tauAnalyticalArr ),
+ where=tauCriticalArr != 0 )
+ subsetMesh.cell_data[ 'tauCriticalAnalytical' ] = tauCriticalArr
+ subsetMesh.cell_data[ 'SCUAnalytical' ] = SCUAnalyticalArr
+ # CFS (Coulomb Failure Stress)
+ CFSAnalyticalArr = tauAnalyticalArr - mu * ( -sigmaNAnalyticalArr )
+ subsetMesh.cell_data[ 'CFSAnalytical' ] = CFSAnalyticalArr
- subsetMesh.cell_data[ 'tauCriticalAnalytical' ] = tauCriticalArr
- subsetMesh.cell_data[ 'SCUAnalytical' ] = SCUAnalyticalArr
- # CFS (Coulomb Failure Stress)
- CFSAnalyticalArr = tauAnalyticalArr - mu * ( -sigmaNAnalyticalArr )
- subsetMesh.cell_data[ 'CFSAnalytical' ] = CFSAnalyticalArr
subsetMesh.cell_data[ 'side' ] = sideArr
subsetMesh.cell_data[ 'nFaultCells' ] = nFaultCellsArr
@@ -597,14 +592,14 @@ def _createVolumicContribMesh( self: Self, volumeData: pv.UnstructuredGrid, faul
print( f" τ range: [{np.nanmin(tauAnalyticalArr):.1f}, {np.nanmax(tauAnalyticalArr):.1f}] bar" )
print( f" Dip angle range: [{np.nanmin(dipAngleArr):.1f}, {np.nanmax(dipAngleArr):.1f}]°" )
- if hasattr( self.config, 'FRICTION_ANGLE' ) and hasattr( self.config, 'COHESION' ):
- print(
- f" SCU range: [{np.nanmin(SCUAnalyticalArr[validAnalytical]):.2f}, {np.nanmax(SCUAnalyticalArr[validAnalytical]):.2f}]"
- )
- nCritical = np.sum( ( SCUAnalyticalArr >= 0.8 ) & ( SCUAnalyticalArr < 1.0 ) )
- nUnstable = np.sum( SCUAnalyticalArr >= 1.0 )
- print( f" Critical cells (SCU≥0.8): {nCritical} ({nCritical/nValid*100:.1f}%)" )
- print( f" Unstable cells (SCU≥1.0): {nUnstable} ({nUnstable/nValid*100:.1f}%)" )
+ # if hasattr( self.config, 'FRICTION_ANGLE' ) and hasattr( self.config, 'COHESION' ):
+ print(
+ f" SCU range: [{np.nanmin(SCUAnalyticalArr[validAnalytical]):.2f}, {np.nanmax(SCUAnalyticalArr[validAnalytical]):.2f}]"
+ )
+ nCritical = np.sum( ( SCUAnalyticalArr >= 0.8 ) & ( SCUAnalyticalArr < 1.0 ) )
+ nUnstable = np.sum( SCUAnalyticalArr >= 1.0 )
+ print( f" Critical cells (SCU≥0.8): {nCritical} ({nCritical/nValid*100:.1f}%)" )
+ print( f" Unstable cells (SCU≥1.0): {nUnstable} ({nUnstable/nValid*100:.1f}%)" )
else:
print( " ⚠️ No analytical stresses computed (no fault mapping)" )
diff --git a/geos-processing/src/geos/processing/tools/FaultVisualizer.py b/geos-processing/src/geos/processing/tools/FaultVisualizer.py
index b0544dc5..3fa6a382 100644
--- a/geos-processing/src/geos/processing/tools/FaultVisualizer.py
+++ b/geos-processing/src/geos/processing/tools/FaultVisualizer.py
@@ -12,7 +12,9 @@
from typing_extensions import Self
from geos.processing.post_processing.ProfileExtractor import ProfileExtractor
-from geos.processing.post_processing.FaultStabilityAnalysis import Config
+# from geos.processing.post_processing.FaultStabilityAnalysis import Config
+
+# from geos.processing.tools.Config import Config
# ============================================================================
@@ -22,9 +24,18 @@ class Visualizer:
"""Visualization utilities."""
# -------------------------------------------------------------------
- def __init__( self, config: Config ) -> None:
+ # def __init__( self, config: Config ) -> None:
+ def __init__( self, profileSearchRadius: float| None = None,
+ minDepthProfiles: float | None = None,
+ maxDepthProfiles: float | None = None,
+ showPlots: bool = True, savePlots:bool = True ) -> None:
"""Init."""
- self.config = config
+ self.profileSearchRadius = profileSearchRadius
+ self.minDepthProfiles = minDepthProfiles
+ self.maxDepthProfiles = maxDepthProfiles
+ # self.config = config
+ self.savePlots = savePlots
+ self.showPlots = showPlots
# -------------------------------------------------------------------
@staticmethod
@@ -265,15 +276,18 @@ def loadReferenceData( time: float,
return result
# -------------------------------------------------------------------
- @staticmethod
- def plotDepthProfiles( surface: pv.PolyData,
+ def plotDepthProfiles( self,
+ surface: pv.PolyData,
time: float,
path: Path,
show: bool = True,
save: bool = True,
profileStartPoints: list[ tuple[ float, float ] ] | None = None,
maxProfilePoints: int = 1000,
- referenceProfileId: int = 1 ) -> None:
+ referenceProfileId: int = 1,
+ # profileSearchRadius: float | None = None,
+ showProfileExtractor: bool = True,
+ ) -> None:
"""Plot vertical profiles along the fault showing stress and SCU vs depth."""
print( " 📊 Creating depth profiles " )
@@ -320,8 +334,8 @@ def plotDepthProfiles( surface: pv.PolyData,
yRange = yMax - yMin
zMax - zMin
- if self.config.PROFILE_SEARCH_RADIUS is not None:
- searchRadius = self.config.PROFILE_SEARCH_RADIUS
+ if self.profileSearchRadius is not None:
+ searchRadius = self.profileSearchRadius
else:
searchRadius = min( xRange, yRange ) * 0.15
@@ -365,18 +379,6 @@ def plotDepthProfiles( surface: pv.PolyData,
for i, ( xPos, yPos, zPos ) in enumerate( profileStartPoints ):
print( f" → Profile {i+1}: starting at ({xPos:.1f}, {yPos:.1f}, {zPos:.1f})" )
- # depthsSigma, profileSigmaN, PathXSigma, PathYSigma = ProfileExtractor.extractVerticalProfileTopologyBased(
- # surface, 'sigmaNEffective', xPos, yPos, zPos, verbose=True)
-
- # depthsTau, profileTau, _, _ = ProfileExtractor.extractVerticalProfileTopologyBased(
- # surface, 'tauEffective', xPos, yPos, zPos, verbose=False)
-
- # depthsSCU, profileSCU, _, _ = ProfileExtractor.extractVerticalProfileTopologyBased(
- # surface, 'SCU', xPos, yPos, zPos, verbose=False)
-
- # depthsDeltaSCU, profileDeltaSCU, _, _ = ProfileExtractor.extractVerticalProfileTopologyBased(
- # surface, 'deltaSCU', xPos, yPos, zPos, verbose=False)
-
depthsSigma, profileSigmaN, PathXSigma, PathYSigma = ProfileExtractor.extractAdaptiveProfile(
centers, sigmaN, xPos, yPos, searchRadius )
@@ -397,7 +399,7 @@ def plotDepthProfiles( surface: pv.PolyData,
f" Path length: {pathLength:.1f}m (horizontal displacement: {np.abs(PathXSigma[-1] - PathXSigma[0]):.1f}m)"
)
- if self.config.SHOW_PROFILE_EXTRACTOR:
+ if showProfileExtractor:
ProfileExtractor.plotProfilePath3D( surface=surface,
pathX=PathXSigma,
pathY=PathYSigma,
@@ -571,13 +573,13 @@ def plotDepthProfiles( surface: pv.PolyData,
axes[ 3 ].set_xlim( left=0, right=2 )
# Change verticale scale
- if self.config.MAX_DEPTH_PROFILES is not None:
+ if self.maxDepthProfiles is not None:
for i in range( len( axes ) ):
- axes[ i ].set_ylim( bottom=self.config.MAX_DEPTH_PROFILES )
+ axes[ i ].set_ylim( bottom=self.maxDepthProfiles )
- if self.config.MIN_DEPTH_PROFILES is not None:
+ if self.minDepthProfiles is not None:
for i in range( len( axes ) ):
- axes[ i ].set_ylim( top=self.config.MIN_DEPTH_PROFILES )
+ axes[ i ].set_ylim( top=self.minDepthProfiles )
# Overall title
years = time / ( 365.25 * 24 * 3600 )
@@ -606,7 +608,11 @@ def plotVolumeStressProfiles( self: Self,
show: bool = True,
save: bool = True,
profileStartPoints: list[ tuple[ float, float, float ] ] | None = None,
- maxProfilePoints: int = 1000 ) -> None:
+ maxProfilePoints: int = 1000,
+ # profileSearchRadius: float | None = None,
+ # maxDepthProfile: float | None = None,
+ # minDepthProfile: float | None = None
+ ) -> None:
"""Plot stress profiles in volume cells adjacent to the fault.
Extracts profiles through contributing cells on BOTH sides of the fault
@@ -703,8 +709,8 @@ def plotVolumeStressProfiles( self: Self,
zMax - zMin
# Search radius (pour extractAdaptiveProfile sur volumes)
- if self.config.PROFILE_SEARCH_RADIUS is not None:
- searchRadius = self.config.PROFILE_SEARCH_RADIUS
+ if self.profileSearchRadius is not None:
+ searchRadius = self.profileSearchRadius
else:
searchRadius = min( xRange, yRange ) * 0.2
@@ -1034,13 +1040,13 @@ def plotVolumeStressProfiles( self: Self,
axes[ 4 ].legend( handles=customLines, loc='best', fontsize=fsize - 3, ncol=1 )
# Change verticale scale
- if self.config.MAX_DEPTH_PROFILES is not None:
+ if self.maxDepthProfile is not None:
for i in range( len( axes ) ):
- axes[ i ].set_ylim( bottom=self.config.MAX_DEPTH_PROFILES )
+ axes[ i ].set_ylim( bottom=self.maxDepthProfiles )
- if self.config.MIN_DEPTH_PROFILES is not None:
+ if self.minDepthProfiles is not None:
for i in range( len( axes ) ):
- axes[ i ].set_ylim( top=self.config.MIN_DEPTH_PROFILES )
+ axes[ i ].set_ylim( top=self.minDepthProfiles )
# Overall title
years = time / ( 365.25 * 24 * 3600 )
@@ -1072,7 +1078,11 @@ def plotAnalyticalVsNumericalComparison( self: Self,
show: bool = True,
save: bool = True,
profileStartPoints: list[ tuple[ int, int, int ] ] | None = None,
- referenceProfileId: int = 1 ) -> None:
+ referenceProfileId: int = 1,
+ # profileSearchRadius: float| None = None,
+ # minDepthProfile: float | None = None,
+ # maxDepthProfile: float | None = None,
+ ) -> None:
"""Plot comparison between analytical fault stresses (Anderson formulas) and numerical tensor projection - COMBINED PLOTS ONLY.
Parameters
@@ -1195,8 +1205,8 @@ def plotAnalyticalVsNumericalComparison( self: Self,
yRange = yMax - yMin
# Search radius
- if self.config.PROFILE_SEARCH_RADIUS is not None:
- searchRadius = self.config.PROFILE_SEARCH_RADIUS
+ if self.profileSearchRadius is not None:
+ searchRadius = self.profileSearchRadius
else:
searchRadius = min( xRange, yRange ) * 0.2
@@ -1563,13 +1573,13 @@ def plotAnalyticalVsNumericalComparison( self: Self,
y=0.995 )
# Change verticale scale
- if self.config.MAX_DEPTH_PROFILES is not None:
+ if self.maxDepthProfiles is not None:
for i in range( len( axes ) ):
- axes[ i ].set_ylim( bottom=self.config.MAX_DEPTH_PROFILES )
+ axes[ i ].set_ylim( bottom=self.maxDepthProfiles )
- if self.config.MIN_DEPTH_PROFILES is not None:
+ if self.minDepthProfiles is not None:
for i in range( len( axes ) ):
- axes[ i ].set_ylim( top=self.config.MIN_DEPTH_PROFILES )
+ axes[ i ].set_ylim( top=self.minDepthProfiles )
plt.tight_layout( rect=( 0, 0, 1, 0.99 ) )
From 94e2c652c4433873ba59c833d9745937b4cae79e Mon Sep 17 00:00:00 2001
From: Paloma Martinez <104762252+paloma-martinez@users.noreply.github.com>
Date: Thu, 19 Feb 2026 15:34:34 +0100
Subject: [PATCH 5/5] Migration pyvista to vtk
---
.../geos/geomechanics/model/StressTensor.py | 4 +-
geos-mesh/src/geos/mesh/io/vtkIO.py | 29 +++
.../src/geos/mesh/utils/arrayModifiers.py | 26 +++
.../src/geos/mesh/utils/genericHelpers.py | 48 +++++
.../post_processing/FaultGeometry.py | 195 ++++++++++--------
.../post_processing/FaultStabilityAnalysis.py | 109 ++++------
.../processing/post_processing/MohrCoulomb.py | 57 ++---
.../post_processing/ProfileExtractor.py | 11 +-
.../post_processing/SensitivityAnalyzer.py | 66 +++---
.../post_processing/StressProjector.py | 153 +++++++-------
.../geos/processing/tools/FaultVisualizer.py | 57 +++--
11 files changed, 434 insertions(+), 321 deletions(-)
diff --git a/geos-geomechanics/src/geos/geomechanics/model/StressTensor.py b/geos-geomechanics/src/geos/geomechanics/model/StressTensor.py
index 464bdcbb..619fc542 100644
--- a/geos-geomechanics/src/geos/geomechanics/model/StressTensor.py
+++ b/geos-geomechanics/src/geos/geomechanics/model/StressTensor.py
@@ -39,8 +39,8 @@ def rotateToFaultFrame( stressTensorarr: npt.NDArray[ np.float64 ], normal: npt.
tangent2: npt.NDArray[ np.float64 ] ) -> dict[ str, Any ]:
"""Rotate stress tensor to fault local coordinate system."""
# Verify orthonormality
- assert np.abs( np.linalg.norm( tangent1 ) - 1.0 ) < 1e-10
- assert np.abs( np.linalg.norm( tangent2 ) - 1.0 ) < 1e-10
+ assert np.abs( np.linalg.norm( tangent1 ) - 1.0 ) < 1e-10, f"T1 - {np.abs( np.linalg.norm( tangent1 ) - 1.0 )}"
+ assert np.abs( np.linalg.norm( tangent2 ) - 1.0 ) < 1e-10, f"T2 - {np.abs( np.linalg.norm( tangent2 ) - 1.0 )}"
assert np.abs( np.dot( normal, tangent1 ) ) < 1e-10
assert np.abs( np.dot( normal, tangent2 ) ) < 1e-10
diff --git a/geos-mesh/src/geos/mesh/io/vtkIO.py b/geos-mesh/src/geos/mesh/io/vtkIO.py
index 55efbc9f..1b721217 100644
--- a/geos-mesh/src/geos/mesh/io/vtkIO.py
+++ b/geos-mesh/src/geos/mesh/io/vtkIO.py
@@ -5,6 +5,7 @@
from enum import Enum
from pathlib import Path
from typing import Optional, Type, TypeAlias
+from xml.etree import ElementTree as ET
from vtkmodules.vtkCommonDataModel import vtkPointSet, vtkUnstructuredGrid
from vtkmodules.vtkIOCore import vtkWriter
from vtkmodules.vtkIOLegacy import vtkDataReader, vtkUnstructuredGridWriter, vtkUnstructuredGridReader
@@ -266,3 +267,31 @@ def writeMesh( mesh: vtkPointSet, vtkOutput: VtkOutput, canOverwrite: bool = Fal
except ( ValueError, RuntimeError ) as e:
ioLogger.error( e )
raise
+
+
+class PVDReader:
+ def __init__( self, filename ):
+ self.filename = filename
+ self.dir = Path( filename ).parent
+ self.datasets = {}
+ self._read()
+
+ def _read( self ):
+ tree = ET.parse( self.filename )
+ root = tree.getroot()
+ datasets = root[0].findall( 'DataSet' )
+
+ n: int = 0
+ for dataset in datasets:
+ timestep = float( dataset.attrib.get( 'timestep', 0 ) )
+ datasetFile = Path( dataset.attrib.get( 'file' ) )
+ # self.datasets.update( ( n, timestep ): datasetFile )
+ self.datasets[ n ] = ( timestep, datasetFile )
+ n += 1
+
+
+ def getDataSetAtTimeIndex( self, timeIndex: int):
+ return readMesh( self.dir / self.datasets[ timeIndex ][ 1 ] )
+
+ def getAllTimestepsValues( self ) -> list[ float ]:
+ return list( [ value[0] for _, value in self.datasets.items() ] )
\ No newline at end of file
diff --git a/geos-mesh/src/geos/mesh/utils/arrayModifiers.py b/geos-mesh/src/geos/mesh/utils/arrayModifiers.py
index 6bc763b3..22672f9b 100644
--- a/geos-mesh/src/geos/mesh/utils/arrayModifiers.py
+++ b/geos-mesh/src/geos/mesh/utils/arrayModifiers.py
@@ -898,6 +898,32 @@ def renameAttribute(
return
+def updateAttribute( mesh: vtkDataSet, newValue: npt.NDArray[ Any ], attributeName: str, piece: Piece = Piece.CELLS, logger: Union[ Logger, None ] = None ) -> None:
+ """Update the value of an attribute. Creates the attribute if it is not already in the dataset.
+
+ Args:
+ mesh (vtkDataSet): Input mesh.
+ attributeName (str): Name of the attribute.
+ newValue (vtkDataArray):
+ """
+ # Check if an external logger is given.
+ if logger is None:
+ logger = getLogger( "updateAttribute", True )
+
+ if isAttributeInObject( mesh, attributeName, piece ):
+ if piece == Piece.CELLS:
+ data = mesh.GetCellData()
+ elif piece == Piece.POINTS:
+ data = mesh.GetPointData()
+ else:
+ raise ValueError( "Only point and cell data handled." )
+ data.RemoveArray( attributeName )
+
+ createAttribute( mesh, newValue, attributeName, piece=piece, logger=logger )
+
+ return
+
+
def createCellCenterAttribute(
mesh: Union[ vtkMultiBlockDataSet, vtkDataSet ],
cellCenterAttributeName: str,
diff --git a/geos-mesh/src/geos/mesh/utils/genericHelpers.py b/geos-mesh/src/geos/mesh/utils/genericHelpers.py
index de11a402..bbc422c4 100644
--- a/geos-mesh/src/geos/mesh/utils/genericHelpers.py
+++ b/geos-mesh/src/geos/mesh/utils/genericHelpers.py
@@ -15,6 +15,15 @@
from vtkmodules.vtkFiltersTexture import vtkTextureMapToPlane
from vtkmodules.vtkFiltersGeometry import vtkDataSetSurfaceFilter
from vtkmodules.vtkFiltersGeneral import vtkDataSetTriangleFilter
+from vtkmodules.util.numpy_support import numpy_to_vtkIdTypeArray
+from vtkmodules.vtkFiltersExtraction import vtkExtractSelection
+from vtkmodules.vtkFiltersGeometry import vtkGeometryFilter
+from vtkmodules.vtkFiltersVerdict import vtkCellSizeFilter
+from vtkmodules.vtkCommonDataModel import ( vtkUnstructuredGrid, vtkFieldData, vtkMultiBlockDataSet, vtkDataSet,
+ vtkCompositeDataSet, vtkDataObject, vtkPointData, vtkCellData, vtkPolyData,
+ vtkCell, , vtkSelection )
+from typing import cast
+
from geos.mesh.utils.multiblockHelpers import ( getBlockElementIndexesFlatten, getBlockFromFlatIndex )
@@ -515,6 +524,7 @@ def getLocalBasisVectors(
def computeNormals(
surface: vtkPolyData,
+ pointNormals: bool = False,
logger: Union[ Logger, None ] = None,
) -> vtkPolyData:
"""Compute and set the normals of a given surface.
@@ -665,3 +675,41 @@ def computeSurfaceTextureCoordinates(
vtkErrorLogger.error( captured.strip() )
return textureFilter.GetOutput()
+
+
+def extractCellSelection( mesh: vtkUnstructuredGrid, ids: list[ int ]) -> vtkUnstructuredGrid:
+
+ selectionNode: vtkSelectionNode = vtkSelectionNode()
+ selectionNode.SetFieldType( vtkSelectionNode.CELL )
+ selectionNode.SetContentType( vtkSelectionNode.INDICES )
+ selectionNode.SetSelectionList( numpy_to_vtkIdTypeArray (np.asarray( ids ).astype( np.int64 ) ) )
+
+ selection: vtkSelection = vtkSelection()
+ selection.AddNode( selectionNode )
+
+ extractCells = vtkExtractSelection()
+ extractCells.SetInputData(0, mesh )
+ extractCells.SetInputData(1, selection )
+ extractCells.Update()
+
+ # TODO raiseError
+ return vtkUnstructuredGrid.SafeDownCast( extractCells.GetOutputDataObject( 0 ) )
+
+
+def extractSurface( mesh: vtkUnstructuredGrid ) -> vtkUnstructuredGrid:
+ geomFilter: vtkGeometryFilter = vtkGeometryFilter()
+ geomFilter.SetInputData( mesh )
+
+ geomFilter.Update()
+
+ return geomFilter.GetOutput()
+
+
+
+def computeCellVolumes( mesh: vtkUnstructuredGrid ) -> vtkUnstructuredGrid:
+ volFilter: vtkCellSizeFilter = vtkCellSizeFilter()
+ volFilter.SetInputData( mesh )
+ volFilter.SetComputeVolume( True )
+ volFilter.Update()
+
+ return volFilter.GetOutput()
\ No newline at end of file
diff --git a/geos-processing/src/geos/processing/post_processing/FaultGeometry.py b/geos-processing/src/geos/processing/post_processing/FaultGeometry.py
index 5dba1941..1f89be58 100644
--- a/geos-processing/src/geos/processing/post_processing/FaultGeometry.py
+++ b/geos-processing/src/geos/processing/post_processing/FaultGeometry.py
@@ -4,15 +4,26 @@
# ============================================================================
# FAULT GEOMETRY
# ============================================================================
+import sys
import pyvista as pv
import numpy as np
from pathlib import Path
from typing_extensions import Self, Any
-from vtkmodules.vtkCommonDataModel import vtkCellLocator
-# from vtkmodules.vtkCommonDataModel import vtkIdList
+from vtkmodules.vtkCommonDataModel import vtkCellLocator, vtkMultiBlockDataSet
+from vtkmodules.vtkIOXML import vtkXMLUnstructuredGridWriter
+
import numpy.typing as npt
from scipy.spatial import cKDTree
+from vtkmodules.util.numpy_support import vtk_to_numpy, numpy_to_vtk
+
+from geos.mesh.utils.arrayModifiers import createAttribute
+from geos.mesh.utils.arrayHelpers import ( getArrayInObject, computeCellCenterCoordinates )
+from geos.mesh.utils.multiblockModifiers import ( mergeBlocks )
+from geos.mesh.utils.genericHelpers import ( extractCellSelection, extractSurface, computeNormals, getNormalVectors, computeCellVolumes )
+from geos.utils.pieceEnum import Piece
+
+from geos.mesh.io.vtkIO import writeMesh, VtkOutput
__doc__="""
@@ -29,10 +40,10 @@ def __init__( self: Self, mesh: pv.DataSet, faultValues: list[ int ], faultAttri
"""Initialize fault geometry with pre-computed topology.
Args:
- mesh (pv.DataSet):
+ mesh (pv.DataSet): Input mesh
faultValues (list[int]): Config.FAULT_VALUES
faultAttribute (str): Config.FAULT_ATTRIBUTES
- volumeMesh (pv.DataSet): processor._merge_blocks(dataset)
+ volumeMesh (pv.DataSet): PVD mesh
"""
self.mesh = mesh
self.faultValues = faultValues
@@ -90,12 +101,12 @@ def initialize( self: Self,
if len( m[ 'plus' ] ) > 0 and len( m[ 'minus' ] ) > 0 )
print( "\n✅ Adjacency topology computed:" )
- print( f" - {nMapped}/{self.faultSurface.n_cells} fault cells mapped" )
+ print( f" - {nMapped}/{self.faultSurface.GetNumberOfCells()} fault cells mapped" )
print( f" - {nWithBoth} cells have neighbors on both sides" )
# Visualize contributions if requested
- if showContributionViz:
- self._visualizeContributions()
+ # if showContributionViz:
+ # self._visualizeContributions()
return self.faultSurface, self.adjacencyMapping
@@ -104,7 +115,7 @@ def _markContributingCells( self: Self, saveContributionCells: bool = True ) ->
"""Mark volume cells that contribute to fault stress projection."""
print( "\n📦 Marking contributing volume cells..." )
- nVolume = self.volumeMesh.n_cells
+ nVolume = self.volumeMesh.GetNumberOfCells()
# Collect contributing cells by side
allPlus = set()
@@ -126,18 +137,20 @@ def _markContributingCells( self: Self, saveContributionCells: bool = True ) ->
contributionSide[ idx ] += 2
# Add classification to volume mesh
- self.volumeMesh.cell_data[ "contributionSide" ] = contributionSide
contribMask = contributionSide > 0
- self.volumeMesh.cell_data[ "contribution_to_faults" ] = contribMask.astype( int )
+ contribMask = contribMask.astype( int )
+
+ createAttribute( self.volumeMesh, contributionSide, "contributionSide" )
+ createAttribute( self.volumeMesh, contribMask, "contributionToFaults" )
# Extract subsets
- maskAll = contribMask
- maskPlus = ( contributionSide == 1 ) | ( contributionSide == 3 )
- maskMinus = ( contributionSide == 2 ) | ( contributionSide == 3 )
+ maskAll = np.where( contribMask )[0]
+ maskPlus = np.where( ( contributionSide == 1 ) | ( contributionSide == 3 ) )[0]
+ maskMinus = np.where( ( contributionSide == 2 ) | ( contributionSide == 3 ) )[0]
- self.contributingCells = self.volumeMesh.extract_cells( maskAll )
- self.contributingCellsPlus = self.volumeMesh.extract_cells( maskPlus )
- self.contributingCellsMinus = self.volumeMesh.extract_cells( maskMinus )
+ self.contributingCells = extractCellSelection( self.volumeMesh, maskAll )
+ self.contributingCellsPlus = extractCellSelection( self.volumeMesh, maskPlus )
+ self.contributingCellsMinus = extractCellSelection( self.volumeMesh, maskMinus )
# Statistics
nContrib = np.sum( maskAll )
@@ -166,21 +179,25 @@ def _saveContributingCells( self: Self ) -> None:
# Save all contributing cells
filenameAll = outputDir / "contributing_cells_all.vtu"
- self.contributingCells.save( str( filenameAll ) )
+
+ writeMesh( mesh=self.contributingCells, vtkOutput=VtkOutput(filenameAll), canOverwrite=True )
+ # self.contributingCells.save( str( filenameAll ) )
print( f"\n 💾 All contributing cells saved: {filenameAll}" )
- print( f" ({self.contributingCells.n_cells} cells, {self.contributingCells.n_points} points)" )
+ print( f" ({self.contributingCells.GetNumberOfCells()} cells, {self.contributingCells.GetNumberOfPoints} points)" )
# Save plus side
- outputDir / "contributingCellsPlus.vtu"
+ filenamePlus = outputDir / "contributingCellsPlus.vtu"
# self.contributingCellsPlus.save(str(filenamePlus))
# print(f" 💾 Plus side cells saved: {filenamePlus}")
- print( f" ({self.contributingCellsPlus.n_cells} cells, {self.contributingCellsPlus.n_points} points)" )
+ writeMesh( mesh=self.contributingCellsPlus, vtkOutput=VtkOutput(filenamePlus), canOverwrite=True )
+ print( f" ({self.contributingCellsPlus.GetNumberOfCells()} cells, {self.contributingCellsPlus.GetNumberOfPoints} points)" )
# Save minus side
- outputDir / "contributingCellsMinus.vtu"
+ filenameMinus = outputDir / "contributingCellsMinus.vtu"
# self.contributingCellsMinus.save(str(filenameMinus))
# print(f" 💾 Minus side cells saved: {filenameMinus}")
- print( f" ({self.contributingCellsMinus.n_cells} cells, {self.contributingCellsMinus.n_points} points)" )
+ writeMesh( mesh=self.contributingCellsMinus, vtkOutput=VtkOutput(filenameMinus), canOverwrite=True )
+ print( f" ({self.contributingCellsMinus.GetNumberOfCells()} cells, {self.contributingCellsMinus.GetNumberOfPoints} points)" )
# -------------------------------------------------------------------
def getContributingCells( self: Self, side: str = 'all' ) -> pv.UnstructuredGrid:
@@ -227,7 +244,7 @@ def getGeometricProperties( self: Self ) -> dict[ str, Any ]:
}
# -------------------------------------------------------------------
- def _precomputeGeometricProperties( self: Self ) -> None:
+ def _precomputeGeometricProperties( self: Self ) -> None: # TODO
"""Pre-compute geometric properties of volume mesh for efficient stress projection.
Computes:
@@ -238,16 +255,16 @@ def _precomputeGeometricProperties( self: Self ) -> None:
"""
print( "\n📐 Pre-computing geometric properties..." )
- nVolume = self.volumeMesh.n_cells
+ nVolume = self.volumeMesh.GetNumberOfCells()
# 1. Compute volume centers
print( " Computing cell centers..." )
- self.volumeCenters = self.volumeMesh.cell_centers().points
+ self.volumeCenters = vtk_to_numpy( computeCellCenterCoordinates( self.volumeMesh ) )
# 2. Compute cell volumes
print( " Computing cell volumes..." )
- volumeWithSizes = self.volumeMesh.compute_cell_sizes( length=False, area=False, volume=True )
- self.volumeCellVolumes = volumeWithSizes.cell_data[ 'Volume' ]
+ volumeWithSizes = computeCellVolumes( self.volumeMesh )
+ self.volumeCellVolumes = getArrayInObject( volumeWithSizes, 'Volume', Piece.CELLS )
print( f" Volume range: [{np.min(self.volumeCellVolumes):.1e}, "
f"{np.max(self.volumeCellVolumes):.1e}] m³" )
@@ -255,7 +272,7 @@ def _precomputeGeometricProperties( self: Self ) -> None:
# 3. Build KDTree for fault surface (for fast distance queries)
print( " Building KDTree for fault surface..." )
- faultCenters = self.faultSurface.cell_centers().points
+ faultCenters = computeCellCenterCoordinates( self.faultSurface )
self.faultTree = cKDTree( faultCenters )
# 4. Compute distance from each volume cell to nearest fault cell
@@ -270,8 +287,8 @@ def _precomputeGeometricProperties( self: Self ) -> None:
f"{np.max(self.distanceToFault):.1f}] m" )
# 5. Add these properties to volume mesh for reference
- self.volumeMesh.cell_data[ 'cellVolume' ] = self.volumeCellVolumes # TODO FIX
- self.volumeMesh.cell_data[ 'distanceToFault' ] = self.distanceToFault
+ createAttribute ( self.volumeMesh, self.volumeCellVolumes, 'cellVolume', Piece.CELLS )
+ createAttribute ( self.volumeMesh, self.distanceToFault, 'distanceToFault', Piece.CELLS )
print( " ✅ Geometric properties computed and cached" )
@@ -282,16 +299,16 @@ def _buildAdjacencyMappingFaceSharing( self: Self,
Uses adaptive epsilon optimization.
"""
- faultIds = np.unique( self.faultSurface.cell_data[ self.faultAttribute ] )
+ faultIds = np.unique( getArrayInObject ( self.faultSurface, self.faultAttribute, Piece.CELLS ) )
nFaults = len( faultIds )
print( f" 📋 Processing {nFaults} separate faults: {faultIds}" )
allMappings = {}
for faultId in faultIds:
- mask = self.faultSurface.cell_data[ self.faultAttribute ] == faultId
+ mask = getArrayInObject( self.faultSurface, self.faultAttribute, Piece.CELLS ) == faultId
indices = np.where( mask )[ 0 ]
- singleFault = self.faultSurface.extract_cells( indices )
+ singleFault = extractCellSelection( self.faultSurface, indices )
print( f" 🔧 Mapping Fault {faultId}..." )
@@ -306,15 +323,15 @@ def _buildAdjacencyMappingFaceSharing( self: Self,
return allMappings
# -------------------------------------------------------------------
- def _findFaceSharingCells( self: Self, faultSurface: pv.DataSet ) -> pv.DataSet:
+ def _findFaceSharingCells( self: Self, faultSurface ) -> pv.DataSet:
"""Find volume cells that share a FACE with fault cells.
Uses FindCell with adaptive epsilon to maximize cells with both neighbors
"""
volMesh = self.volumeMesh
- volCenters = volMesh.cell_centers().points
- faultNormals = faultSurface.cell_data[ "Normals" ]
- faultCenters = faultSurface.cell_centers().points
+ volCenters = vtk_to_numpy( computeCellCenterCoordinates( volMesh ) )
+ faultNormals = vtk_to_numpy( faultSurface.GetCellData().GetNormals() )
+ faultCenters = vtk_to_numpy( computeCellCenterCoordinates( faultSurface ) )
# Determine base epsilon based on mesh size
volBounds = volMesh.bounds
@@ -386,7 +403,7 @@ def _testEpsilon( self: Self, faultSurface: pv.DataSet, locator: vtkCellLocator,
nFoundNone = 0
totalNeighbors = 0
- for fid in range( faultSurface.n_cells ):
+ for fid in range( faultSurface.GetNumberOfCells() ):
fcenter = faultCenters[ fid ]
fnormal = faultNormals[ fid ]
@@ -419,7 +436,7 @@ def _testEpsilon( self: Self, faultSurface: pv.DataSet, locator: vtkCellLocator,
else:
nFoundNone += 1
- nCells = faultSurface.n_cells
+ nCells = faultSurface.GetNumberOfCells()
avgNeighbors = totalNeighbors / nCells if nCells > 0 else 0
stats = {
@@ -593,41 +610,45 @@ def _extractAndComputeNormals( self: Self,
zScale: float = 1.0 ) -> tuple[ pv.DataSet, list[ pv.DataSet ] ]:
"""Extract fault surfaces and compute oriented normals/tangents."""
surfaces = []
+ mb = vtkMultiBlockDataSet()
+ mb.SetNumberOfBlocks( len( self.faultValues ) )
- for faultId in self.faultValues:
+ for i, faultId in enumerate( self.faultValues ):
# Extract fault cells
- faultMask = self.mesh.cell_data[ self.faultAttribute ] == faultId
- faultCells = self.mesh.extract_cells( faultMask )
+ faultMask = np.where( getArrayInObject( self.mesh, self.faultAttribute, piece=Piece.CELLS ) == faultId )[0]
+ faultCells = extractCellSelection( self.mesh, ids=faultMask )
- if faultCells.n_cells == 0:
+ if faultCells.GetNumberOfCells() == 0:
print( f"⚠️ No cells for fault {faultId}" )
continue
# Extract surface
- surf = faultCells.extract_surface()
- if surf.n_cells == 0:
+ surf = extractSurface( faultCells )
+ if surf.GetNumberOfCells() == 0:
continue
# Compute normals
- surf.compute_normals( cell_normals=True, point_normals=True, inplace=True )
+ surf = computeNormals( surf, pointNormals=True )
# Orient normals consistently within the fault
surf = self._orientNormals( surf )
+ mb.SetBlock( i, surf)
surfaces.append( surf )
- merged = pv.MultiBlock( surfaces ).combine()
- print( f"✅ Normals computed for {merged.n_cells} fault cells" )
+ merged = mergeBlocks( mb, keepPartialAttributes=True)
+ # merged = pv.MultiBlock( surfaces ).combine()
+ print( f"✅ Normals computed for {merged.GetNumberOfCells()} fault cells" )
- if showPlot:
- self.plotGeometry( merged, scaleFactor, zScale )
+ # if showPlot:
+ # self.plotGeometry( merged, scaleFactor, zScale )
return merged, surfaces
# -------------------------------------------------------------------
def _orientNormals( self: Self, surf: pv.PolyData, rotateNormals: bool = False ) -> pv.DataSet:
"""Ensure normals point in consistent direction within the fault."""
- normals = surf.cell_data[ 'Normals' ]
+ normals = vtk_to_numpy( surf.GetCellData().GetNormals() )
meanNormal = np.mean( normals, axis=0 )
meanNormal /= np.linalg.norm( meanNormal )
@@ -658,14 +679,16 @@ def _orientNormals( self: Self, surf: pv.PolyData, rotateNormals: bool = False )
tangents1[ i ] = t1
tangents2[ i ] = t2
- surf.cell_data[ 'Normals' ] = normals
- surf.cell_data[ 'tangent1' ] = tangents1
- surf.cell_data[ 'tangent2' ] = tangents2
+ surf.GetCellData().SetNormals( numpy_to_vtk( normals.ravel() ) )
- dip_angles, strike_angles = self.computeDipStrikeFromCellBase( normals, tangents1, tangents2 )
+ createAttribute( surf, tangents1, "Tangents1" )
+ createAttribute( surf, tangents2, "Tangents2" )
+ surf.GetCellData().SetActiveTangents( "Tangents1" )
- surf.cell_data[ 'dipAngle' ] = dip_angles
- surf.cell_data[ 'strikeAngle' ] = strike_angles
+ dipAngles, strikeAngles = self.computeDipStrikeFromCellBase( normals, tangents1, tangents2 )
+
+ createAttribute( surf, dipAngles, "dipAngle" )
+ createAttribute( surf, strikeAngles, "strikeAngle" )
return surf
@@ -741,9 +764,9 @@ def diagnoseNormals( self: Self, scaleFactor: float = 50.0, zScale: float = 1.0
print( "\n🔍 DIAGNOSTIC DES NORMALES" )
print( "=" * 60 )
- normals = surface.cell_data[ 'Normals' ]
- tangent1 = surface.cell_data[ 'tangent1' ]
- tangent2 = surface.cell_data[ 'tangent2' ]
+ normals = surface.GetCellData().GetNormals()
+ tangent1 = surface.GetCellData().GetTangents()
+ tangent2 = surface.GetCellData().GetArray( "Tangents2" )
nCells = len( normals )
@@ -804,39 +827,39 @@ def diagnoseNormals( self: Self, scaleFactor: float = 50.0, zScale: float = 1.0
print( "=" * 60 )
# Visualization
- plotter = pv.Plotter( shape=( 1, 2 ) )
+ # plotter = pv.Plotter( shape=( 1, 2 ) )
- # Plot 1: Surface with normals
- plotter.subplot( 0, 0 )
- plotter.add_mesh( surface, color='lightgray', show_edges=True, opacity=0.8 )
+ # # Plot 1: Surface with normals
+ # plotter.subplot( 0, 0 )
+ # plotter.add_mesh( surface, color='lightgray', show_edges=True, opacity=0.8 )
- centers = surface.cell_centers()
- arrowsNorm = centers.glyph( orient='Normals', scale=False, factor=scaleFactor )
- plotter.add_mesh( arrowsNorm, color='red', label='Normals' )
+ # centers = surface.cell_centers()
+ # arrowsNorm = centers.glyph( orient='Normals', scale=False, factor=scaleFactor )
+ # plotter.add_mesh( arrowsNorm, color='red', label='Normals' )
- plotter.add_legend()
- plotter.add_axes()
- plotter.add_text( "Normales (Rouge)", position='upper_edge' )
- plotter.set_scale( zscale=zScale )
+ # plotter.add_legend()
+ # plotter.add_axes()
+ # plotter.add_text( "Normales (Rouge)", position='upper_edge' )
+ # plotter.set_scale( zscale=zScale )
- # Plot 2: All vectors
- plotter.subplot( 0, 1 )
- plotter.add_mesh( surface, color='lightgray', show_edges=True, opacity=0.5 )
+ # # Plot 2: All vectors
+ # plotter.subplot( 0, 1 )
+ # plotter.add_mesh( surface, color='lightgray', show_edges=True, opacity=0.5 )
- arrowsNorm = centers.glyph( orient='Normals', scale=False, factor=scaleFactor )
- arrowsT1 = centers.glyph( orient='tangent1', scale=False, factor=scaleFactor )
- arrowsT2 = centers.glyph( orient='tangent2', scale=False, factor=scaleFactor )
+ # arrowsNorm = centers.glyph( orient='Normals', scale=False, factor=scaleFactor )
+ # arrowsT1 = centers.glyph( orient='tangent1', scale=False, factor=scaleFactor )
+ # arrowsT2 = centers.glyph( orient='tangent2', scale=False, factor=scaleFactor )
- plotter.add_mesh( arrowsNorm, color='red', label='Normal' )
- plotter.add_mesh( arrowsT1, color='green', label='Tangent1' )
- plotter.add_mesh( arrowsT2, color='blue', label='Tangent2' )
+ # plotter.add_mesh( arrowsNorm, color='red', label='Normal' )
+ # plotter.add_mesh( arrowsT1, color='green', label='Tangent1' )
+ # plotter.add_mesh( arrowsT2, color='blue', label='Tangent2' )
- plotter.add_legend()
- plotter.add_axes()
- plotter.add_text( "Système complet (R,G,B)", position='upper_edge' )
- plotter.set_scale( zscale=zScale )
+ # plotter.add_legend()
+ # plotter.add_axes()
+ # plotter.add_text( "Système complet (R,G,B)", position='upper_edge' )
+ # plotter.set_scale( zscale=zScale )
- plotter.link_views()
- plotter.show()
+ # plotter.link_views()
+ # plotter.show()
return surface
diff --git a/geos-processing/src/geos/processing/post_processing/FaultStabilityAnalysis.py b/geos-processing/src/geos/processing/post_processing/FaultStabilityAnalysis.py
index b0f1584c..65be56e6 100755
--- a/geos-processing/src/geos/processing/post_processing/FaultStabilityAnalysis.py
+++ b/geos-processing/src/geos/processing/post_processing/FaultStabilityAnalysis.py
@@ -3,15 +3,19 @@
# SPDX-FileContributor: Nicolas Pillardou, Paloma Martinez
from pathlib import Path
import numpy as np
-import pyvista as pv
from typing_extensions import Self
+from geos.mesh.utils.multiblockModifiers import mergeBlocks
+from geos.mesh.io.vtkIO import PVDReader
+from geos.mesh.utils.arrayHelpers import (isAttributeInObject, getAttributeSet)
+
from geos.processing.post_processing.FaultGeometry import FaultGeometry
from geos.processing.tools.FaultVisualizer import Visualizer
from geos.processing.post_processing.SensitivityAnalyzer import SensitivityAnalyzer
from geos.processing.post_processing.StressProjector import StressProjector
from geos.processing.post_processing.MohrCoulomb import MohrCoulomb
+from geos.utils.pieceEnum import Piece
# ============================================================================
# TIME SERIES PROCESSING
# ============================================================================
@@ -28,7 +32,25 @@ def __init__( self: Self, outputDir: str = ".", showPlots: bool = True, savePlot
self.savePlots: bool = savePlots
# -------------------------------------------------------------------
- def process( self: Self, path: Path, faultGeometry: FaultGeometry, pvdFile: str, timeIndexes: list[ int ] = [], weightingScheme: str = "arithmetic", cohesion: float = 0, frictionAngle: float = 10, runSensitivity: bool = True, profileStartPoints: list[tuple[ float, ...]] = [], computePrincipalStress: bool = True, showDepthProfiles: bool = True, stressName: str = "averageStress", biotCoefficient: str = "rockPorosity_biotCoefficient", profileSearchRadius=None, minDepthProfiles=None, maxDepthProfiles=None ) -> pv.DataSet:
+ def process( self: Self,
+ path: Path,
+ faultGeometry: FaultGeometry,
+ pvdFile: str,
+ timeIndexes: list[ int ] = [],
+ weightingScheme: str = "arithmetic",
+ cohesion: float = 0,
+ frictionAngle: float = 10,
+ runSensitivity: bool = True,
+ profileStartPoints: list[tuple[ float, ...]] = [],
+ computePrincipalStress: bool = False,
+ showDepthProfiles: bool = True,
+ stressName: str = "averageStress",
+ biotCoefficient: str = "rockPorosity_biotCoefficient",
+ profileSearchRadius=None,
+ minDepthProfiles=None,
+ maxDepthProfiles=None,
+ sensitivityFrictionAngles=None,
+ sensitivityCohesions=None ):
"""Process all time steps using pre-computed fault geometry.
Parameters:
@@ -36,8 +58,8 @@ def process( self: Self, path: Path, faultGeometry: FaultGeometry, pvdFile: str,
faultGeometry: FaultGeometry object with initialized topology
pvdFile: PVD file name
"""
- pvdReader = pv.PVDReader( path / pvdFile )
- timeValues = np.array( pvdReader.time_values )
+ reader = PVDReader( path / pvdFile )
+ timeValues = reader.getAllTimestepsValues()
if timeIndexes:
timeValues = timeValues[ timeIndexes ]
@@ -63,15 +85,15 @@ def process( self: Self, path: Path, faultGeometry: FaultGeometry, pvdFile: str,
# Read time step
idx = timeIndexes[ i ] if timeIndexes else i
- pvdReader.set_active_time_point( idx )
- dataset = pvdReader.read()
+ dataset = reader.getDataSetAtTimeIndex( idx )
# Merge blocks
- volumeData = self._mergeBlocks( dataset )
+ volumeData = mergeBlocks( dataset, keepPartialAttributes=True )
if dataInitial is None:
dataInitial = volumeData
+
# -----------------------------------
# Projection using pre-computed topology
# -----------------------------------
@@ -104,11 +126,13 @@ def process( self: Self, path: Path, faultGeometry: FaultGeometry, pvdFile: str,
# -----------------------------------
if runSensitivity:
analyzer = SensitivityAnalyzer( self.outputDir, self.showPlots )
- analyzer.runAnalysis( surfaceResult, time )
+ if sensitivityFrictionAngles is None or sensitivityCohesions is None:
+ raise ValueError( "sensitivity friction angles and cohesions required if runSensitivity is set to True" )
+ analyzer.runAnalysis( surfaceResult, time, sensitivityFrictionAngles, sensitivityCohesions, profileStartPoints, profileSearchRadius )
# Save
filename = f'fault_analysis_{i:04d}.vtu'
- surfaceResult.save( self.outputDir / filename )
+ # surfaceResult.save( self.outputDir / filename )
outputFiles.append( ( time, filename ) )
print( f" 💾 Saved: {filename}" )
@@ -116,70 +140,9 @@ def process( self: Self, path: Path, faultGeometry: FaultGeometry, pvdFile: str,
self._createPVD( outputFiles )
return surfaceResult
-
- # -------------------------------------------------------------------
- @staticmethod
- def _mergeBlocks( dataset: pv.DataSet ) -> pv.UnstructuredGrid:
- """Merge multi-block dataset - descente automatique jusqu'aux données."""
-
- # -----------------------------------------------
- def extractLeafBlocks(
- block: pv.DataSet,
- path: str = "",
- depth: float = 0
- ) -> list[ tuple[ pv.DataSet, str, tuple[ float, float, float, float, float, float ] ] ]:
- """Descend récursivement dans la structure MultiBlock jusqu'aux feuilles avec données.
-
- Returns:
- list of (block, path, bounds) tuples
- """
- leaves = []
-
- # Cas 1: C'est un MultiBlock avec des sous-blocs
- if hasattr( block, 'n_blocks' ) and block.n_blocks > 0:
- for i in range( block.n_blocks ):
- subBlock = block.GetBlock( i )
- blockName = block.get_block_name( i ) if hasattr( block, 'get_block_name' ) else f"Block{i}"
- newPath = f"{path}/{blockName}" if path else blockName
-
- if subBlock is not None:
- # Récursion
- leaves.extend( extractLeafBlocks( subBlock, newPath, depth + 1 ) )
-
- # Cas 2: C'est un dataset final (feuille)
- elif hasattr( block, 'n_cells' ) and block.n_cells > 0:
- bounds = block.bounds
- leaves.append( ( block, path, bounds ) )
-
- return leaves
-
- print( " 📦 Extracting volume blocks" )
-
- # Extraire toutes les feuilles
- allBlocks = extractLeafBlocks( dataset )
-
- # Filtrer et afficher
- merged = []
- blocksWithPressure = 0
- blocksWithoutPressure = 0
-
- for block, _path, _bounds in allBlocks:
- hasPressure = 'pressure' in block.cell_data
-
- if hasPressure:
- blocksWithPressure += 1
- merged.append( block )
- else:
- blocksWithoutPressure += 1
-
- # Combiner
- combined = pv.MultiBlock( merged ).combine()
-
- return combined
-
# -------------------------------------------------------------------
- def _plotResults( self, surface: pv.PolyData, contributingCells: pv.DataSet, time: list[ int ],
- path: str, profileStartPoints: list[tuple[float, ...]], computePrincipalStress: bool = True, showDepthProfiles:bool = True,
+ def _plotResults( self, surface, contributingCells, time: list[ int ],
+ path: str, profileStartPoints: list[tuple[float, ...]], computePrincipalStress: bool = False, showDepthProfiles:bool = True,
profileSearchRadius: float|None=None, minDepthProfiles: float | None = None,
maxDepthProfiles: float | None = None, ) -> None: # TODO check type surface
Visualizer.plotMohrCoulombDiagram( surface,
@@ -205,7 +168,6 @@ def _plotResults( self, surface: pv.PolyData, contributingCells: pv.DataSet, tim
showPlots = self.showPlots, savePlots = self.savePlots )
if computePrincipalStress:
-
# Plot principal stress from volume cells
visualizer.plotVolumeStressProfiles( volumeMesh=contributingCells,
faultSurface=surface,
@@ -235,4 +197,3 @@ def _createPVD( self, outputFiles: list[ tuple[ int, str ] ] ) -> None:
f.write( '\n' )
print( f"\n✅ PVD created: {pvdPath}" )
-
diff --git a/geos-processing/src/geos/processing/post_processing/MohrCoulomb.py b/geos-processing/src/geos/processing/post_processing/MohrCoulomb.py
index ffda01c2..6a6dbcb1 100644
--- a/geos-processing/src/geos/processing/post_processing/MohrCoulomb.py
+++ b/geos-processing/src/geos/processing/post_processing/MohrCoulomb.py
@@ -1,5 +1,10 @@
import numpy as np
-import pyvista as pv
+from vtkmodules.vtkCommonDataModel import (
+ vtkDataSet )
+from vtkmodules.util.numpy_support import numpy_to_vtk
+from geos.mesh.utils.arrayHelpers import ( getArrayInObject, isAttributeInObject )
+from geos.mesh.utils.arrayModifiers import ( createAttribute, updateAttribute )
+from geos.utils.pieceEnum import Piece
# ============================================================================
# MOHR COULOMB
# ============================================================================
@@ -7,7 +12,7 @@ class MohrCoulomb:
"""Mohr-Coulomb failure criterion analysis."""
@staticmethod
- def analyze( surface: pv.DataSet, cohesion: float, frictionAngleDeg: float, verbose: bool = True ) -> pv.DataSet:
+ def analyze( surface: vtkDataSet, cohesion: float, frictionAngleDeg: float, verbose: bool = True ) -> vtkDataSet:
"""Perform Mohr-Coulomb stability analysis.
Parameters:
@@ -19,10 +24,10 @@ def analyze( surface: pv.DataSet, cohesion: float, frictionAngleDeg: float, verb
mu = np.tan( np.radians( frictionAngleDeg ) )
# Extract stress components
- sigmaN = surface.cell_data[ "sigmaNEffective" ]
- tau = surface.cell_data[ "tauEffective" ]
- surface.cell_data[ 'deltaSigmaNEffective' ]
- surface.cell_data[ 'deltaTauEffective' ]
+ sigmaN = getArrayInObject( surface, "sigmaNEffective", Piece.CELLS )
+ tau = getArrayInObject( surface, "tauEffective", Piece.CELLS )
+ deltaSigmaN = getArrayInObject( surface, 'deltaSigmaNEffective', Piece.CELLS )
+ deltaTau = getArrayInObject( surface, 'deltaTauEffective', Piece.CELLS )
# Mohr-Coulomb failure envelope
tauCritical = cohesion - sigmaN * mu
@@ -34,21 +39,22 @@ def analyze( surface: pv.DataSet, cohesion: float, frictionAngleDeg: float, verb
# Shear Capacity Utilization: SCU = τ / τ_crit
SCU = np.divide( tau, tauCritical, out=np.zeros_like( tau ), where=tauCritical != 0 )
- if "SCUInitial" not in surface.cell_data:
+ # if "SCUInitial" not in surface.cell_data:
+ if not isAttributeInObject( surface, "SCUInitial", Piece.CELLS ):
# First timestep: store as initial reference
SCUInitial = SCU.copy()
CFSInitial = CFS.copy()
deltaSCU = np.zeros_like( SCU )
deltaCFS = np.zeros_like( CFS )
- surface.cell_data[ "SCUInitial" ] = SCUInitial
- surface.cell_data[ "CFSInitial" ] = CFSInitial
+ createAttribute( surface, SCUInitial, "SCUInitial" )
+ createAttribute( surface, CFSInitial, "CFSInitial" )
isInitial = True
else:
# Subsequent timesteps: calculate change from initial
- SCUInitial = surface.cell_data[ "SCUInitial" ]
- CFSInitial = surface.cell_data[ 'CFSInitial' ]
+ SCUInitial = getArrayInObject( surface, "SCUInitial", Piece.CELLS )
+ CFSInitial = getArrayInObject( surface, "CFSInitial", Piece.CELLS )
deltaSCU = SCU - SCUInitial
deltaCFS = CFS - CFSInitial
isInitial = False
@@ -66,19 +72,22 @@ def analyze( surface: pv.DataSet, cohesion: float, frictionAngleDeg: float, verb
safety = tauCritical - tau
# Store results
- surface.cell_data.update( {
- "mohrCohesion": np.full( surface.n_cells, cohesion ),
- "mohrFrictionAngle": np.full( surface.n_cells, frictionAngleDeg ),
- "mohrFrictionCoefficient": np.full( surface.n_cells, mu ),
- "mohr_critical_shear_stress": tauCritical,
- "SCU": SCU,
- "deltaSCU": deltaSCU,
- "CFS": CFS,
- "deltaCFS": deltaCFS,
- "safetyMargin": safety,
- "stabilityState": stability,
- "failureProbability": failureProba
- } )
+ attributes = { "mohrCohesion": np.full( surface.GetNumberOfCells(), cohesion ),
+ "mohrFrictionAngle": np.full( surface.GetNumberOfCells(), frictionAngleDeg ),
+ "mohrFrictionCoefficient": np.full( surface.GetNumberOfCells(), mu ),
+ "mohr_critical_shear_stress": tauCritical,
+ "SCU": SCU,
+ "deltaSCU": deltaSCU,
+ "CFS": CFS,
+ "deltaCFS": deltaCFS,
+ "safetyMargin": safety,
+ "stabilityState": stability,
+ "failureProbability": failureProba }
+
+ cdata = surface.GetCellData()
+ for attributeName, value in attributes.items():
+ updateAttribute( surface, value, attributeName, Piece.CELLS )
+
if verbose:
nStable = np.sum( stability == 0 )
diff --git a/geos-processing/src/geos/processing/post_processing/ProfileExtractor.py b/geos-processing/src/geos/processing/post_processing/ProfileExtractor.py
index ebc8c3f8..9ed491dd 100644
--- a/geos-processing/src/geos/processing/post_processing/ProfileExtractor.py
+++ b/geos-processing/src/geos/processing/post_processing/ProfileExtractor.py
@@ -5,6 +5,8 @@
import pyvista as pv
import numpy.typing as npt
+from vtkmodules.vtkCommonDataModel import vtkCellData, vtkDataSet
+from vtkmodules.util.numpy_support import vtk_to_numpy
# ============================================================================
# PROFILE EXTRACTOR
@@ -24,7 +26,7 @@ def extractAdaptiveProfile(
stepSize: float = 20.0,
maxSteps: float = 500,
verbose: bool = True,
- cellData: dict[ str, npt.NDArray ] | None = None
+ cellData: vtkCellData = None
) -> tuple[ npt.NDArray[ np.float64 ], npt.NDArray[ np.float64 ], npt.NDArray[ np.float64 ],
npt.NDArray[ np.float64 ] ]:
"""Extraction de profil vertical par COUCHES DE PROFONDEUR avec détection automatique de faille.
@@ -114,8 +116,9 @@ def extractAdaptiveProfile(
faultFieldNames = [ 'attribute', 'FaultMask', 'faultId', 'region' ]
for fieldName in faultFieldNames:
- if fieldName in cellData:
- faultIds = np.asarray( cellData[ fieldName ] )
+ if cellData.HasArray( fieldName ):
+ faultIds = vtk_to_numpy( cellData[ fieldName ] )
+
if len( faultIds ) != len( centers ):
if verbose:
@@ -305,7 +308,7 @@ def extractAdaptiveProfile(
# Trier par profondeur décroissante (haut → bas)
sortOrder = np.argsort( -centers[ profileIndicesArr, 2 ] )
- profileIndicesArr = profileIndices[ sortOrder ]
+ profileIndicesArr = profileIndicesArr[ sortOrder ]
# Extraire résultats
depths = centers[ profileIndicesArr, 2 ]
diff --git a/geos-processing/src/geos/processing/post_processing/SensitivityAnalyzer.py b/geos-processing/src/geos/processing/post_processing/SensitivityAnalyzer.py
index f4c72d31..7fa15e02 100644
--- a/geos-processing/src/geos/processing/post_processing/SensitivityAnalyzer.py
+++ b/geos-processing/src/geos/processing/post_processing/SensitivityAnalyzer.py
@@ -10,12 +10,16 @@
from matplotlib.colors import Normalize
from matplotlib.cm import ScalarMappable
import matplotlib.pyplot as plt
-import pyvista as pv
from typing_extensions import Any, Self
+
+from vtkmodules.vtkCommonDataModel import vtkCellData, vtkDataSet, vtkPolyData, vtkUnstructuredGrid
+
+from geos.utils.pieceEnum import Piece
+from geos.mesh.utils.arrayHelpers import ( getArrayInObject )
from geos.processing.post_processing.MohrCoulomb import ( MohrCoulomb )
-from geos.processing.post_processing.ProfileExtractor import ProfileExtractor
+from geos.processing.post_processing.ProfileExtractor import ( ProfileExtractor )
class SensitivityAnalyzer:
@@ -30,7 +34,7 @@ def __init__( self: Self, outputDir: str = ".", showPlots: bool = True ) -> None
self.showPlots = showPlots
# -------------------------------------------------------------------
- def runAnalysis( self: Self, surfaceWithStress: pv.DataSet, time: float, sensitivityFrictionAngles: list[float], sensitivityCohesions: list[float], profileStartPoints: list[tuple[float]], profileSearchRadius: list[tuple[float]] ) -> list[ dict[ str, Any ] ]:
+ def runAnalysis( self: Self, surfaceWithStress, time: float, sensitivityFrictionAngles: list[float], sensitivityCohesions: list[float], profileStartPoints: list[tuple[float]], profileSearchRadius: list[tuple[float]] ) -> list[ dict[ str, Any ] ]:
"""Run sensitivity analysis for multiple friction angles and cohesions."""
frictionAngles = sensitivityFrictionAngles
cohesions = sensitivityCohesions
@@ -43,12 +47,12 @@ def runAnalysis( self: Self, surfaceWithStress: pv.DataSet, time: float, sensiti
print( f"Total combinations: {len(frictionAngles) * len(cohesions)}" )
results = []
-
for frictionAngle in frictionAngles:
for cohesion in cohesions:
print( f"\n→ Testing φ={frictionAngle}°, C={cohesion} bar" )
- surfaceCopy = surfaceWithStress.copy()
+ surfaceCopy = type(surfaceWithStress)()
+ surfaceCopy.DeepCopy( surfaceWithStress )
surfaceAnalyzed = MohrCoulomb.analyze(
surfaceCopy,
@@ -74,24 +78,27 @@ def runAnalysis( self: Self, surfaceWithStress: pv.DataSet, time: float, sensiti
return results
# -------------------------------------------------------------------
- def _extractStatistics( self: Self, surface: pv.DataSet, frictionAngle: float,
+ def _extractStatistics( self: Self, surface: vtkPolyData, frictionAngle: float,
cohesion: float ) -> dict[ str, Any ]:
"""Extract statistical metrics from analyzed surface."""
- stability = surface.cell_data[ "stabilityState" ]
- SCU = surface.cell_data[ "SCU" ]
- failureProba = surface.cell_data[ "failureProbability" ]
- safetyMargin = surface.cell_data[ "safetyMargin" ]
+ stability = getArrayInObject( surface, "stabilityState", Piece.CELLS )
+ SCU = getArrayInObject( surface, "SCU", Piece.CELLS )
+ failureProba = getArrayInObject( surface, "failureProbability", Piece.CELLS )
+ safetyMargin = getArrayInObject( surface, "safetyMargin", Piece.CELLS )
+
+ nCells = surface.GetNumberOfCells()
+
stats = {
'frictionAngle': frictionAngle,
'cohesion': cohesion,
- 'nCells': surface.n_cells,
+ 'nCells': nCells,
'nStable': np.sum( stability == 0 ),
'nCritical': np.sum( stability == 1 ),
'nUnstable': np.sum( stability == 2 ),
- 'pctUnstable': np.sum( stability == 2 ) / surface.n_cells * 100,
- 'pctCritical': np.sum( stability == 1 ) / surface.n_cells * 100,
- 'pctStable': np.sum( stability == 0 ) / surface.n_cells * 100,
+ 'pctUnstable': np.sum( stability == 2 ) / nCells * 100,
+ 'pctCritical': np.sum( stability == 1 ) / nCells * 100,
+ 'pctStable': np.sum( stability == 0 ) / nCells * 100,
'meanSCU': np.mean( SCU ),
'maxSCU': np.max( SCU ),
'meanFailureProb': np.mean( failureProba ),
@@ -114,17 +121,15 @@ def _plotSensitivityResults( self: Self, results: list[ dict[ str, Any ] ], time
self._plotHeatMap( df, 'meanSCU', 'Mean SCU [-]', axes[ 1, 0 ] )
self._plotHeatMap( df, 'meanSafetyMargin', 'Mean Safety Margin [bar]', axes[ 1, 1 ] )
- plt.tight_layout()
+ fig.tight_layout()
years = time / ( 365.25 * 24 * 3600 )
filename = f'sensitivity_analysis_{years:.0f}y.png'
- plt.savefig( self.outputDir / filename, dpi=300, bbox_inches='tight' )
+ fig.savefig( self.outputDir / filename, dpi=300, bbox_inches='tight' )
print( f"\n📊 Sensitivity plot saved: {filename}" )
if self.showPlots:
- plt.show()
- else:
- plt.close()
+ fig.show()
# -------------------------------------------------------------------
def _plotHeatMap( self: Self, df: pd.DataFrame, column: str, title: str, ax: plt.Axes ) -> None:
@@ -153,17 +158,16 @@ def _plotHeatMap( self: Self, df: pd.DataFrame, column: str, title: str, ax: plt
# -------------------------------------------------------------------
def _plotSCUDepthProfiles( self: Self, results: list[ dict[ str, Any ] ], time: float,
- surfaceWithStress: pv.DataSet, profileStartPoints=None, profileSearchRadius=None,
+ surfaceWithStress: vtkDataSet, profileStartPoints=None, profileSearchRadius=None,
maxDepthProfiles=None ) -> None:
"""Plot SCU depth profiles for all parameter combinations.
Each (cohesion, friction) pair gets a unique color
- Uses profile points from config.PROFILE_START_POINTS.
"""
print( "\n 📊 Creating SCU sensitivity depth profiles..." )
# Extract depth data
- centers = surfaceWithStress.cell_data[ 'elementCenter' ]
+ centers = getArrayInObject( surfaceWithStress, 'elementCenter', Piece.CELLS )
centers[ :, 2 ]
# Get profile points from config
@@ -189,12 +193,14 @@ def _plotSCUDepthProfiles( self: Self, results: list[ dict[ str, Any ] ], time:
profileStartPoints = [ ( xPos, yPos ) ]
# Get search radius from config or auto-compute
- if searchRadius is None:
+ if profileSearchRadius is None:
xMin, xMax = np.min( centers[ :, 0 ] ), np.max( centers[ :, 0 ] )
yMin, yMax = np.min( centers[ :, 1 ] ), np.max( centers[ :, 1 ] )
xRange = xMax - xMin
yRange = yMax - yMin
searchRadius = min( xRange, yRange ) * 0.15
+ else:
+ searchRadius = profileSearchRadius
print( f" 📍 Using {len(profileStartPoints)} profile point(s) from config" )
print( f" Search radius: {searchRadius:.1f}m" )
@@ -225,16 +231,16 @@ def _plotSCUDepthProfiles( self: Self, results: list[ dict[ str, Any ] ], time:
cohesion = params[ 'cohesion' ]
# Re-analyze surface with these parameters
- surfaceCopy = surfaceWithStress.copy()
+ surfaceCopy = type(surfaceWithStress)()
+ surfaceCopy.DeepCopy( surfaceWithStress )
surfaceAnalyzed = MohrCoulomb.analyze(
- # surfaceCopy, cohesion, frictionAngle, time, verbose=False
surfaceCopy,
cohesion,
frictionAngle,
verbose=False )
# Extract SCU
- SCU = np.abs( surfaceAnalyzed.cell_data[ "SCU" ] )
+ SCU = np.abs( getArrayInObject( surfaceAnalyzed, "SCU", Piece.CELLS ) )
# Extract profile using adaptive method
# depthsSCU, profileSCU, _, _ = ProfileExtractor.extractVerticalProfileTopologyBased(
@@ -282,14 +288,12 @@ def _plotSCUDepthProfiles( self: Self, results: list[ dict[ str, Any ] ], time:
years = time / ( 365.25 * 24 * 3600 )
fig.suptitle( 'SCU Depth Profiles - Sensitivity Analysis', fontsize=16, weight='bold', y=0.98 )
- plt.tight_layout( rect=( 0, 0, 1, 0.96 ) )
+ fig.tight_layout( rect=( 0, 0, 1, 0.96 ) )
# Save
filename = f'sensitivity_scu_profiles_{years:.0f}y.png'
- plt.savefig( self.outputDir / filename, dpi=300, bbox_inches='tight' )
+ fig.savefig( self.outputDir / filename, dpi=300, bbox_inches='tight' )
print( f"\n 💾 SCU sensitivity profiles saved: {filename}" )
if self.showPlots:
- plt.show()
- else:
- plt.close()
+ fig.show()
diff --git a/geos-processing/src/geos/processing/post_processing/StressProjector.py b/geos-processing/src/geos/processing/post_processing/StressProjector.py
index 4c2e7361..7709ce74 100644
--- a/geos-processing/src/geos/processing/post_processing/StressProjector.py
+++ b/geos-processing/src/geos/processing/post_processing/StressProjector.py
@@ -9,7 +9,15 @@
from xml.etree.ElementTree import ElementTree, Element, SubElement
import pyvista as pv
+from vtkmodules.vtkCommonDataModel import ( vtkUnstructuredGrid, vtkPolyData )
+from vtkmodules.util.numpy_support import vtk_to_numpy, numpy_to_vtk
+
from geos.geomechanics.model.StressTensor import StressTensor
+from geos.mesh.utils.genericHelpers import ( extractCellSelection, getLocalBasisVectors )
+from geos.mesh.utils.arrayHelpers import (isAttributeInObject, getArrayInObject, computeCellCenterCoordinates)
+from geos.mesh.utils.arrayModifiers import (createAttribute, updateAttribute)
+from geos.utils.pieceEnum import Piece
+from geos.mesh.io.vtkIO import writeMesh, VtkOutput
# ============================================================================
@@ -67,33 +75,33 @@ def setMonitoredCells( self: Self, cellIndices: list[ int ] | None = None ) -> N
# -------------------------------------------------------------------
def projectStressToFault(
self: Self,
- volumeData: pv.UnstructuredGrid,
- volumeInitial: pv.UnstructuredGrid,
- faultSurface: pv.PolyData,
+ volumeData: vtkUnstructuredGrid,
+ volumeInitial: vtkUnstructuredGrid,
+ faultSurface: vtkPolyData,
time: float | None = None,
timestep: int | None = None,
weightingScheme: str = "arithmetic",
stressName: str = "averageStress",
biotName: str = "rockPorosity_biotCoefficient",
computePrincipalStresses: bool = False,
- frictionAngle: float = 10, cohesion: float = 0 ) -> tuple[ pv.PolyData, pv.UnstructuredGrid, pv.UnstructuredGrid ]:
+ frictionAngle: float = 10, cohesion: float = 0 ) -> tuple[ vtkPolyData, vtkUnstructuredGrid, vtkUnstructuredGrid ]:
"""Project stress and save principal stresses to VTU.
Now uses pre-computed geometric properties for efficiency
"""
- if stressName not in volumeData.array_names:
+ if not isAttributeInObject ( volumeData, stressName, Piece.CELLS ):
raise ValueError( f"No stress data '{stressName}' in dataset" )
# =====================================================================
# 1. EXTRACT STRESS DATA
# =====================================================================
- pressure = volumeData[ "pressure" ] / 1e5
- pressureFault = volumeInitial[ "pressure" ] / 1e5
- pressureInitial = volumeInitial[ "pressure" ] / 1e5
- biot = volumeData[ biotName ]
+ pressure = getArrayInObject( volumeData, "pressure", Piece.CELLS ) / 1e5
+ pressureFault = getArrayInObject( volumeInitial, "pressure", Piece.CELLS ) / 1e5
+ pressureInitial = getArrayInObject( volumeInitial, "pressure", Piece.CELLS ) / 1e5
+ biot = getArrayInObject( volumeData, biotName, Piece.CELLS )
- stressEffective = StressTensor.buildFromArray( volumeData[ stressName ] / 1e5 )
- stressEffectiveInitial = StressTensor.buildFromArray( volumeInitial[ stressName ] / 1e5 )
+ stressEffective = StressTensor.buildFromArray( getArrayInObject( volumeData, stressName, Piece.CELLS ) / 1e5 )
+ stressEffectiveInitial = StressTensor.buildFromArray( getArrayInObject( volumeInitial, stressName, Piece.CELLS ) / 1e5 )
# Convert effective stress to total stress
arrI = np.eye( 3 )[ None, :, : ]
@@ -107,14 +115,24 @@ def projectStressToFault(
# =====================================================================
# 3. PREPARE FAULT GEOMETRY
# =====================================================================
- normals = faultSurface.cell_data[ "Normals" ]
- tangent1 = faultSurface.cell_data[ "tangent1" ]
- tangent2 = faultSurface.cell_data[ "tangent2" ]
+ # TODO fix
+ normalsXX, tangent1XX, tangent2XX = getLocalBasisVectors( faultSurface )
+ # normals = faultSurface.cell_data[ "Normals" ]
+ # tangent1 = faultSurface.cell_data[ "tangent1" ]
+ # tangent2 = faultSurface.cell_data[ "tangent2" ]
+ normals = vtk_to_numpy( faultSurface.GetCellData().GetNormals() )
+ tangent1 = vtk_to_numpy( faultSurface.GetCellData().GetArray( "Tangents1") )
+ tangent2 = vtk_to_numpy( faultSurface.GetCellData().GetArray( "Tangents2") )
+
+ print( (normalsXX - normals ).max() )
+ print( (tangent1XX - tangent1 ).max() )
+ print( (tangent2XX - tangent2 ).max() )
- faultCenters = faultSurface.cell_centers().points
- faultSurface.cell_data[ 'elementCenter' ] = faultCenters
+ faultCenters = computeCellCenterCoordinates( faultSurface )
+ fcenters = faultSurface.GetCellData().GetArray( 'elementCenter' )
+ fcenters = faultCenters
- nFault = faultSurface.n_cells
+ nFault = faultSurface.GetNumberOfCells()
# =====================================================================
# 4. COMPUTE PRINCIPAL STRESSES FOR CONTRIBUTING CELLS
@@ -166,13 +184,6 @@ def projectStressToFault(
volPlus = self.adjacencyMapping[ faultIdx ][ 'plus' ]
volMinus = self.adjacencyMapping[ faultIdx ][ 'minus' ]
allVol = volPlus + volMinus
- # for faultIdx in range( nFault ):
- # if faultIdx not in mapping:
- # continue
-
- # volPlus = mapping[ faultIdx ][ 'plus' ]
- # volMinus = mapping[ faultIdx ][ 'minus' ]
- # allVol = volPlus + volMinus
if len( allVol ) == 0:
continue
@@ -258,12 +269,11 @@ def projectStressToFault(
# =====================================================================
# 7. STORE RESULTS ON FAULT SURFACE
# =====================================================================
- faultSurface.cell_data[ "sigmaNEffective" ] = sigmaNArr
- faultSurface.cell_data[ "tauEffective" ] = tauDipArr
- faultSurface.cell_data[ "tauStrike" ] = tauStrikeArr
- faultSurface.cell_data[ "tauDip" ] = tauDipArr
- faultSurface.cell_data[ "deltaSigmaNEffective" ] = deltaSigmaNArr
- faultSurface.cell_data[ "deltaTauEffective" ] = deltaTauArr
+ for attributeName, value in zip(
+ [ "sigmaNEffective", "tauEffective", "tauStrike", "tauDip", "deltaSigmaNEffective", "deltaTauEffective" ],
+ [ sigmaNArr, tauDipArr, tauStrikeArr, tauDipArr, deltaSigmaNArr, deltaTauArr ]
+ ):
+ updateAttribute( faultSurface, value, attributeName, Piece.CELLS )
# =====================================================================
# 8. STATISTICS
@@ -333,16 +343,16 @@ def _createVolumicContribMesh( self: Self, volumeData: pv.UnstructuredGrid, faul
# EXTRACT STRESS DATA FROM VOLUME
# ===================================================================
- if stressName not in volumeData.array_names:
+ if not isAttributeInObject( volumeData, stressName, Piece.CELLS ):
raise ValueError( f"No stress data '{stressName}' in volume dataset" )
print( f" 📊 Extracting stress from field: '{stressName}'" )
# Extract effective stress and pressure
- pressure = volumeData[ "pressure" ] / 1e5 # Convert to bar
- biot = volumeData[ biotName ]
+ pressure = getArrayInObject( volumeData, "pressure", Piece.CELLS ) / 1e5 # Convert to bar
+ biot = getArrayInObject( volumeData, biotName, Piece.CELLS )
- stressEffective = StressTensor.buildFromArray( volumeData[ stressName ] / 1e5 )
+ stressEffective = StressTensor.buildFromArray( getArrayInObject( volumeData, stressName, Piece.CELLS ) / 1e5 )
# Convert effective stress to total stress
arrI = np.eye( 3 )[ None, :, : ]
@@ -352,21 +362,21 @@ def _createVolumicContribMesh( self: Self, volumeData: pv.UnstructuredGrid, faul
# EXTRACT SUBSET OF CELLS
# ===================================================================
cellIndices = sorted( cellsToTrack )
- cellMask = np.zeros( volumeData.n_cells, dtype=bool )
+ cellMask = np.zeros( volumeData.GetNumberOfCells(), dtype=bool )
cellMask[ cellIndices ] = True
- subsetMesh = volumeData.extract_cells( cellMask )
+ subsetMesh = extractCellSelection ( cellMask )
# ===================================================================
# REBUILD MAPPING: subsetIdx -> originalIdx
# ===================================================================
- originalCenters = volumeData.cell_centers().points[ cellIndices ]
- subsetCenters = subsetMesh.cell_centers().points
+ originalCenters = vtk_to_numpy( computeCellCenterCoordinates( volumeData ) )[ cellIndices ]
+ subsetCenters = vtk_to_numpy( computeCellCenterCoordinates( subsetMesh ) )
tree = cKDTree( originalCenters )
- subsetToOriginal = np.zeros( subsetMesh.n_cells, dtype=int )
- for subsetIdx in range( subsetMesh.n_cells ):
+ subsetToOriginal = np.zeros( subsetMesh.GetNumberOfCells(), dtype=int )
+ for subsetIdx in range( subsetMesh.GetNumberOfCells() ):
dist, idx = tree.query( subsetCenters[ subsetIdx ] )
if dist > 1e-6:
print( f" WARNING: Cell {subsetIdx} not matched (dist={dist})" )
@@ -386,17 +396,17 @@ def _createVolumicContribMesh( self: Self, volumeData: pv.UnstructuredGrid, faul
if 'strikeAngle' not in faultSurface.cell_data:
print( " ⚠️ WARNING: 'strikeAngle' not found in faultSurface" )
- # Create mapping: volume_cell_id -> [dip_angles, strike_angles]
+ # Create mapping: volume_cell_id -> [dipAngles, strikeAngles]
volumeToDip: dict[ int, npt.NDArray[ np.float64 ] ] = {}
volumeToStrike: dict[ int, npt.NDArray[ np.float64 ] ] = {}
for faultIdx, neighbors in mapping.items():
# Get dip and strike angle from fault cell
- faultDip = faultSurface.cell_data[ 'dipAngle' ][ faultIdx ]
+ faultDip = getArrayInObject( faultSurface, 'dipAngle', Piece.CELLS )[ faultIdx ]
# Strike is optional
- if 'strikeAngle' in faultSurface.cell_data:
- faultStrike = faultSurface.cell_data[ 'strikeAngle' ][ faultIdx ]
+ if isAttributeInObject ( faultSurface, 'strikeAngle', Piece.CELLS):
+ faultStrike = getArrayInObject( faultSurface, 'strikeAngle', Piece.CELLS )[ faultIdx ]
else:
faultStrike = np.nan
@@ -422,7 +432,7 @@ def _createVolumicContribMesh( self: Self, volumeData: pv.UnstructuredGrid, faul
# ===================================================================
# COMPUTE PRINCIPAL STRESSES AND ANALYTICAL FAULT STRESSES
# ===================================================================
- nCells = subsetMesh.n_cells
+ nCells = subsetMesh.GetNumberOfCells()
sigma1Arr = np.zeros( nCells )
sigma2Arr = np.zeros( nCells )
@@ -536,23 +546,23 @@ def _createVolumicContribMesh( self: Self, volumeData: pv.UnstructuredGrid, faul
# ===================================================================
# ADD DATA TO MESH
# ===================================================================
- subsetMesh.cell_data[ 'sigma1' ] = sigma1Arr
- subsetMesh.cell_data[ 'sigma2' ] = sigma2Arr
- subsetMesh.cell_data[ 'sigma3' ] = sigma3Arr
- subsetMesh.cell_data[ 'meanStress' ] = meanStressArr
- subsetMesh.cell_data[ 'deviatoricStress' ] = deviatoricStressArr
- subsetMesh.cell_data[ 'pressure_bar' ] = pressureArr
-
- subsetMesh.cell_data[ 'sigma1Direction' ] = direction1Arr
- subsetMesh.cell_data[ 'sigma2Direction' ] = direction2Arr
- subsetMesh.cell_data[ 'sigma3Direction' ] = direction3Arr
-
- # Analytical fault stresses
- subsetMesh.cell_data[ 'sigmaNAnalytical' ] = sigmaNAnalyticalArr
- subsetMesh.cell_data[ 'tauAnalytical' ] = tauAnalyticalArr
- subsetMesh.cell_data[ 'dipAngle' ] = dipAngleArr
- subsetMesh.cell_data[ 'strikeAngle' ] = strikeAngleArr
- subsetMesh.cell_data[ 'deltaAngle' ] = deltaArr
+ for attributeName, attributeArray in zip([
+ ( 'sigma1' , sigma1Arr ),
+ ( 'sigma2' , sigma2Arr ),
+ ( 'sigma3' , sigma3Arr ),
+ ( 'meanStress' , meanStressArr ),
+ ( 'deviatoricStress' , deviatoricStressArr ),
+ ( 'pressure_bar' , pressureArr ),
+ ( 'sigma1Direction' , direction1Arr ),
+ ( 'sigma2Direction' , direction2Arr ),
+ ( 'sigma3Direction' , direction3Arr ),
+ ( 'sigmaNAnalytical', sigmaNAnalyticalArr ),
+ ( 'tauAnalytical', tauAnalyticalArr ),
+ ( 'dipAngle', dipAngleArr ),
+ ( 'strikeAngle', strikeAngleArr ),
+ ( 'deltaAngle', deltaArr ),
+ ]):
+ updateAttribute( subsetMesh, attributeArray, attributeName, piece=Piece.CELLS )
# ===================================================================
# COMPUTE SCU ANALYTICALLY (Mohr-Coulomb)
@@ -567,17 +577,18 @@ def _createVolumicContribMesh( self: Self, volumeData: pv.UnstructuredGrid, faul
tauCriticalArr,
out=np.zeros_like( tauAnalyticalArr ),
where=tauCriticalArr != 0 )
- subsetMesh.cell_data[ 'tauCriticalAnalytical' ] = tauCriticalArr
- subsetMesh.cell_data[ 'SCUAnalytical' ] = SCUAnalyticalArr
+
# CFS (Coulomb Failure Stress)
CFSAnalyticalArr = tauAnalyticalArr - mu * ( -sigmaNAnalyticalArr )
- subsetMesh.cell_data[ 'CFSAnalytical' ] = CFSAnalyticalArr
-
-
- subsetMesh.cell_data[ 'side' ] = sideArr
- subsetMesh.cell_data[ 'nFaultCells' ] = nFaultCellsArr
- subsetMesh.cell_data[ 'originalCellId' ] = subsetToOriginal
+ for attributeName, attributeArray in zip([
+ ( 'tauCriticalAnalytical', tauCriticalArr ),
+ ( 'SCUAnalytical', SCUAnalyticalArr ),
+ ( 'side', sideArr ),
+ ( 'nFaultCells', nFaultCellsArr ),
+ ( 'originalCellIds', subsetToOriginal )
+ ]):
+ createAttribute( subsetMesh, attributeArray, attributeName, piece=Piece.CELLS )
# ===================================================================
# STATISTICS
@@ -622,7 +633,7 @@ def _savePrincipalStressVTU( self: Self, mesh: pv.DataSet, time: float, timestep
vtuPath = self.vtuOutputDir / vtuFilename
# Save mesh
- mesh.save( str( vtuPath ) )
+ writeMesh( mesh=mesh, vtkOutput=VtkOutput( vtuPath ) )
# Store metadata for PVD
self.timestepInfo.append( {
diff --git a/geos-processing/src/geos/processing/tools/FaultVisualizer.py b/geos-processing/src/geos/processing/tools/FaultVisualizer.py
index 3fa6a382..9934b59e 100644
--- a/geos-processing/src/geos/processing/tools/FaultVisualizer.py
+++ b/geos-processing/src/geos/processing/tools/FaultVisualizer.py
@@ -11,10 +11,11 @@
import pyvista as pv
from typing_extensions import Self
-from geos.processing.post_processing.ProfileExtractor import ProfileExtractor
-# from geos.processing.post_processing.FaultStabilityAnalysis import Config
+from vtkmodules.vtkCommonDataModel import vtkPolyData
-# from geos.processing.tools.Config import Config
+from geos.processing.post_processing.ProfileExtractor import ProfileExtractor
+from geos.mesh.utils.arrayHelpers import ( isAttributeInObject, getArrayInObject )
+from geos.utils.pieceEnum import Piece
# ============================================================================
@@ -39,20 +40,20 @@ def __init__( self, profileSearchRadius: float| None = None,
# -------------------------------------------------------------------
@staticmethod
- def plotMohrCoulombDiagram( surface: pv.PolyData,
+ def plotMohrCoulombDiagram( surface: vtkPolyData,
time: float,
path: Path,
show: bool = True,
save: bool = True ) -> None:
"""Create Mohr-Coulomb diagram with depth coloring."""
- sigmaN = -surface.cell_data[ "sigmaNEffective" ]
- tau = np.abs( surface.cell_data[ "tauEffective" ] )
- SCU = np.abs( surface.cell_data[ "SCU" ] )
- depth = surface.cell_data[ 'elementCenter' ][ :, 2 ]
+ sigmaN = - getArrayInObject( surface, "sigmaNEffective", Piece.CELLS )
+ tau = np.abs( getArrayInObject( surface, "tauEffective", Piece.CELLS ) )
+ SCU = np.abs( getArrayInObject( surface, "SCU", Piece.CELLS ) )
+ depth = getArrayInObject( surface, 'elementCenter', Piece.CELLS )[ :, 2 ]
- cohesion = surface.cell_data[ "mohrCohesion" ][ 0 ]
- mu = surface.cell_data[ "mohrFrictionCoefficient" ][ 0 ]
- phi = surface.cell_data[ 'mohrFrictionAngle' ][ 0 ]
+ cohesion = getArrayInObject( surface, "mohrCohesion", Piece.CELLS )[ 0 ]
+ mu = getArrayInObject( surface, "mohrFrictionCoefficient" , Piece.CELLS )[ 0 ]
+ phi = getArrayInObject( surface, 'mohrFrictionAngle' , Piece.CELLS )[ 0 ]
fig, axes = plt.subplots( 1, 2, figsize=( 16, 8 ) )
@@ -632,28 +633,28 @@ def plotVolumeStressProfiles( self: Self,
requiredFields = [ 'sigma1', 'sigma2', 'sigma3', 'side', 'elementCenter' ]
for field in requiredFields:
- if field not in volumeMesh.cell_data:
+ if isAttributeInObject( volumeMesh, field, Piece.CELLS ):
print( f" ⚠️ Missing required field: {field}" )
return
# Check for pressure
- if 'pressure_bar' in volumeMesh.cell_data:
+ if isAttributeInObject( volumeMesh, 'pressure_bar', Piece.CELLS):
pressureField = 'pressure_bar'
- pressure = volumeMesh.cell_data[ pressureField ]
- elif 'pressure' in volumeMesh.cell_data:
+ pressure = getArrayInObject( volumeMesh, pressureField, Piece.CELLS )
+ elif isAttributeInObject( volumeMesh, 'pressure', Piece.CELLS ):
pressureField = 'pressure'
- pressure = volumeMesh.cell_data[ pressureField ] / 1e5
+ pressure = getArrayInObject( volumeMesh, pressureField, Piece.CELLS ) / 1e5
print( " ℹ️ Converting pressure from Pa to bar" )
else:
print( " ⚠️ No pressure field found" )
pressure = None
# Extract volume data
- centers = volumeMesh.cell_data[ 'elementCenter' ]
- sigma1 = volumeMesh.cell_data[ 'sigma1' ]
- sigma2 = volumeMesh.cell_data[ 'sigma2' ]
- sigma3 = volumeMesh.cell_data[ 'sigma3' ]
- sideData = volumeMesh.cell_data[ 'side' ]
+ centers = getArrayInObject( volumeMesh, 'elementCenter', Piece.CELLS )
+ sigma1 = getArrayInObject( volumeMesh, 'sigma1', Piece.CELLS )
+ sigma2 = getArrayInObject( volumeMesh, 'sigma2', Piece.CELLS )
+ sigma3 = getArrayInObject( volumeMesh, 'sigma3', Piece.CELLS )
+ sideData = getArrayInObject( volumeMesh, 'side', Piece.CELLS )
# ===================================================================
# FILTER CELLS BY SIDE (BOTH PLUS AND MINUS)
@@ -668,11 +669,6 @@ def plotVolumeStressProfiles( self: Self,
if pressure is not None:
pressurePlus = pressure[ maskPlus ]
- # Créer subset de cellData pour le côté plus
- cellDataPlus = {}
- for key in volumeMesh.cell_data:
- cellDataPlus[ key ] = volumeMesh.cell_data[ key ][ maskPlus ]
-
# Minus side (side = 2 or 3)
maskMinus = ( sideData == 2 ) | ( sideData == 3 )
centersMinus = centers[ maskMinus ]
@@ -682,10 +678,13 @@ def plotVolumeStressProfiles( self: Self,
if pressure is not None:
pressureMinus = pressure[ maskMinus ]
- # Créer subset de cellData pour le côté minus
+ # Créer subset de cellData pour le côté plus
+ cellDataPlus = {}
cellDataMinus = {}
- for key in volumeMesh.cell_data:
- cellDataMinus[ key ] = volumeMesh.cell_data[ key ][ maskMinus ]
+ for key in volumeMesh.GetCellData().items():
+ cellDataPlus[ key ] = getArrayInObject( volumeMesh, key )[ maskPlus ]
+ cellDataMinus[ key ] = getArrayInObject( volumeMesh, key )[ maskMinus ]
+
print( f" 📍 Plus side: {len(centersPlus):,} cells" )
print( f" 📍 Minus side: {len(centersMinus):,} cells" )