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" )