# app_sonic_ravensgate.py # Configuration TensorFlow AVANT tous les imports pour éviter les erreurs CUDA import os os.environ['CUDA_VISIBLE_DEVICES'] = '-1' # Force CPU pour TensorFlow os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2' # Réduit les logs TensorFlow import streamlit as st import pandas as pd import numpy as np # Configuration Matplotlib pour stabilité et performance import matplotlib matplotlib.use('Agg') # Backend non-interactif pour éviter les reruns import matplotlib.pyplot as plt plt.ioff() # Désactiver le mode interactif from matplotlib.colors import LogNorm from matplotlib.backends.backend_pdf import PdfPages from sklearn.cluster import KMeans from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor from sklearn.linear_model import Ridge from sklearn.preprocessing import StandardScaler from sklearn.model_selection import train_test_split import joblib import pickle import chardet import os import tempfile import io import plotly.graph_objects as go from datetime import datetime import pygimli as pg from pygimli.physics.ert import ERTManager, simulate from matplotlib.colors import ListedColormap, BoundaryNorm, LinearSegmentedColormap from PIL import Image import torch import hashlib import json # Import du module d'authentification # try: # from auth_module import AuthManager, show_auth_ui, show_user_info, require_auth # AUTH_ENABLED = True # except ImportError: # AUTH_ENABLED = False # print("⚠️ Module d'authentification non disponible") AUTH_ENABLED = False # ═══════════════════════════════════════════════════════════════ # GÉNÉRATION DE COUPES GÉOLOGIQUES RÉALISTES AVEC PYGIMLI # ═══════════════════════════════════════════════════════════════ # Configuration des modèles depuis Hugging Face Hub (optimisé pour Spaces) SETRAF_BASE_PATH = os.path.dirname(os.path.abspath(__file__)) # Utiliser des modèles légers compatibles avec HF Spaces MISTRAL_MODEL_PATH = "microsoft/Phi-3-mini-4k-instruct" # Modèle léger 3.8B, rapide et efficace CLIP_MODEL_PATH = "openai/clip-vit-base-patch32" # CLIP standard # ═══════════════════════════════════════════════════════════════ # SYSTÈME RAG (Retrieval-Augmented Generation) POUR GÉOPHYSIQUE ERT # ═══════════════════════════════════════════════════════════════ # Configuration RAG - Chemins locaux dans SETRAF RAG_DOCUMENTS_PATH = os.path.join(SETRAF_BASE_PATH, "rag_documents") VECTOR_DB_PATH = os.path.join(SETRAF_BASE_PATH, "vector_db") ML_MODELS_PATH = os.path.join(SETRAF_BASE_PATH, "ml_models") HF_TOKEN = os.getenv("HF_TOKEN", "") # Use environment variable for security TAVILY_API_KEY = os.getenv("TAVILY_API_KEY", "") # Use environment variable for security class ERTKnowledgeBase: """ Base de connaissances vectorielle spécialisée en géophysique ERT OPTIMISÉE pour un chargement rapide et des performances élevées + SYSTÈME D'AUTO-APPRENTISSAGE ML intégré """ def __init__(self): self.vectorstore = None self.embeddings = None self.documents = [] self.document_metadata = [] # Métadonnées avec hash IDs self.web_search_enabled = True self.initialized = False self.use_lightweight_model = True # Modèle plus rapide # Sous-modèles ML auto-apprenants self.ml_models = { 'resistivity_predictor': None, # Prédiction résistivité apparente 'color_classifier': None, # Classification couleurs 'anomaly_detector': None, # Détection anomalies 'depth_interpolator': None # Interpolation profondeur } self.scaler = None self.training_history = [] # Historique d'entraînement self.models_initialized = False # Dictionnaire des fichiers .dat traités (hash -> métadonnées) self.dat_files_registry = {} # {hash_id: {filename, upload_time, n_samples, ...}} self._load_dat_registry() def initialize_embeddings(self): """Initialise le modèle d'embeddings OPTIMISÉ depuis HF Hub""" try: import torch import os # Désactiver complètement PyTorch meta device os.environ['PYTORCH_CUDA_ALLOC_CONF'] = 'max_split_size_mb:512' from sentence_transformers import SentenceTransformer st.info("🔄 Chargement du modèle d'embeddings depuis HF Hub...") # Utiliser un modèle léger directement depuis HuggingFace Hub model_name = "sentence-transformers/all-MiniLM-L6-v2" # Charger directement depuis HF Hub self.embeddings = SentenceTransformer( model_name, device='cpu' ) self.embeddings.eval() # Mode évaluation # Optimisations pour la vitesse self.embeddings.max_seq_length = 256 # Réduire la longueur max # Test rapide pour vérifier que le modèle fonctionne with torch.no_grad(): _ = self.embeddings.encode(["test"], show_progress_bar=False, convert_to_numpy=True) st.success("✅ Modèle d'embeddings chargé depuis HF Hub !") return True except Exception as e: st.warning(f"⚠️ Impossible de charger les embeddings : {str(e)[:100]}") return False def load_or_create_vectorstore(self): """Charge ou crée la base vectorielle RAPIDEMENT""" try: import faiss import pickle import os # Créer le dossier si nécessaire os.makedirs(VECTOR_DB_PATH, exist_ok=True) db_file = os.path.join(VECTOR_DB_PATH, "ert_knowledge_light.faiss") # Nom plus court docs_file = os.path.join(VECTOR_DB_PATH, "ert_documents_light.pkl") if os.path.exists(db_file) and os.path.exists(docs_file): # Chargement RAPIDE depuis le cache st.info("🔄 Chargement de la base vectorielle...") self.vectorstore = faiss.read_index(db_file) with open(docs_file, 'rb') as f: data = pickle.load(f) # Support des deux formats : dict ou liste directe if isinstance(data, dict): self.documents = data.get('texts', []) else: self.documents = data st.success(f"✅ Base vectorielle chargée : {len(self.documents)} chunks") st.info(f"📊 Total mots: {sum(len(doc.split()) for doc in self.documents):,}") self.initialized = True return True else: # Création optimisée st.info("🔄 Création optimisée de la base vectorielle...") return self.create_vectorstore_optimized() except Exception as e: st.warning(f"⚠️ Erreur base vectorielle : {str(e)}") return False def create_vectorstore_optimized(self): """Crée la base vectorielle de façon OPTIMISÉE""" try: import faiss import pickle import os from langchain_text_splitters import RecursiveCharacterTextSplitter # DOCUMENTS OPTIMISÉS - Plus courts et plus ciblés default_docs = [ { "title": "Résistivité ERT - Échelle rapide", "content": """ ÉCHELLE RAPIDE RÉSISTIVITÉ ERT: 0.01-1 Ω·m : EAU DE MER / MINÉRAUX 1-10 Ω·m : EAU SAUMÂTRE / ARGILES 10-100 Ω·m : EAU DOUCE / SOLS FINS 100-1000 Ω·m : SABLES SATURÉS 1000-10000 Ω·m : ROCHES SÉDIMENTAIRES >10000 Ω·m : SOCLE CRISTALLIN """ }, { "title": "Méthodes ERT essentielles", "content": """ MÉTHODES ERT PRINCIPALES: PSEUDO-SECTIONS: Représentation 2D rapide INVERSION: Reconstruction 3D des valeurs réelles CLASSIFICATION: Regroupement par résistivité """ } ] # Documents PDF si disponibles - TOUS les PDFs pdf_docs = self.extract_text_from_pdfs_optimized() if pdf_docs: default_docs.extend(pdf_docs) # Inclure TOUS les PDFs # SPLITTING OPTIMISÉ - Chunks plus petits texts = [] metadatas = [] text_splitter = RecursiveCharacterTextSplitter( chunk_size=512, # Réduit pour rapidité chunk_overlap=50, # Réduit length_function=len ) for doc in default_docs: chunks = text_splitter.split_text(doc["content"]) for chunk in chunks: if len(chunk.strip()) > 50: # Éviter les chunks vides texts.append(chunk.strip()) metadatas.append({ "title": doc["title"], "source": "ERT Knowledge Base" }) # Embeddings par batch pour rapidité if not self.embeddings: if not self.initialize_embeddings(): return False st.info(f"🔄 Génération rapide des embeddings pour {len(texts)} chunks...") # Traitement par petits batches pour éviter la surcharge mémoire batch_size = 32 embeddings_list = [] for i in range(0, len(texts), batch_size): batch_texts = texts[i:i+batch_size] batch_embeddings = self.embeddings.encode(batch_texts, show_progress_bar=False) embeddings_list.append(batch_embeddings) # Concaténer tous les embeddings embeddings_array = np.vstack(embeddings_list) # Index FAISS optimisé dimension = embeddings_array.shape[1] self.vectorstore = faiss.IndexFlatL2(dimension) self.vectorstore.add(embeddings_array.astype('float32')) # Sauvegarde optimisée db_file = os.path.join(VECTOR_DB_PATH, "ert_knowledge_light.faiss") docs_file = os.path.join(VECTOR_DB_PATH, "ert_documents_light.pkl") faiss.write_index(self.vectorstore, db_file) with open(docs_file, 'wb') as f: pickle.dump({ 'texts': texts, 'metadatas': metadatas }, f) self.documents = texts self.initialized = True st.success(f"✅ Base optimisée créée : {len(texts)} chunks indexés") return True except Exception as e: st.error(f"❌ Erreur création optimisée : {str(e)}") return False def extract_text_from_pdfs_optimized(self): """Extraction PDF complète - TOUS les PDFs et TOUTES les pages""" try: import os from pypdf import PdfReader pdf_docs = [] if os.path.exists(RAG_DOCUMENTS_PATH): pdf_files = [f for f in os.listdir(RAG_DOCUMENTS_PATH) if f.endswith('.pdf')] st.info(f"📄 Traitement de {len(pdf_files)} fichier(s) PDF...") for file in pdf_files: pdf_path = os.path.join(RAG_DOCUMENTS_PATH, file) try: reader = PdfReader(pdf_path) text = "" total_pages = len(reader.pages) # Extraire TOUTES les pages du PDF for page_num in range(total_pages): page = reader.pages[page_num] page_text = page.extract_text() if len(page_text.strip()) > 50: # Pages avec contenu text += page_text + "\n\n" if len(text.strip()) > 100: # Document avec contenu suffisant pdf_docs.append({ "title": f"PDF: {file}", "content": text, "pages": total_pages, "source": file }) st.success(f"✅ {file}: {total_pages} pages extraites") except Exception as e: st.warning(f"⚠️ Erreur PDF {file}: {str(e)[:50]}") continue return pdf_docs except ImportError: st.error("❌ Module pypdf non installé") return [] def search_knowledge_base(self, query, k=5): """Recherche dans la base vectorielle avec plus de résultats""" try: if not self.vectorstore or not self.embeddings or not self.initialized: return [] # Encoder la requête (très rapide) query_embedding = self.embeddings.encode([query], show_progress_bar=False) # Recherche optimisée distances, indices = self.vectorstore.search(query_embedding.astype('float32'), k) results = [] for i, idx in enumerate(indices[0]): if idx < len(self.documents) and distances[0][i] < 1.5: # Seuil de pertinence results.append({ 'content': self.documents[idx][:300], # Contenu tronqué 'distance': distances[0][i], 'relevance_score': max(0, 1.0 - distances[0][i]) # Score normalisé }) return results[:2] # Max 2 résultats pour rapidité except Exception as e: return [] def search_web(self, query, max_results=1): """Recherche web ULTRA-RAPIDE - un seul résultat""" try: if not self.web_search_enabled: return [] import requests # Requête optimisée url = "https://api.tavily.com/search" headers = {"Content-Type": "application/json"} data = { "api_key": TAVILY_API_KEY, "query": f"géophysique ERT {query}", "search_depth": "basic", # Recherche basique plus rapide "max_results": max_results, "include_answer": False # Pas de réponse générée pour rapidité } response = requests.post(url, json=data, headers=headers, timeout=3) # Timeout court if response.status_code == 200: results = response.json() web_results = [] if "results" in results and results["results"]: item = results["results"][0] # Premier résultat seulement web_results.append({ 'title': item.get('title', '')[:50], # Tronqué 'content': item.get('content', '')[:200], # Tronqué 'url': item.get('url', ''), 'source': 'web_tavily' }) return web_results return [] except Exception as e: return [] def initialize_ml_models(self): """Initialise ou charge les modèles ML d'auto-apprentissage""" try: os.makedirs(ML_MODELS_PATH, exist_ok=True) # Charger les modèles existants s'ils existent resistivity_model_path = os.path.join(ML_MODELS_PATH, 'resistivity_predictor.pkl') color_model_path = os.path.join(ML_MODELS_PATH, 'color_classifier.pkl') scaler_path = os.path.join(ML_MODELS_PATH, 'scaler.pkl') history_path = os.path.join(ML_MODELS_PATH, 'training_history.pkl') if os.path.exists(resistivity_model_path): self.ml_models['resistivity_predictor'] = joblib.load(resistivity_model_path) self.ml_models['color_classifier'] = joblib.load(color_model_path) self.scaler = joblib.load(scaler_path) if os.path.exists(history_path): with open(history_path, 'rb') as f: self.training_history = pickle.load(f) self.models_initialized = True return True else: # Initialiser de nouveaux modèles self.ml_models['resistivity_predictor'] = RandomForestRegressor( n_estimators=100, max_depth=10, random_state=42, n_jobs=-1 ) self.ml_models['color_classifier'] = GradientBoostingRegressor( n_estimators=50, max_depth=5, random_state=42 ) self.ml_models['anomaly_detector'] = Ridge(alpha=1.0) self.ml_models['depth_interpolator'] = RandomForestRegressor( n_estimators=50, max_depth=8, random_state=42 ) self.scaler = StandardScaler() return True except Exception as e: st.warning(f"⚠️ Erreur init ML models: {e}") return False def train_on_dat_file(self, df, file_metadata=None): """Entraîne les modèles ML sur un fichier .dat chargé Returns: dict: {'success': bool, 'already_exists': bool, 'hash_id': str, 'message': str} """ try: if df.empty or len(df) < 10: return {'success': False, 'already_exists': False, 'hash_id': None, 'message': 'Données insuffisantes'} # Vérifier si le fichier existe déjà filename = file_metadata.get('filename', 'unknown') if file_metadata else 'unknown' check_result = self.check_dat_exists(df, filename) if check_result['exists']: # Fichier déjà traité, retourner les métadonnées return { 'success': True, 'already_exists': True, 'hash_id': check_result['hash_id'], 'metadata': check_result['metadata'], 'message': 'Fichier déjà stocké dans la base vectorielle' } if not self.models_initialized: self.initialize_ml_models() st.info("🤖 Auto-apprentissage ML en cours...") # Préparer les features features = self._extract_features_from_dat(df) if features is None: return {'success': False, 'already_exists': False, 'hash_id': None, 'message': 'Erreur extraction features'} X = features[['survey_point', 'depth_from', 'depth_to', 'depth_mean']].values y_resistivity = features['data'].values # Normalisation if not hasattr(self.scaler, 'mean_'): X_scaled = self.scaler.fit_transform(X) else: X_scaled = self.scaler.transform(X) # Entraînement incrémental des modèles if len(X_scaled) > 20: # Split pour validation X_train, X_test, y_train, y_test = train_test_split( X_scaled, y_resistivity, test_size=0.2, random_state=42 ) # Entraîner le prédicteur de résistivité self.ml_models['resistivity_predictor'].fit(X_train, y_train) score = self.ml_models['resistivity_predictor'].score(X_test, y_test) # Créer des targets pour les couleurs (basé sur échelle de résistivité) y_color_class = self._resistivity_to_color_class(y_resistivity) self.ml_models['color_classifier'].fit(X_train, y_color_class[:len(X_train)]) # Enregistrer l'historique avec hash_id hash_id = check_result['hash_id'] training_record = { 'hash_id': hash_id, 'timestamp': datetime.now().isoformat(), 'n_samples': len(df), 'n_points': df['survey_point'].nunique() if 'survey_point' in df.columns else 0, 'resistivity_score': float(score), 'file_metadata': file_metadata or {}, 'resistivity_range': [float(df['data'].min()), float(df['data'].max())] } self.training_history.append(training_record) # Enregistrer dans le registre des fichiers .dat self.dat_files_registry[hash_id] = { 'filename': filename, 'upload_time': training_record['timestamp'], 'n_samples': training_record['n_samples'], 'n_points': training_record['n_points'], 'resistivity_range': training_record['resistivity_range'] } self._save_dat_registry() # Sauvegarder les modèles self._save_ml_models() # Ajouter les données à la base vectorielle RAG self._add_dat_to_vectorstore(df, training_record) st.success(f"✅ Modèles ML entraînés ! Score R²: {score:.3f}") return { 'success': True, 'already_exists': False, 'hash_id': hash_id, 'metadata': training_record, 'message': 'Nouveau fichier traité et stocké' } return {'success': False, 'already_exists': False, 'hash_id': None, 'message': 'Échec entraînement'} except Exception as e: st.error(f"❌ Erreur entraînement ML: {e}") return {'success': False, 'already_exists': False, 'hash_id': None, 'message': str(e)} def _extract_features_from_dat(self, df): """Extrait les features pour l'entraînement ML""" try: required_cols = ['survey_point', 'data'] if not all(col in df.columns for col in required_cols): return None features = df.copy() # Créer des features additionnelles if 'depth_from' in df.columns and 'depth_to' in df.columns: features['depth_mean'] = (df['depth_from'] + df['depth_to']) / 2 else: features['depth_from'] = 0 features['depth_to'] = 0 features['depth_mean'] = 0 return features[['survey_point', 'depth_from', 'depth_to', 'depth_mean', 'data']] except Exception as e: return None def _resistivity_to_color_class(self, resistivity_values): """Convertit les valeurs de résistivité en classes de couleurs""" # Échelle de résistivité ERT standard classes = np.zeros_like(resistivity_values) classes[resistivity_values < 1] = 0 # Bleu foncé - Eau de mer classes[(resistivity_values >= 1) & (resistivity_values < 10)] = 1 # Bleu - Argiles classes[(resistivity_values >= 10) & (resistivity_values < 100)] = 2 # Vert - Eau douce classes[(resistivity_values >= 100) & (resistivity_values < 1000)] = 3 # Jaune - Sables classes[(resistivity_values >= 1000) & (resistivity_values < 10000)] = 4 # Orange - Roches classes[resistivity_values >= 10000] = 5 # Rouge - Socle cristallin return classes def _save_ml_models(self): """Sauvegarde tous les modèles ML""" try: joblib.dump(self.ml_models['resistivity_predictor'], os.path.join(ML_MODELS_PATH, 'resistivity_predictor.pkl')) joblib.dump(self.ml_models['color_classifier'], os.path.join(ML_MODELS_PATH, 'color_classifier.pkl')) joblib.dump(self.scaler, os.path.join(ML_MODELS_PATH, 'scaler.pkl')) with open(os.path.join(ML_MODELS_PATH, 'training_history.pkl'), 'wb') as f: pickle.dump(self.training_history, f) except Exception as e: pass def _add_dat_to_vectorstore(self, df, training_record): """Ajoute les informations du fichier .dat à la base vectorielle RAG""" try: if not self.vectorstore or not self.embeddings: return # Créer un résumé textuel du fichier .dat summary_text = f""" DONNÉES ERT - {training_record['timestamp']} Nombre de mesures: {training_record['n_samples']} Points de sondage: {training_record['n_points']} Résistivité: {training_record['resistivity_range'][0]:.2f} - {training_record['resistivity_range'][1]:.2f} Ω·m Interprétation automatique: {self._interpret_resistivity_range(training_record['resistivity_range'])} Statistiques: - Moyenne: {df['data'].mean():.2f} Ω·m - Médiane: {df['data'].median():.2f} Ω·m - Écart-type: {df['data'].std():.2f} Ω·m """ # Encoder et ajouter à FAISS import faiss embedding = self.embeddings.encode([summary_text], show_progress_bar=False) self.vectorstore.add(embedding.astype('float32')) self.documents.append(summary_text) # Sauvegarder la base mise à jour import pickle db_file = os.path.join(VECTOR_DB_PATH, "ert_knowledge_light.faiss") docs_file = os.path.join(VECTOR_DB_PATH, "ert_documents_light.pkl") faiss.write_index(self.vectorstore, db_file) with open(docs_file, 'wb') as f: pickle.dump({ 'texts': self.documents, 'metadatas': [{'source': 'dat_file', 'timestamp': training_record['timestamp']}] * len(self.documents) }, f) except Exception as e: pass def _interpret_resistivity_range(self, res_range): """Interprète automatiquement la plage de résistivité""" min_res, max_res = res_range interpretation = [] if min_res < 10: interpretation.append("Présence probable d'argiles ou matériaux conducteurs") if max_res > 1000: interpretation.append("Présence de formations résistantes (sables, roches)") if max_res > 10000: interpretation.append("Socle cristallin ou roches très résistantes détectés") if 10 <= min_res <= 100 and 100 <= max_res <= 1000: interpretation.append("Zone aquifère potentielle (sables saturés)") return " | ".join(interpretation) if interpretation else "Formation géologique mixte" def _compute_dat_hash(self, df, filename): """Calcule un hash unique pour un fichier .dat basé sur son contenu""" try: # Créer une signature du fichier basée sur les données content_str = f"{filename}_{len(df)}_{df['data'].sum():.6f}_{df['survey_point'].nunique()}" # Ajouter quelques valeurs représentatives if len(df) > 0: content_str += f"_{df['data'].iloc[0]:.6f}_{df['data'].iloc[-1]:.6f}" hash_id = hashlib.sha256(content_str.encode()).hexdigest()[:16] return hash_id except Exception as e: return None def _load_dat_registry(self): """Charge le registre des fichiers .dat traités""" try: registry_file = os.path.join(VECTOR_DB_PATH, "dat_files_registry.json") if os.path.exists(registry_file): with open(registry_file, 'r') as f: self.dat_files_registry = json.load(f) except Exception as e: self.dat_files_registry = {} def _save_dat_registry(self): """Sauvegarde le registre des fichiers .dat traités""" try: os.makedirs(VECTOR_DB_PATH, exist_ok=True) registry_file = os.path.join(VECTOR_DB_PATH, "dat_files_registry.json") with open(registry_file, 'w') as f: json.dump(self.dat_files_registry, f, indent=2) except Exception as e: pass def check_dat_exists(self, df, filename): """Vérifie si un fichier .dat existe déjà dans la base vectorielle Returns: dict: {'exists': bool, 'hash_id': str, 'metadata': dict or None} """ try: hash_id = self._compute_dat_hash(df, filename) if hash_id is None: return {'exists': False, 'hash_id': None, 'metadata': None} if hash_id in self.dat_files_registry: return { 'exists': True, 'hash_id': hash_id, 'metadata': self.dat_files_registry[hash_id] } else: return {'exists': False, 'hash_id': hash_id, 'metadata': None} except Exception as e: return {'exists': False, 'hash_id': None, 'metadata': None} def predict_resistivity(self, survey_point, depth_from, depth_to): """Prédit la résistivité apparente pour un point donné""" try: if not self.models_initialized or self.ml_models['resistivity_predictor'] is None: return None depth_mean = (depth_from + depth_to) / 2 X = np.array([[survey_point, depth_from, depth_to, depth_mean]]) X_scaled = self.scaler.transform(X) predicted_resistivity = self.ml_models['resistivity_predictor'].predict(X_scaled)[0] predicted_color_class = self.ml_models['color_classifier'].predict(X_scaled)[0] # Convertir la classe de couleur en info lisible color_info = self._color_class_to_info(predicted_color_class) return { 'resistivity': float(predicted_resistivity), 'color_class': int(predicted_color_class), 'color_name': color_info['name'], 'color_hex': color_info['hex'], 'geological_interpretation': color_info['interpretation'] } except Exception as e: return None def _color_class_to_info(self, color_class): """Convertit une classe de couleur en informations détaillées""" color_map = { 0: {'name': 'Bleu foncé', 'hex': '#00008B', 'interpretation': 'Eau de mer / Minéraux conducteurs'}, 1: {'name': 'Bleu', 'hex': '#0000FF', 'interpretation': 'Argiles / Eau saumâtre'}, 2: {'name': 'Vert', 'hex': '#00FF00', 'interpretation': 'Eau douce / Sols fins'}, 3: {'name': 'Jaune', 'hex': '#FFFF00', 'interpretation': 'Sables saturés / Zone aquifère'}, 4: {'name': 'Orange', 'hex': '#FFA500', 'interpretation': 'Roches sédimentaires'}, 5: {'name': 'Rouge', 'hex': '#FF0000', 'interpretation': 'Socle cristallin / Roches très résistantes'} } return color_map.get(int(color_class), color_map[2]) def get_ml_enhanced_context(self, query, df=None): """Obtient un contexte enrichi par ML + RAG pour le LLM""" context_parts = [] # 1. Contexte RAG classique rag_context = self.get_enhanced_context(query, use_web=False) if rag_context: context_parts.append("=== CONTEXTE RAG ===") context_parts.append(rag_context) # 2. Historique d'entraînement ML if self.training_history: context_parts.append("\n=== HISTORIQUE ML ===") recent_trainings = self.training_history[-3:] # 3 derniers entraînements for record in recent_trainings: context_parts.append(f"Fichier analysé: {record['n_samples']} mesures, " f"Résistivité: {record['resistivity_range'][0]:.1f}-{record['resistivity_range'][1]:.1f} Ω·m") # 3. Prédictions ML si données disponibles if df is not None and not df.empty and self.models_initialized: context_parts.append("\n=== PRÉDICTIONS ML ===") # Prédire pour quelques points représentatifs sample_points = df.sample(min(3, len(df))) for _, row in sample_points.iterrows(): prediction = self.predict_resistivity( row.get('survey_point', 0), row.get('depth_from', 0), row.get('depth_to', 0) ) if prediction: context_parts.append( f"Point {row.get('survey_point', '?')}: " f"Résistivité prédite={prediction['resistivity']:.2f} Ω·m, " f"Couleur={prediction['color_name']}, " f"Interprétation: {prediction['geological_interpretation']}" ) return "\n".join(context_parts) if context_parts else "" def get_enhanced_context(self, query, use_web=False): """Obtient un contexte enrichi avec PLUS de chunks pour analyses détaillées""" context_parts = [] # Recherche vectorielle prioritaire - AUGMENTÉ à 5 chunks vector_results = self.search_knowledge_base(query, k=5) if vector_results: context_parts.append("=== BASE DE CONNAISSANCES RAG ===") for i, result in enumerate(vector_results, 1): # TOUS les résultats (5) context_parts.append(f"📄 Chunk {i}: {result['content']}") context_parts.append("") # Afficher le nombre de chunks utilisés context_parts.append(f"✅ {len(vector_results)} chunks pertinents trouvés sur {len(self.documents)} indexés") context_parts.append("") # Recherche web comme COMPLÉMENT (pas seulement si pas de résultats) if use_web and self.web_search_enabled: web_results = self.search_web(query, max_results=2) if web_results: context_parts.append("=== RECHERCHE WEB (TAVILY) ===") for i, result in enumerate(web_results, 1): context_parts.append(f"🌐 Source {i}: {result['content'][:500]}...") context_parts.append("") return "\n".join(context_parts) if context_parts else ""# Instance globale de la base de connaissances if 'ert_knowledge_base' not in st.session_state: st.session_state.ert_knowledge_base = ERTKnowledgeBase() def initialize_rag_system(): """Initialise le système RAG de façon OPTIMISÉE""" kb = st.session_state.ert_knowledge_base # Si déjà initialisé, retourner immédiatement if kb.initialized and kb.vectorstore is not None: return True try: # Chargement rapide des embeddings if not kb.embeddings: if not kb.initialize_embeddings(): return False # Chargement ou création rapide de la base if not kb.vectorstore: if not kb.load_or_create_vectorstore(): return False return kb.initialized except Exception as e: st.warning(f"⚠️ Erreur initialisation RAG : {str(e)[:50]}") return False# ═══════════════════════════════════════════════════════════════ # SYSTÈME D'EXPLICATION INTELLIGENTE EN TEMPS RÉEL # ═══════════════════════════════════════════════════════════════ class ExplanationTracker: """ Tracker global pour toutes les explications LLM générées dans l'application Permet de tracer chaque opération et sa compréhension par le LLM """ def __init__(self): self.explanations = [] self.operations_count = 0 def add_explanation(self, operation_type, operation_data, llm_explanation, timestamp=None): """ Ajoute une explication pour une opération Args: operation_type: Type d'opération (ex: "data_loading", "clustering", "visualization") operation_data: Données/métadonnées de l'opération llm_explanation: Texte d'explication généré par le LLM timestamp: Horodatage (auto si None) """ from datetime import datetime if timestamp is None: timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") self.operations_count += 1 self.explanations.append({ 'id': self.operations_count, 'timestamp': timestamp, 'type': operation_type, 'data': operation_data, 'explanation': llm_explanation }) def get_all_explanations(self): """Retourne toutes les explications enregistrées""" return self.explanations def get_summary(self): """Génère un résumé des opérations expliquées""" types = {} for exp in self.explanations: op_type = exp['type'] types[op_type] = types.get(op_type, 0) + 1 return { 'total_operations': self.operations_count, 'operations_by_type': types, 'latest_explanation': self.explanations[-1] if self.explanations else None } def clear(self): """Efface toutes les explications""" self.explanations = [] self.operations_count = 0 # Instance globale du tracker if 'explanation_tracker' not in st.session_state: st.session_state['explanation_tracker'] = ExplanationTracker() def show_explanation_dashboard(): """Affiche le dashboard complet des explications RAG avec analyse des chunks""" st.markdown("### 🧠 Dashboard Explications & Base de Connaissances RAG") # Créer les onglets du dashboard tab_stats, tab_chunks, tab_search, tab_history = st.tabs([ "📊 Statistiques", "📚 Base de Connaissances", "🔍 Tester la Recherche", "📜 Historique Explications" ]) with tab_stats: st.markdown("#### 📊 Vue d'ensemble du système RAG") # Métriques principales col1, col2, col3, col4 = st.columns(4) with col1: if 'ert_knowledge_base' in st.session_state: kb = st.session_state.ert_knowledge_base nb_docs = len(kb.documents) if kb.documents else 0 st.metric("📚 Documents indexés", nb_docs) else: st.metric("📚 Documents indexés", 0) with col2: cache_size = len(st.session_state.get('explanation_cache', {})) st.metric("💾 Explications en cache", cache_size) with col3: tracker = st.session_state.get('explanation_tracker') if tracker: st.metric("🔄 Opérations traitées", tracker.operations_count) else: st.metric("🔄 Opérations traitées", 0) with col4: if 'ert_knowledge_base' in st.session_state: kb = st.session_state.ert_knowledge_base dimension = kb.embeddings.get_sentence_embedding_dimension() if kb.embeddings else 0 st.metric("🎯 Dimension vecteurs", dimension) else: st.metric("🎯 Dimension vecteurs", 0) # Informations détaillées st.markdown("---") st.markdown("#### 🔧 Configuration du système") col_a, col_b = st.columns(2) with col_a: st.info("""**Modèle d'embeddings** - Nom: all-MiniLM-L6-v2 - Type: SentenceTransformer - Dimension: 384 - Device: CPU""") with col_b: if 'ert_knowledge_base' in st.session_state: kb = st.session_state.ert_knowledge_base web_status = "✅ Activée" if kb.web_search_enabled else "❌ Désactivée" init_status = "✅ Initialisé" if kb.initialized else "❌ Non initialisé" st.info(f"""**État du système** - Recherche web: {web_status} - Vectorstore: {init_status} - Base: FAISS IndexFlatL2 - Chunk size: 512 caractères""") with tab_chunks: st.markdown("#### 📚 Contenu de la base de connaissances") if 'ert_knowledge_base' not in st.session_state or not st.session_state.ert_knowledge_base.documents: st.warning("⚠️ Aucun document dans la base. Initialisez le système RAG d'abord.") else: kb = st.session_state.ert_knowledge_base documents = kb.documents # Afficher le nombre de PDFs dans le dossier pdf_count = 0 if os.path.exists(RAG_DOCUMENTS_PATH): pdf_files = [f for f in os.listdir(RAG_DOCUMENTS_PATH) if f.endswith('.pdf')] pdf_count = len(pdf_files) col_info1, col_info2 = st.columns(2) with col_info1: st.success(f"✅ **{len(documents)} chunks** indexés dans la base vectorielle") with col_info2: st.info(f"📁 **{pdf_count} fichier(s) PDF** dans `rag_documents/`") # Liste des PDFs if pdf_count > 0: with st.expander(f"📄 Liste des {pdf_count} PDF(s)", expanded=True): for pdf_file in pdf_files: pdf_path = os.path.join(RAG_DOCUMENTS_PATH, pdf_file) file_size = os.path.getsize(pdf_path) / 1024 # KB st.markdown(f"- 📄 **{pdf_file}** ({file_size:.1f} KB)") # Statistiques sur les chunks st.markdown("##### 📈 Analyse des chunks") col_stat1, col_stat2, col_stat3 = st.columns(3) with col_stat1: avg_length = sum(len(doc) for doc in documents) / len(documents) if documents else 0 st.metric("📏 Longueur moyenne", f"{avg_length:.0f} chars") with col_stat2: min_length = min(len(doc) for doc in documents) if documents else 0 st.metric("⬇️ Plus court", f"{min_length} chars") with col_stat3: max_length = max(len(doc) for doc in documents) if documents else 0 st.metric("⬆️ Plus long", f"{max_length} chars") # Afficher tous les chunks avec numérotation st.markdown("---") st.markdown("##### 📑 Liste complète des chunks") # Filtrage optionnel filter_text = st.text_input("🔍 Filtrer par mot-clé:", "", key="filter_chunks") chunks_to_display = documents if filter_text: chunks_to_display = [doc for doc in documents if filter_text.lower() in doc.lower()] st.info(f"📊 {len(chunks_to_display)} chunk(s) trouvé(s) sur {len(documents)}") # Afficher les chunks for idx, doc in enumerate(chunks_to_display, 1): with st.expander(f"📄 Chunk #{idx} ({len(doc)} chars)", expanded=False): # Afficher le contenu st.text_area( "Contenu:", doc, height=150, key=f"chunk_content_{idx}", disabled=True ) # Statistiques du chunk col_info1, col_info2, col_info3 = st.columns(3) with col_info1: word_count = len(doc.split()) st.caption(f"📝 {word_count} mots") with col_info2: line_count = doc.count('\n') + 1 st.caption(f"📄 {line_count} lignes") with col_info3: st.caption(f"🔢 Chunk ID: {idx-1}") # Bouton d'export st.markdown("---") if st.button("💾 Exporter la base de connaissances (TXT)", key="export_kb"): export_text = "\n\n" + "="*80 + "\n\n".join( [f"CHUNK #{i+1}\n{'-'*80}\n{doc}" for i, doc in enumerate(documents)] ) st.download_button( "📥 Télécharger knowledge_base.txt", export_text, "knowledge_base.txt", "text/plain" ) with tab_search: st.markdown("#### 🔍 Tester la recherche sémantique") if 'ert_knowledge_base' not in st.session_state or not st.session_state.ert_knowledge_base.initialized: st.warning("⚠️ Système RAG non initialisé") else: kb = st.session_state.ert_knowledge_base # Interface de recherche col_query, col_k = st.columns([3, 1]) with col_query: search_query = st.text_input( "💬 Entrez votre question:", placeholder="Ex: Quelle est la résistivité de l'argile ?", key="search_query" ) with col_k: k_results = st.number_input("Nb résultats", 1, 10, 3, key="k_results") if st.button("🔍 Rechercher", key="do_search") and search_query: with st.spinner("🔄 Recherche en cours..."): results = kb.search_knowledge_base(search_query, k=k_results) if results: st.success(f"✅ {len(results)} résultat(s) trouvé(s)") for i, result in enumerate(results, 1): score = result.get('relevance_score', 0) distance = result.get('distance', 0) content = result.get('content', '') # Couleur selon le score if score > 0.7: color = "green" elif score > 0.4: color = "orange" else: color = "red" with st.expander( f"🎯 Résultat #{i} - Score: {score:.3f} - Distance: {distance:.3f}", expanded=(i == 1) ): st.markdown(f"**Pertinence:** :{color}[{'█' * int(score * 20)}] {score*100:.1f}%") st.text_area("Contenu:", content, height=200, key=f"result_{i}") else: st.error("❌ Aucun résultat trouvé") # Exemples de requêtes st.markdown("---") st.markdown("##### 💡 Exemples de questions") examples = [ "Quelle est la résistivité de l'eau douce ?", "Comment fonctionne l'inversion ERT ?", "Qu'est-ce qu'une pseudo-section ?", "Résistivité des argiles", "Méthodes de classification ERT" ] cols = st.columns(len(examples)) for idx, (col, example) in enumerate(zip(cols, examples)): with col: if st.button(f"💬", key=f"ex_{idx}", help=example): st.session_state['search_query'] = example st.rerun() with tab_history: st.markdown("#### 📜 Historique des explications générées") tracker = st.session_state.get('explanation_tracker') if not tracker or not tracker.explanations: st.info("ℹ️ Aucune explication générée pour le moment") else: explanations = tracker.get_all_explanations() st.success(f"✅ {len(explanations)} explication(s) enregistrée(s)") # Filtres col_filter1, col_filter2 = st.columns(2) with col_filter1: filter_type = st.selectbox( "Filtrer par type:", ["Tous"] + list(set(exp['type'] for exp in explanations)), key="filter_type" ) with col_filter2: sort_order = st.selectbox( "Ordre:", ["Plus récent", "Plus ancien"], key="sort_order" ) # Appliquer les filtres filtered = explanations if filter_type != "Tous": filtered = [exp for exp in filtered if exp['type'] == filter_type] if sort_order == "Plus ancien": filtered = reversed(filtered) # Afficher les explications for exp in filtered: exp_id = exp['id'] exp_type = exp['type'] exp_time = exp['timestamp'] exp_text = exp['explanation'] with st.expander(f"🔍 #{exp_id} - {exp_type} - {exp_time}", expanded=False): st.markdown(exp_text) # Métadonnées st.caption(f"Type: {exp_type} | Timestamp: {exp_time}") # Bouton pour effacer l'historique st.markdown("---") if st.button("🗑️ Effacer tout l'historique", key="clear_history"): tracker.clear() st.success("✅ Historique effacé !") st.rerun() def generate_plotly_visualizations(operation_type, operation_data, llm_explanation): """ 🎨 Génère automatiquement des visualisations Plotly interactives basées sur les données d'opération et l'analyse LLM Args: operation_type: Type d'opération (data_loading, geological_analysis, etc.) operation_data: Dict contenant les données numériques llm_explanation: Texte de l'analyse générée par le LLM Returns: List[Tuple[str, go.Figure]]: Liste de (nom_figure, figure_plotly) """ import plotly.graph_objects as go import plotly.express as px import numpy as np import re figures = [] try: if operation_type == "data_loading": # 📊 1. Distribution des résistivités avec échelle de couleurs géologiques if 'data_range' in operation_data: # Extraire min et max de data_range (format: "X - Y Ω·m") range_text = str(operation_data.get('data_range', '0 - 0')) range_match = re.findall(r'[\d.]+', range_text) if len(range_match) >= 2: min_res = float(range_match[0]) max_res = float(range_match[1]) # Échelle de résistivités standardisée resistivity_ranges = [ {'range': (0, 1), 'color': '#00008B', 'label': 'Eau de mer', 'formation': 'Minéraux très conducteurs'}, {'range': (1, 10), 'color': '#0000FF', 'label': 'Argiles', 'formation': 'Argiles saturées/Eau saumâtre'}, {'range': (10, 100), 'color': '#00FF00', 'label': 'Eau douce', 'formation': 'Aquifère argileux'}, {'range': (100, 1000), 'color': '#FFFF00', 'label': 'Sables/Graviers', 'formation': 'Aquifère productif'}, {'range': (1000, 10000), 'color': '#FFA500', 'label': 'Roches fracturées', 'formation': 'Socle altéré/Grès'}, {'range': (10000, 1000000), 'color': '#FF0000', 'label': 'Socle cristallin', 'formation': 'Granite/Gneiss'} ] # Déterminer quelles formations sont présentes present_formations = [] present_labels = [] present_colors = [] present_values = [] for r in resistivity_ranges: if min_res <= r['range'][1] and max_res >= r['range'][0]: overlap_min = max(min_res, r['range'][0]) overlap_max = min(max_res, r['range'][1]) extent = overlap_max - overlap_min present_formations.append(r['formation']) present_labels.append(f"{r['label']}
{overlap_min:.1f}-{overlap_max:.1f} Ω·m") present_colors.append(r['color']) present_values.append(extent) # Créer un graphique en barres horizontales fig1 = go.Figure() fig1.add_trace(go.Bar( y=present_formations, x=present_values, orientation='h', marker=dict( color=present_colors, line=dict(color='black', width=1) ), text=present_labels, textposition='inside', textfont=dict(color='white', size=10, family='Arial Black'), hovertemplate="%{y}
Étendue: %{x:.1f} Ω·m
" )) fig1.update_layout( title={ 'text': "🎨 Distribution des Formations Géologiques par Résistivité", 'x': 0.5, 'xanchor': 'center', 'font': {'size': 16, 'family': 'Arial Black'} }, xaxis_title="Étendue de résistivité (Ω·m)", yaxis_title="Type de formation géologique", height=450, showlegend=False, template="plotly_white", margin=dict(l=20, r=20, t=60, b=60) ) figures.append(("🎨 Distribution résistivités", fig1)) # 🏔️ 2. Modèle 3D conceptuel si plusieurs points de mesure if 'n_survey_points' in operation_data and operation_data.get('n_survey_points', 0) > 2: n_points = min(operation_data.get('n_survey_points', 5), 20) x_coords = np.linspace(0, 100, n_points) # Distance en mètres # Simuler des profondeurs d'interfaces géologiques z_surface = np.zeros(n_points) z_aquifer_top = -(5 + np.sin(x_coords / 15) * 2 + np.random.randn(n_points) * 0.5) z_aquifer_bottom = -(15 + np.sin(x_coords / 20) * 3 + np.random.randn(n_points) * 0.8) z_bedrock = -(30 + np.cos(x_coords / 25) * 5 + np.random.randn(n_points) * 1) fig2 = go.Figure() # Surface du sol fig2.add_trace(go.Scatter3d( x=x_coords, y=np.zeros(n_points), z=z_surface, mode='lines', name='Surface topographique', line=dict(color='#8B4513', width=4), showlegend=True )) # Toit de l'aquifère fig2.add_trace(go.Scatter3d( x=x_coords, y=np.zeros(n_points), z=z_aquifer_top, mode='lines', name='Toit aquifère (estimé)', line=dict(color='#00BFFF', width=3, dash='dash'), showlegend=True )) # Base de l'aquifère fig2.add_trace(go.Scatter3d( x=x_coords, y=np.zeros(n_points), z=z_aquifer_bottom, mode='lines', name='Base aquifère', line=dict(color='#0000FF', width=3), showlegend=True )) # Socle rocheux fig2.add_trace(go.Scatter3d( x=x_coords, y=np.zeros(n_points), z=z_bedrock, mode='lines', name='Socle cristallin', line=dict(color='#696969', width=4), showlegend=True )) # Ajouter des marqueurs aux extrémités for i in [0, n_points-1]: fig2.add_trace(go.Scatter3d( x=[x_coords[i]], y=[0], z=[z_aquifer_top[i]], mode='markers+text', marker=dict(size=8, color='blue'), text=[f'P{i+1}'], textposition='top center', showlegend=False )) fig2.update_layout( title={ 'text': "🏔️ Modèle Géologique 3D Conceptuel", 'x': 0.5, 'xanchor': 'center', 'font': {'size': 16, 'family': 'Arial Black'} }, scene=dict( xaxis_title="Distance (m)", yaxis_title="", zaxis_title="Profondeur (m)", camera=dict( eye=dict(x=1.8, y=1.8, z=0.7), center=dict(x=0, y=0, z=-0.2) ), xaxis=dict(showbackground=True, backgroundcolor='lightgray'), yaxis=dict(showbackground=True, backgroundcolor='lightgray'), zaxis=dict(showbackground=True, backgroundcolor='lightblue') ), height=550, template="plotly_white", margin=dict(l=0, r=0, t=60, b=0) ) figures.append(("🏔️ Modèle 3D", fig2)) # 📈 3. Graphique de profondeur vs résistivité (si données disponibles) if 'rho_min' in operation_data and 'rho_max' in operation_data: # Créer un profil vertical simplifié depths = np.array([0, 5, 10, 15, 20, 30, 50]) # Simuler une variation typique de résistivité avec la profondeur rho_min = float(operation_data.get('rho_min', 10)) rho_max = float(operation_data.get('rho_max', 1000)) # Profil réaliste : haute résistivité en surface (sec), basse en profondeur (aquifère) resistivities = np.array([ rho_max * 0.8, # Surface sèche rho_max * 0.5, # Transition rho_min * 5, # Zone aquifère rho_min * 2, # Aquifère principal rho_min * 3, # Transition vers socle rho_max * 0.7, # Socle altéré rho_max * 1.2 # Socle sain ]) # Attribuer des couleurs selon résistivité colors = [] for r in resistivities: if r < 10: colors.append('#0000FF') elif r < 100: colors.append('#00FF00') elif r < 1000: colors.append('#FFFF00') else: colors.append('#FF0000') fig3 = go.Figure() fig3.add_trace(go.Scatter( x=resistivities, y=-depths, # Négatif pour avoir profondeur vers le bas mode='lines+markers', name='Profil résistivité', line=dict(color='darkblue', width=3), marker=dict( size=12, color=colors, line=dict(color='black', width=2) ), hovertemplate="Profondeur: %{y} m
Résistivité: %{x:.1f} Ω·m
" )) # Ajouter zones aquifères fig3.add_hrect(y0=-15, y1=-10, fillcolor='lightblue', opacity=0.2, annotation_text="Zone aquifère potentielle", annotation_position="left") fig3.update_layout( title={ 'text': "📉 Profil Vertical de Résistivité", 'x': 0.5, 'xanchor': 'center', 'font': {'size': 16, 'family': 'Arial Black'} }, xaxis_title="Résistivité (Ω·m) - échelle log", yaxis_title="Profondeur (m)", xaxis_type="log", height=500, template="plotly_white", hovermode='closest', margin=dict(l=20, r=20, t=60, b=60) ) figures.append(("📉 Profil vertical", fig3)) elif operation_type == "geological_analysis": # 🗺️ Coupe géologique 2D détaillée if 'rho_min' in operation_data and 'rho_max' in operation_data: rho_min = float(operation_data.get('rho_min', 1)) rho_max = float(operation_data.get('rho_max', 10000)) # Créer une grille de résistivités simulée x_profile = np.linspace(0, 200, 50) # Distance en mètres z_profile = np.linspace(0, -50, 30) # Profondeur en mètres X, Z = np.meshgrid(x_profile, z_profile) # Simuler une distribution de résistivités réaliste # Haute résistivité en surface, basse vers 10-20m (aquifère), haute en profondeur (socle) R = np.zeros_like(X) for i in range(len(z_profile)): depth = abs(z_profile[i]) if depth < 5: # Surface R[i, :] = rho_max * (0.6 + 0.3 * np.random.rand(len(x_profile))) elif depth < 20: # Zone aquifère R[i, :] = rho_min * (2 + 8 * np.random.rand(len(x_profile))) else: # Socle R[i, :] = rho_max * (0.5 + 0.5 * np.random.rand(len(x_profile))) fig4 = go.Figure() fig4.add_trace(go.Contour( x=x_profile, z=z_profile, z_values=R, colorscale=[ [0, '#00008B'], # Bleu foncé (très faible) [0.2, '#0000FF'], # Bleu (faible) [0.4, '#00FF00'], # Vert (moyen-faible) [0.6, '#FFFF00'], # Jaune (moyen) [0.8, '#FFA500'], # Orange (élevé) [1, '#FF0000'] # Rouge (très élevé) ], colorbar=dict( title="Résistivité
(Ω·m)", titleside="right", tickmode="array", tickvals=[rho_min, rho_min*10, rho_min*100, rho_max], ticktext=[f"{rho_min:.0f}", f"{rho_min*10:.0f}", f"{rho_min*100:.0f}", f"{rho_max:.0f}"] ), hovertemplate="Distance: %{x} m
Profondeur: %{z} m
Résistivité: %{z_values:.1f} Ω·m
", contours=dict( start=np.log10(rho_min), end=np.log10(rho_max), size=(np.log10(rho_max) - np.log10(rho_min)) / 10 ) )) # Ajouter annotations pour formations fig4.add_annotation(x=100, y=-10, text="AQUIFÈRE", showarrow=True, arrowhead=2, arrowcolor="blue", font=dict(size=14, color="blue", family="Arial Black"), bgcolor="white", opacity=0.8) fig4.add_annotation(x=100, y=-35, text="SOCLE ROCHEUX", showarrow=True, arrowhead=2, arrowcolor="red", font=dict(size=14, color="red", family="Arial Black"), bgcolor="white", opacity=0.8) fig4.update_layout( title={ 'text': "🗺️ Coupe Géo-électrique 2D (Résistivité)", 'x': 0.5, 'xanchor': 'center', 'font': {'size': 16, 'family': 'Arial Black'} }, xaxis_title="Distance le long du profil (m)", yaxis_title="Profondeur (m)", height=500, template="plotly_white", margin=dict(l=20, r=20, t=60, b=60) ) figures.append(("🗺️ Coupe géologique", fig4)) except Exception as e: import streamlit as st st.warning(f"⚠️ Génération partielle des visualisations Plotly : {e}") return figures def explain_operation_with_llm(llm_pipeline, operation_type, operation_data, context="", show_in_ui=True, save_to_tracker=True, use_rag=True): """ Fonction UNIVERSELLE pour expliquer N'IMPORTE QUELLE opération avec le LLM VERSION ENRICHIE AVEC RAG : recherche vectorielle + web pour contexte ultra-précis Args: llm_pipeline: Pipeline Mistral chargé operation_type: Type d'opération à expliquer operation_data: Dictionnaire avec les données de l'opération context: Contexte additionnel show_in_ui: Afficher l'explication dans Streamlit save_to_tracker: Sauvegarder dans le tracker global use_rag: Utiliser le système RAG pour enrichir le contexte Returns: Texte d'explication généré """ if llm_pipeline is None: return "⚠️ LLM non chargé - Explication non disponible" try: # CONSTRUCTION DU CONTEXTE ENRICHIE AVEC RAG + ML enhanced_context = context # Ajouter contexte ML si disponible if 'ml_predictions' in operation_data: enhanced_context += f"\n\n=== PRÉDICTIONS ML ===\n{operation_data['ml_predictions']}" if use_rag and 'ert_knowledge_base' in st.session_state: kb = st.session_state.ert_knowledge_base # Construire une requête intelligente pour la recherche RAPIDE if operation_type == "geological_analysis": rag_query = f"résistivité {operation_data.get('rho_min', 0):.0f}-{operation_data.get('rho_max', 1000):.0f} Ω·m ERT" elif operation_type == "visualization": rag_query = f"coupe géologique résistivité {operation_data.get('plot_type', 'graphique')}" elif operation_type == "clustering": rag_query = f"clustering K-means géophysique" elif operation_type == "data_loading": rag_query = f"chargement données ERT résistivité couleurs" else: rag_query = f"ERT {operation_type}" # TOUJOURS obtenir le contexte enrichi avec recherche web activée rag_context = kb.get_enhanced_context(rag_query, use_web=True) if rag_context: enhanced_context += f"\n\n=== CONTEXTE ENRICHI (RAG + WEB) ===\n{rag_context}" # Prompts spécialisés pour chaque type d'opération - VERSION RAG ENRICHIE prompts = { "data_loading": f"""[INST] Tu es un EXPERT GÉOPHYSICIEN SENIOR spécialisé en tomographie électrique ERT. Tu viens de recevoir un fichier .dat contenant des mesures de résistivité électrique du sous-sol. 📊 DONNÉES CHARGÉES : {operation_data} 🎯 TON RÔLE : Génère une ANALYSE GÉOPHYSIQUE DÉTAILLÉE de 25-35 LIGNES couvrant : 1️⃣ CARACTÉRISATION DES DONNÉES (5 lignes) - Type de mesures et méthodologie ERT utilisée - Nombre de points de mesure et couverture spatiale - Plage de profondeurs explorées - Calcul de la profondeur d'investigation maximale théorique 2️⃣ ANALYSE DES RÉSISTIVITÉS (8-10 lignes) - Calcul des statistiques : moyenne, médiane, écart-type, min/max - Identification des COULEURS DE RÉSISTIVITÉ selon l'échelle ERT : * < 1 Ω·m : BLEU FONCÉ (eau de mer, minéraux conducteurs) * 1-10 Ω·m : BLEU (argiles, eau saumâtre) * 10-100 Ω·m : VERT (eau douce, sols fins, zone aquifère potentielle) * 100-1000 Ω·m : JAUNE (sables saturés, aquifère productif) * 1000-10000 Ω·m : ORANGE (roches sédimentaires, formations peu perméables) * > 10000 Ω·m : ROUGE (socle cristallin, roches ignées) - Distribution des formations géologiques détectées - Anomalies de résistivité remarquables 3️⃣ INTERPRÉTATION HYDROGÉOLOGIQUE (7-10 lignes) - Identification des zones aquifères potentielles - Calcul de la profondeur probable de la nappe phréatique - Estimation de la porosité relative des formations - Analyse de la stratification géologique (couches successives) - Zones de recharge et d'écoulement préférentielles - Présence de structures géologiques (failles, fractures) 4️⃣ RECOMMANDATIONS TECHNIQUES (5-7 lignes) - Points optimaux pour implantation de forages - Profondeurs de forage recommandées avec justification - Risques géologiques identifiés (cavités, argiles gonflantes) - Prédiction du débit potentiel des forages - Qualité probable de l'eau selon les résistivités 5️⃣ PROCHAINES ÉTAPES (2-3 lignes) - Analyses complémentaires suggérées - Besoin d'investigations supplémentaires ⚠️ IMPORTANT : - Utilise des CALCULS PRÉCIS (profondeurs, statistiques, débits) - Cite les VALEURS NUMÉRIQUES exactes du fichier - Référence les COULEURS DE RÉSISTIVITÉ appropriées - Propose des GRAPHIQUES Plotly à générer si pertinent - Structure avec des SECTIONS CLAIRES et numérotées - Minimum 25 lignes, maximum 35 lignes RÉPONDS EN FRANÇAIS avec une analyse PROFESSIONNELLE et DÉTAILLÉE. [/INST]""", "clustering": f"""[INST] Tu es un expert en analyse de données. Explique EN FRANÇAIS cette opération de clustering : OPÉRATION : Clustering K-Means PARAMÈTRES : {operation_data} Explique en 3 phrases : 1. Pourquoi utiliser K-Means sur ces données 2. Signification des {operation_data.get('n_clusters', 'N')} clusters trouvés 3. Interprétation géologique des groupes RÉPONDS UNIQUEMENT EN FRANÇAIS. [/INST]""", "interpolation": f"""[INST] Tu es un expert en géophysique. Explique EN FRANÇAIS cette interpolation : OPÉRATION : Interpolation spatiale DÉTAILS : {operation_data} Explique en 3 phrases : 1. Pourquoi interpoler ces données 2. Méthode utilisée et avantages 3. Précision attendue du résultat RÉPONDS UNIQUEMENT EN FRANÇAIS. [/INST]""", "imputation": f"""[INST] Tu es un expert en traitement de données. Explique EN FRANÇAIS cette imputation : OPÉRATION : Imputation de valeurs manquantes MÉTHODE : {operation_data.get('method', 'Unknown')} STATISTIQUES : {operation_data} Explique en 3 phrases : 1. Pourquoi des valeurs sont manquantes 2. Comment la méthode {operation_data.get('method', '')} les remplace 3. Impact sur la qualité finale RÉPONDS UNIQUEMENT EN FRANÇAIS. [/INST]""", "3d_reconstruction": f"""[INST] Tu es un expert géophysique. Explique EN FRANÇAIS cette reconstruction 3D : OPÉRATION : Reconstruction volumétrique 3D PARAMÈTRES : {operation_data} Explique en 4 phrases : 1. Principe de la reconstruction 3D 2. Rôle des paramètres ({operation_data.get('n_cells', 'N')} cellules, λ={operation_data.get('lambda', 'N/A')}) 3. Informations apportées par le volume 3D 4. Applications pratiques (forages, etc.) RÉPONDS UNIQUEMENT EN FRANÇAIS. [/INST]""", "visualization": f"""[INST] Tu es un expert en visualisation de données. Explique EN FRANÇAIS ce graphique : OPÉRATION : Génération de visualisation TYPE : {operation_data.get('plot_type', 'Unknown')} DONNÉES : {operation_data} Explique en 3 phrases : 1. Ce que montre le graphique 2. Comment l'interpréter (couleurs, axes, etc.) 3. Conclusions principales RÉPONDS UNIQUEMENT EN FRANÇAIS. [/INST]""", "geological_analysis": f"""[INST] Tu es un GÉOLOGUE HYDROGÉOLOGUE EXPERT avec 20+ ans d'expérience en prospection géophysique. 📊 DONNÉES DE RÉSISTIVITÉ À ANALYSER : {operation_data} 🔬 CONTEXTE ENRICHI : {enhanced_context} 🎯 MISSION : Produis une ANALYSE GÉOLOGIQUE EXHAUSTIVE de 30-40 LIGNES : 1️⃣ IDENTIFICATION DES FORMATIONS (10-12 lignes) - Liste complète des lithologies détectées avec résistivités mesurées - Mapping précis couleur → formation géologique - Épaisseur estimée de chaque couche (calculs basés sur profondeurs) - Âge géologique probable des formations - Processus de formation (sédimentation, érosion, tectonique) - Continuité latérale des couches 2️⃣ STRUCTURE GÉOLOGIQUE 3D (8-10 lignes) - Description de la coupe géologique verticale (VOIR LES COUPES GÉOLOGIQUES GÉNÉRÉES) - Pendage et orientation des couches - Détection de discontinuités (failles, fractures, karsts) - Zones de contact entre formations - Calcul du volume des aquifères potentiels - Modèle conceptuel 3D du sous-sol 3️⃣ PROPRIÉTÉS HYDROGÉOLOGIQUES (8-10 lignes) - Porosité estimée (calcul basé sur résistivité) - Perméabilité relative des formations - Transmissivité des aquifères (calcul Darcy) - Coefficient d'emmagasinement - Vitesse d'écoulement de la nappe - Direction préférentielle d'écoulement - Gradient hydraulique 4️⃣ RECOMMANDATIONS DE FORAGE (5-7 lignes) - Coordonnées GPS précises des points de forage optimaux - Profondeur de forage recommandée (avec justification) - Diamètre de forage suggéré - Débit probable (L/s) avec calculs - Coût estimatif des travaux - Planning des opérations 5️⃣ RISQUES ET PRÉCAUTIONS (3-5 lignes) - Risques géotechniques identifiés - Mesures de précaution nécessaires - Tests complémentaires requis 💡 Si des graphiques Plotly seraient utiles, suggère : - Coupe géologique 2D avec couleurs de résistivité - Modèle 3D des aquifères - Courbes de variation de résistivité Utilise des CALCULS PRÉCIS, cite les VALEURS NUMÉRIQUES, référence les COULEURS. Minimum 30 lignes. ANALYSE PROFESSIONNELLE EN FRANÇAIS. [/INST]""", "pdf_export": f"""[INST] Tu es un expert en rapports techniques. Explique EN FRANÇAIS cette génération de PDF : OPÉRATION : Export PDF CONTENU : {operation_data} Explique en 3 phrases : 1. Sections incluses dans le rapport 2. Types de graphiques exportés 3. Usage prévu du document RÉPONDS UNIQUEMENT EN FRANÇAIS. [/INST]""", "error_detection": f"""[INST] Tu es un expert en contrôle qualité. Explique EN FRANÇAIS cette détection d'anomalies : OPÉRATION : Détection d'anomalies RÉSULTATS : {operation_data} Explique en 3 phrases : 1. Types d'anomalies détectées 2. Causes probables 3. Actions correctives recommandées RÉPONDS UNIQUEMENT EN FRANÇAIS. [/INST]""", } # Prompt par défaut si type non reconnu prompt = prompts.get(operation_type, f"""[INST] Tu es un expert technique. Explique EN FRANÇAIS cette opération : TYPE : {operation_type} DONNÉES : {operation_data} CONTEXTE : {context} Fournis une explication claire en 3-4 phrases EN FRANÇAIS. RÉPONDS UNIQUEMENT EN FRANÇAIS. [/INST]""") # Génération avec le LLM - paramètres OPTIMISÉS pour analyses détaillées with st.spinner(f"🧠 Génération d'analyse détaillée pour : {operation_type}..."): result = llm_pipeline( prompt, max_new_tokens=1500, # Augmenté pour analyses longues (30+ lignes) do_sample=True, temperature=0.7, # Augmenté pour créativité top_p=0.92, # Augmenté pour diversité repetition_penalty=1.1, # Pour éviter répétitions pad_token_id=llm_pipeline.tokenizer.eos_token_id ) # Extraire la réponse generated = result[0]['generated_text'] if '[/INST]' in generated: explanation = generated.split('[/INST]')[-1].strip() else: explanation = generated.strip() # 🎨 Générer automatiquement des graphiques Plotly si pertinent plotly_figs = [] if operation_type in ["data_loading", "geological_analysis", "visualization"]: plotly_figs = generate_plotly_visualizations(operation_type, operation_data, explanation) # Sauvegarder dans le tracker if save_to_tracker: st.session_state['explanation_tracker'].add_explanation( operation_type, operation_data, explanation ) # Afficher dans l'UI si demandé if show_in_ui: with st.expander(f"🧠 Analyse Détaillée LLM : {operation_type}", expanded=True): # Afficher l'explication structurée st.markdown(explanation) # Afficher les graphiques Plotly générés if plotly_figs: st.subheader("📊 Visualisations Interactives") for fig_name, fig in plotly_figs: st.plotly_chart(fig, use_container_width=True) if enhanced_context and len(enhanced_context) > 100: st.caption(f"📚 Contexte RAG + ML utilisé : {len(enhanced_context)} caractères de connaissances") return explanation except Exception as e: error_msg = f"⚠️ Erreur génération explication RAG : {str(e)[:100]}" if show_in_ui: st.warning(error_msg) return error_msg def show_explanation_dashboard(): """ Affiche le dashboard complet des explications LLM générées """ st.markdown("---") st.subheader("📊 Dashboard d'Explications LLM") if 'explanation_tracker' not in st.session_state: st.info("Aucune explication générée pour le moment.") return tracker = st.session_state['explanation_tracker'] summary = tracker.get_summary() # Métriques principales col1, col2, col3 = st.columns(3) with col1: st.metric("**Opérations expliquées**", summary['total_operations']) with col2: st.metric("**Types d'opérations**", len(summary['operations_by_type'])) with col3: st.metric("**Cache explications**", len(st.session_state.get('explanation_cache', {}))) # Répartition par type if summary['operations_by_type']: st.markdown("### 📈 Répartition par type d'opération") types_df = pd.DataFrame({ 'Type': list(summary['operations_by_type'].keys()), 'Nombre': list(summary['operations_by_type'].values()) }) st.bar_chart(types_df.set_index('Type')) # Liste détaillée des explications if summary['total_operations'] > 0: st.markdown("### 📝 Historique des explications") # Filtre par type all_types = list(summary['operations_by_type'].keys()) selected_type = st.selectbox( "Filtrer par type d'opération:", ["Tous"] + all_types, key="explanation_filter" ) # Afficher les explications explanations = tracker.get_all_explanations() if selected_type != "Tous": explanations = [e for e in explanations if e['type'] == selected_type] for exp in reversed(explanations[-10:]): # Les 10 dernières with st.expander(f"#{exp['id']} - {exp['type']} ({exp['timestamp']})", expanded=False): st.markdown(f"**Données de l'opération:**") st.json(exp['data']) st.markdown(f"**Explication LLM:**") st.info(exp['explanation']) # Actions sur le dashboard col_clear, col_export = st.columns(2) with col_clear: if st.button("🗑️ Effacer toutes les explications", key="clear_explanations"): tracker.clear() st.session_state['explanation_cache'] = {} st.success("Explications effacées !") st.rerun() with col_export: if st.button("📄 Exporter les explications (JSON)", key="export_explanations"): import json export_data = { 'summary': summary, 'explanations': tracker.get_all_explanations(), 'export_date': datetime.now().strftime("%Y-%m-%d %H:%M:%S") } st.download_button( label="📥 Télécharger JSON", data=json.dumps(export_data, indent=2, ensure_ascii=False), file_name=f"explanations_llm_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json", mime="application/json", key="download_explanations" ) # ═══════════════════════════════════════════════════════════════ # FONCTIONS UTILITAIRES POUR INTÉGRATION LLM # ═══════════════════════════════════════════════════════════════ def explain_with_cache(llm_pipeline, operation_type, operation_data, context=""): """ Version avec cache des explications pour éviter les recalculs Args: llm_pipeline: Pipeline Mistral operation_type: Type d'opération operation_data: Données de l'opération context: Contexte additionnel Returns: Explication (du cache ou générée) """ if 'explanation_cache' not in st.session_state: st.session_state['explanation_cache'] = {} # Créer une clé de cache unique cache_key = f"{operation_type}_{hash(str(operation_data))}_{hash(context)}" # Vérifier le cache if cache_key in st.session_state['explanation_cache']: return st.session_state['explanation_cache'][cache_key] # Générer l'explication explanation = explain_operation_with_llm( llm_pipeline, operation_type, operation_data, context=context, show_in_ui=False, save_to_tracker=True ) # Sauvegarder dans le cache st.session_state['explanation_cache'][cache_key] = explanation return explanation @st.cache_resource def load_clip_model(): """Charge le modèle CLIP quantifier pour analyse d'images avec streaming tokens""" try: from transformers import CLIPProcessor, CLIPModel import torch st.info("🖼️ Chargement de CLIP quantifier pour analyse d'images...") # Charger CLIP avec optimisation pour HF Spaces model = CLIPModel.from_pretrained( CLIP_MODEL_PATH, torch_dtype=torch.float16, # Utiliser float16 pour économie mémoire low_cpu_mem_usage=True ) processor = CLIPProcessor.from_pretrained( CLIP_MODEL_PATH, torch_dtype=torch.float16, low_cpu_mem_usage=True ) # Détection automatique du device avec fallback CPU device = "cuda" if torch.cuda.is_available() else "cpu" model = model.to(device) # Optimisations pour streaming model.eval() torch.set_grad_enabled(False) st.success("✅ CLIP quantifier chargé avec succès ! Streaming tokens activé.") return model, processor, device except Exception as e: st.warning(f"⚠️ CLIP non disponible : {str(e)[:100]}") return None, None, None def create_geological_cross_section_pygimli(rho_data, title="Coupe Géologique", interpretation_text=None, depth_max=20): """ Crée une coupe géologique RÉELLE avec PyGimli basée sur les données de résistivité Args: rho_data: Matrice 2D ou 3D de résistivité (Ω·m) title: Titre de la coupe interpretation_text: Texte d'interprétation du LLM (optionnel) depth_max: Profondeur maximale en mètres Returns: Figure matplotlib avec la coupe géologique """ import matplotlib.pyplot as plt from matplotlib.colors import LogNorm, LinearSegmentedColormap # Si données 3D, prendre une coupe centrale if len(rho_data.shape) == 3: rho_slice = rho_data[:, rho_data.shape[1]//2, :] else: rho_slice = rho_data # Créer la figure fig, ax = plt.subplots(figsize=(16, 8)) # Dimensions n_x, n_z = rho_slice.shape x_coords = np.linspace(0, n_x * 0.5, n_x) # Espacement 0.5m z_coords = np.linspace(0, depth_max, n_z) # Colormap géologique personnalisée colors_geo = [ '#8B4513', # Marron foncé - Argile/Limon (< 50 Ω·m) '#D2691E', # Marron clair - Argile sableuse (50-100 Ω·m) '#F4A460', # Sable - Sable humide (100-300 Ω·m) '#FFD700', # Or - Sable sec (300-500 Ω·m) '#90EE90', # Vert clair - Grès/Roche altérée (500-1000 Ω·m) '#87CEEB', # Bleu ciel - Calcaire (1000-3000 Ω·m) '#4682B4', # Bleu - Roche compacte (3000-5000 Ω·m) '#2F4F4F' # Gris foncé - Substratum rocheux (> 5000 Ω·m) ] cmap_geo = LinearSegmentedColormap.from_list('geological', colors_geo, N=256) # Afficher la coupe avec échelle logarithmique im = ax.imshow(rho_slice.T, extent=[0, x_coords[-1], depth_max, 0], aspect='auto', cmap=cmap_geo, norm=LogNorm(vmin=max(1, rho_slice.min()), vmax=rho_slice.max()), interpolation='bilinear') # Colorbar avec légende géologique cbar = plt.colorbar(im, ax=ax, label='Résistivité (Ω·m)', pad=0.02) # Annotations géologiques ax.set_xlabel('Distance horizontale (m)', fontsize=12, fontweight='bold') ax.set_ylabel('Profondeur (m)', fontsize=12, fontweight='bold') ax.set_title(title, fontsize=14, fontweight='bold', pad=20) # Grille pour lecture ax.grid(True, alpha=0.3, linestyle='--', linewidth=0.5) # Ajouter interprétation du LLM si disponible if interpretation_text: ax.text(0.02, 0.98, f"💡 Interprétation LLM:\n{interpretation_text[:200]}...", transform=ax.transAxes, fontsize=9, verticalalignment='top', bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.8)) # Légende des formations BASÉE SUR LES VRAIES VALEURS rho_min_val = rho_slice.min() rho_max_val = rho_slice.max() rho_mean_val = rho_slice.mean() # Générer la légende dynamiquement basée sur les vraies valeurs legend_lines = ["LÉGENDE (valeurs mesurées):"] # Déterminer quelles couches sont présentes dans les données if rho_min_val < 50: legend_lines.append(f"🟤 Argile/Limon: {max(rho_min_val, 1):.1f}-50 Ω·m") if rho_min_val < 100 and rho_max_val > 50: legend_lines.append(f"🟠 Argile sableuse: 50-100 Ω·m") if rho_min_val < 300 and rho_max_val > 100: legend_lines.append(f"🟡 Sable humide: 100-300 Ω·m") if rho_min_val < 1000 and rho_max_val > 500: legend_lines.append(f"🟢 Grès/Roche altérée: 500-1000 Ω·m") if rho_min_val < 3000 and rho_max_val > 1000: legend_lines.append(f"🔵 Calcaire: 1000-3000 Ω·m") if rho_max_val > 3000: legend_lines.append(f"⚫ Substratum rocheux: >{min(3000, rho_max_val):.0f} Ω·m") legend_lines.append(f"\nPlage totale: {rho_min_val:.1f}-{rho_max_val:.1f} Ω·m") legend_lines.append(f"Résistivité moyenne: {rho_mean_val:.1f} Ω·m") legend_text = "\n".join(legend_lines) ax.text(1.15, 0.5, legend_text, transform=ax.transAxes, fontsize=9, verticalalignment='center', bbox=dict(boxstyle='round', facecolor='lightblue', alpha=0.7)) plt.tight_layout() return fig @st.cache_resource def load_mistral_llm(use_cpu=True, quantize=True): """ Charge le modèle Phi-3-mini LLM ULTRA-OPTIMISÉ avec gestion mémoire stricte EMPÊCHE LE CRASH STREAMLIT (Exit Code 137 = Out of Memory) Args: use_cpu: Utiliser CPU quantize: Activer la quantization 4-bit (économie RAM maximale) Returns: Pipeline optimisé pour vitesse + économie mémoire """ try: from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline, BitsAndBytesConfig import torch import gc st.info("🤖 Chargement du modèle Phi-3-mini quantifier...") # NETTOYER LA MÉMOIRE AVANT CHARGEMENT gc.collect() if torch.cuda.is_available(): torch.cuda.empty_cache() # Charger le tokenizer depuis Hugging Face Hub tokenizer = AutoTokenizer.from_pretrained( MISTRAL_MODEL_PATH, trust_remote_code=True, use_fast=True ) if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token # Optimisations CPU STRICTES pour Phi-3 sur HF Spaces torch.set_num_threads(1) # 1 thread pour économie maximale sur HF Spaces torch.set_grad_enabled(False) torch.backends.cudnn.benchmark = False # Désactiver pour économie mémoire torch.backends.cudnn.enabled = False # Désactiver cuDNN pour compatibilité st.info("📦 Chargement du modèle Phi-3-mini avec quantization 4-bit...") # Configuration QUANTIZATION ADAPTATIVE pour HF Spaces if quantize: try: # Essayer BitsAndBytes 4-bit (peut ne pas être disponible sur HF Spaces) quantization_config = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_compute_dtype=torch.float16, bnb_4bit_use_double_quant=True, bnb_4bit_quant_type="nf4" ) st.info("🔄 Utilisation de la quantization 4-bit NF4 pour Phi-3-mini") quantize_mode = "4bit" except (ImportError, Exception) as e: st.warning(f"⚠️ BitsAndBytes non disponible sur HF Spaces, utilisation d'optimisations alternatives") # Fallback: Optimisations CPU avancées sans quantization quantization_config = None quantize_mode = "cpu_optimized" else: quantization_config = None quantize_mode = "standard" # Charger le modèle Phi-3-mini OPTIMISÉ avec configuration adaptative try: if quantize_mode == "4bit": # Mode quantization 4-bit model = AutoModelForCausalLM.from_pretrained( MISTRAL_MODEL_PATH, quantization_config=quantization_config, torch_dtype=torch.float16, trust_remote_code=True, low_cpu_mem_usage=True, device_map="auto" if torch.cuda.is_available() else None, attn_implementation="eager", use_cache=False # Désactiver cache KV pour éviter DynamicCache error ) else: # Mode CPU optimisé pour HF Spaces (sans BitsAndBytes) st.info("🚀 Mode CPU optimisé activé pour HF Spaces") model = AutoModelForCausalLM.from_pretrained( MISTRAL_MODEL_PATH, torch_dtype=torch.float32, # float32 pour stabilité CPU trust_remote_code=True, low_cpu_mem_usage=True, attn_implementation="eager", use_cache=False, # Désactiver cache KV pour éviter DynamicCache error load_in_8bit=False, # Pas de quantization 8-bit non plus device_map=None # Pas de device_map pour CPU simple ) except Exception as e: st.warning(f"⚠️ Chargement optimisé échoué, mode minimal : {str(e)[:100]}") # Fallback minimal model = AutoModelForCausalLM.from_pretrained( MISTRAL_MODEL_PATH, torch_dtype=torch.float32, trust_remote_code=True, low_cpu_mem_usage=True, attn_implementation="eager", use_cache=False # Désactiver cache en fallback ) model.eval() # Nettoyer à nouveau gc.collect() # Pipeline OPTIMISÉ pour Phi-3 avec streaming llm_pipeline = pipeline( "text-generation", model=model, tokenizer=tokenizer, framework="pt", batch_size=1, use_fast=True, return_full_text=False # Optimisation pour streaming ) st.success(f"✅ Phi-3-mini chargé avec succès ! Mode: {quantize_mode} - Génération optimisée activée.") return llm_pipeline except Exception as e: st.error(f"❌ Erreur chargement Phi-3-mini : {str(e)[:200]}") st.warning("💡 Phi-3-mini non disponible. L'application fonctionnera sans analyses IA avancées.") return None def load_mistral_llm_basic(use_cpu=True): """Version basique sans quantization en fallback""" try: from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline import torch tokenizer = AutoTokenizer.from_pretrained( MISTRAL_MODEL_PATH, trust_remote_code=True ) device = "cpu" if use_cpu else ("cuda" if torch.cuda.is_available() else "cpu") model = AutoModelForCausalLM.from_pretrained( MISTRAL_MODEL_PATH, torch_dtype=torch.float32, trust_remote_code=True, low_cpu_mem_usage=True ).to(device) llm_pipeline = pipeline( "text-generation", model=model, tokenizer=tokenizer, max_new_tokens=512 ) st.success("✅ LLM Mistral chargé (mode standard) !") return llm_pipeline except Exception as e: st.error(f"❌ Erreur critique : {e}") return None return None def enhanced_chat_with_rag(user_question, llm_pipeline, knowledge_base, current_data_context=None): """ Chat amélioré avec RAG et données .dat intégrées + streaming fluide """ try: # 1. RÉCUPÉRATION RAG - Documents pertinents rag_context = "" if knowledge_base and knowledge_base.initialized: rag_results = knowledge_base.search_knowledge_base(user_question, k=3) if rag_results: rag_context = "\n\n📚 CONTEXTE RAG PERTINENT:\n" + "\n".join([ f"- {result['content']} (Pertinence: {result['relevance_score']:.2f})" for result in rag_results ]) # 2. CONTEXTE DONNÉES .DAT actuelles data_context = "" if current_data_context: ctx = current_data_context data_context = f""" 📊 DONNÉES ACTUELLES: - Fichier: {ctx.get('filename', 'N/A')} - Mesures: {ctx.get('n_measures', 'N/A')} points - Résistivité: {ctx.get('resistivity_range', 'N/A')} Ω·m - Moyenne: {ctx.get('mean', 'N/A')} Ω·m, Médiane: {ctx.get('median', 'N/A')} Ω·m - Profondeur: {ctx.get('depth_range', 'N/A')} m""" # 3. PROMPT OPTIMISÉ avec contexte complet prompt = f"""[INST] Tu es un EXPERT GÉOPHYSICIEN spécialisé en tomographie électrique (ERT). Utilise ces informations pour répondre précisément : {data_context} {rag_context} QUESTION: {user_question} RÉPONDS en français, utilise les données fournies, sois précis et concret. [/INST]""" # 4. STREAMING FLUIDE avec indicateur de progression return generate_text_with_streaming(llm_pipeline, prompt, max_new_tokens=400) except Exception as e: return f"Erreur chat RAG: {str(e)[:100]}" def execute_data_tool(tool_command, df): """ Exécute un outil de manipulation des données selon la commande donnée """ try: if tool_command.startswith("filtrer_resistivite"): # Extraire les paramètres min et max import re min_match = re.search(r'min=(\d+)', tool_command) max_match = re.search(r'max=(\d+)', tool_command) if min_match and max_match: min_val = float(min_match.group(1)) max_val = float(max_match.group(1)) # Filtrer les données filtered_df = df[(df['data'] >= min_val) & (df['data'] <= max_val)] return f"✅ Filtrage appliqué: {len(filtered_df)} mesures sur {len(df)} (résistivité {min_val}-{max_val} Ω·m)" elif tool_command.startswith("calculer_stats"): # Extraire la colonne import re col_match = re.search(r"colonne='(\w+)'", tool_command) if col_match: col = col_match.group(1) if col in df.columns: stats = df[col].describe() return f"📊 Statistiques pour {col}:\n{stats.to_string()}" elif tool_command.startswith("analyser_anomalies"): # Extraire le seuil import re seuil_match = re.search(r'seuil=([\d.]+)', tool_command) if seuil_match: seuil = float(seuil_match.group(1)) mean_val = df['data'].mean() std_val = df['data'].std() # Détecter anomalies (valeurs > seuil * écart-type) anomalies = df[abs(df['data'] - mean_val) > (seuil * std_val)] return f"🔍 Anomalies détectées: {len(anomalies)} mesures (seuil {seuil}σ, μ={mean_val:.1f}, σ={std_val:.1f})" return "❌ Commande outil non reconnue" except Exception as e: return f"❌ Erreur exécution outil: {str(e)[:100]}" def analyze_data_with_mistral(llm_pipeline, geophysical_data, progress_callback=None): if llm_pipeline is None: return None, None, None try: if progress_callback: progress_callback("📋 Préparation du contexte OPTIMISÉ (données réduites)...", 0.1) # CHUNK 1 : Résumé statistique seulement (pas toutes les valeurs) n_spectra = geophysical_data.get('n_spectra', 0) rho_min = geophysical_data.get('rho_min', 0) rho_max = geophysical_data.get('rho_max', 0) rho_mean = geophysical_data.get('rho_mean', 0) rho_std = geophysical_data.get('rho_std', 0) # Réduire les grands nombres pour économiser tokens n_spectra_display = f"{n_spectra/1000:.1f}K" if n_spectra > 1000 else str(n_spectra) # CHUNK 2 : Classification géologique basique if rho_mean < 100: geo_type = "argiles/marnes saturées" elif rho_mean < 300: geo_type = "sols mixtes argilo-sableux" elif rho_mean < 600: geo_type = "sables/graviers semi-saturés" else: geo_type = "roches consolidées/substratum" # CHUNK 3 : Contexte ULTRA-CONCIS pour génération RAPIDE context = f"""[INST] Géophysicien ERT. Analyse EXPRESS en 150 mots max: DATA: {n_spectra_display} mesures, ρ={rho_min:.0f}-{rho_max:.0f} Ω·m (moy:{rho_mean:.0f}), {geo_type}, {geophysical_data.get('n_trajectories', 0)} structures RÉPONDS EN 3 SECTIONS COURTES: 1. GÉOLOGIE (2 phrases): Nature sous-sol? 2. ACTIONS (2 points): Que faire? 3. IMAGE (1 phrase): Description coupe géologique Sois BREF et PRÉCIS. [/INST]""" if progress_callback: progress_callback("🧠 Modèle chargé, préparation génération...", 0.3) # OPTIMISATION : Limiter strictement les tokens if progress_callback: progress_callback("💭 Génération en cours (peut prendre 30-60s)...", 0.4) # Générer avec paramètres CPU ultra-optimisés import torch import time from concurrent.futures import ThreadPoolExecutor, TimeoutError start_time = time.time() if progress_callback: progress_callback("🔄 Génération RAPIDE démarrée (15-30s attendus)...", 0.5) # Fonction wrapper pour l'inference avec timeout def run_inference(): with torch.inference_mode(): # Mode inference pour réduire mémoire return llm_pipeline( context, max_new_tokens=128, # RÉDUIT À 128 pour génération RAPIDE (15-30s au lieu de 60s) do_sample=True, temperature=0.7, top_p=0.85, # Réduit pour génération plus déterministe num_return_sequences=1, pad_token_id=llm_pipeline.tokenizer.eos_token_id, repetition_penalty=1.15 # Évite les répétitions pour rester concis ) # Exécuter avec timeout de 45 secondes (génération rapide) try: with ThreadPoolExecutor(max_workers=1) as executor: future = executor.submit(run_inference) try: response = future.result(timeout=45.0) # Timeout de 45 secondes elapsed_time = time.time() - start_time if progress_callback: progress_callback(f"✅ Génération terminée en {elapsed_time:.1f}s", 0.7) except TimeoutError: if progress_callback: progress_callback("⏱️ Timeout - utilisation du fallback", 0.7) # Générer une réponse de fallback fallback_interp = f"Analyse géologique : Résistivité {rho_min:.0f}-{rho_max:.0f} Ω·m, type probable {geo_type}" fallback_reco = "Recommandations : Effectuer des mesures complémentaires" fallback_prompt = f"Coupe géologique {geo_type}, résistivité {rho_min:.0f}-{rho_max:.0f} Ω·m" return fallback_interp, fallback_reco, fallback_prompt except Exception as gen_error: elapsed_time = time.time() - start_time if progress_callback: progress_callback(f"⚠️ Erreur génération, utilisation du fallback", 0.7) # Retourner un fallback au lieu de raise fallback_interp = f"Analyse géologique simplifiée : Détection de {n_spectra_display} mesures avec résistivité moyenne de {rho_mean:.0f} Ω·m" fallback_reco = f"Zones d'intérêt : {geophysical_data.get('n_trajectories', 0)} structures géologiques détectées" fallback_prompt = f"Coupe géologique {geo_type}, résistivité {rho_min:.0f}-{rho_max:.0f} Ω·m" return fallback_interp, fallback_reco, fallback_prompt if progress_callback: progress_callback("📝 Extraction interprétation...", 0.8) if response and len(response) > 0: generated_text = response[0]['generated_text'] # Extraire la réponse (après [/INST]) if '[/INST]' in generated_text: generated_text = generated_text.split('[/INST]')[-1].strip() if progress_callback: progress_callback("🎯 Parsing recommandations...", 0.9) # Parser OPTIMISÉ avec fallbacks interpretation = "" recommendations = "" image_prompt = "" lines = generated_text.split('\n') current_section = None for line in lines: line_upper = line.upper() if 'GÉOLOGIE' in line_upper or 'GEOLOGIE' in line_upper or '1.' in line: current_section = 'interp' elif 'ACTIONS' in line_upper or 'RECOMMANDATION' in line_upper or '2.' in line: current_section = 'reco' elif 'PROMPT' in line_upper or '3.' in line: current_section = 'prompt' elif line.strip() and current_section: if current_section == 'interp': interpretation += line.strip() + " " elif current_section == 'reco': recommendations += line.strip() + " " elif current_section == 'prompt': image_prompt += line.strip() + " " # Fallbacks si parsing échoue if not interpretation: interpretation = generated_text[:300] if not image_prompt: image_prompt = f"Coupe géologique {geo_type}, résistivité {rho_min:.0f}-{rho_max:.0f} Ω·m, {geophysical_data.get('n_trajectories', 0)} structures détectées" if progress_callback: progress_callback("✅ Analyse terminée !", 1.0) return interpretation.strip(), recommendations.strip(), image_prompt.strip() return None, None, None except Exception as e: if progress_callback: progress_callback(f"❌ Erreur: {str(e)[:50]}", 1.0) st.warning(f"⚠️ Erreur LLM : {str(e)[:100]}") # Fallback : génération basique sans LLM fallback_prompt = f"Geological cross-section, {geophysical_data.get('n_cells', 'unknown')} cells, resistivity {geophysical_data.get('rho_min', 10):.0f}-{geophysical_data.get('rho_max', 1000):.0f} Ω·m" return "Analyse non disponible", "Voir données brutes", fallback_prompt @st.cache_resource def load_image_generation_pipeline(model_name="Stable Diffusion XL", use_cpu=False): """ Charge le pipeline de génération d'images avec cache Args: model_name: Nom du modèle à charger use_cpu: Utiliser CPU au lieu de GPU Returns: Pipeline de génération configuré """ try: from diffusers import StableDiffusionXLPipeline, DiffusionPipeline # NOTE: Cette fonction est obsolète - remplacée par PyGimli st.warning("⚠️ Cette fonction de génération IA est obsolète. Utilisez les coupes PyGimli à la place.") return None # Configuration du device if use_cpu or not torch.cuda.is_available(): device = "cpu" torch_dtype = torch.float32 st.info("🖥️ Utilisation du CPU pour la génération d'images") else: device = "cuda" torch_dtype = torch.float16 st.success("🚀 Utilisation du GPU pour la génération d'images") # Charger le pipeline if "XL" in model_name or "SDXL" in model_name: pipe = StableDiffusionXLPipeline.from_pretrained( model_id, torch_dtype=torch_dtype, cache_dir="/home/belikan/.cache/huggingface/hub" ) else: pipe = DiffusionPipeline.from_pretrained( model_id, torch_dtype=torch_dtype, cache_dir="/home/belikan/.cache/huggingface/hub" ) pipe = pipe.to(device) # Optimisations pour performance if device == "cuda": pipe.enable_attention_slicing() pipe.enable_vae_slicing() return pipe except Exception as e: st.warning(f"⚠️ Impossible de charger le modèle {model_name}: {str(e)}") return None def generate_dynamic_legend_and_explanation(llm_pipeline, df, rho_min, rho_max, section_type="general"): """ Génère dynamiquement une légende ET une explication basée sur les VRAIES données en utilisant le LLM Mistral Args: llm_pipeline: Pipeline Mistral chargé df: DataFrame contenant les données réelles rho_min: Résistivité minimale mesurée rho_max: Résistivité maximale mesurée section_type: Type de section ("general", "seawater", "saline", "freshwater", "pure") Returns: Tuple (legend_text, explanation_text) """ if llm_pipeline is None: # Fallback basique legend = f"Résistivité : {rho_min:.1f} - {rho_max:.1f} Ω·m" explanation = f"Analyse de {len(df)} mesures de résistivité." return legend, explanation try: # Statistiques réelles des données rho_mean = df['data'].mean() rho_std = df['data'].std() rho_median = df['data'].median() n_points = len(df) # Profondeur moyenne et étendue depth_min = df['depth'].abs().min() depth_max = df['depth'].abs().max() depth_mean = df['depth'].abs().mean() # Contexte spécifique au type de section section_contexts = { "seawater": "zone d'eau de mer (0.1-1 Ω·m)", "saline": "nappe phréatique salée (1-10 Ω·m)", "freshwater": "aquifère d'eau douce (10-100 Ω·m)", "pure": "eau très pure/roche sèche (>100 Ω·m)", "general": "coupe géologique complète" } context_desc = section_contexts.get(section_type, "données géophysiques") # Prompt optimisé pour le LLM prompt = f"""[INST] Tu es un expert géophysique francophone. Analyse de {context_desc}. DONNÉES RÉELLES MESURÉES: - {n_points} points de mesure - Résistivité: min={rho_min:.2f}, max={rho_max:.2f}, moy={rho_mean:.2f}, méd={rho_median:.2f}, σ={rho_std:.2f} Ω·m - Profondeur: {depth_min:.1f} à {depth_max:.1f}m (moy={depth_mean:.1f}m) Fournis 2 parties STRUCTURÉES EN FRANÇAIS: 1. LÉGENDE (5-6 lignes): Échelle de couleurs avec VRAIES plages observées et leur signification géologique 2. INTERPRÉTATION (5-7 phrases complètes): Analyse géologique détaillée basée sur CES données spécifiques IMPORTANT: Termine toujours tes phrases complètement. RÉPONDS UNIQUEMENT EN FRANÇAIS. [/INST]""" # Génération avec le LLM - augmentation de max_new_tokens pour réponses complètes result = llm_pipeline(prompt, max_new_tokens=600, do_sample=True, temperature=0.7, top_p=0.9) generated = result[0]['generated_text'] # Parser la réponse legend_text = "" explanation_text = "" lines = generated.split('\n') current_part = None for line in lines: line_upper = line.upper() if 'LÉGENDE' in line_upper or 'LEGENDE' in line_upper or '1.' in line: current_part = 'legend' elif 'INTERPRÉTATION' in line_upper or 'INTERPRETATION' in line_upper or '2.' in line: current_part = 'explanation' elif line.strip() and current_part: if current_part == 'legend': legend_text += line.strip() + "\n" elif current_part == 'explanation': explanation_text += line.strip() + " " # Fallback si parsing échoue if not legend_text: legend_text = f"""Résistivité mesurée: {rho_min:.1f} - {rho_max:.1f} Ω·m Moyenne: {rho_mean:.1f} Ω·m | Médiane: {rho_median:.1f} Ω·m {n_points} points | Profondeur: {depth_min:.1f}-{depth_max:.1f}m""" if not explanation_text: explanation_text = f"Les mesures montrent une résistivité variant de {rho_min:.1f} à {rho_max:.1f} Ω·m sur {n_points} points entre {depth_min:.1f} et {depth_max:.1f}m de profondeur." return legend_text.strip(), explanation_text.strip() except Exception as e: st.warning(f"⚠️ Erreur génération dynamique: {str(e)[:100]}") # Fallback basique legend = f"""Résistivité: {rho_min:.1f} - {rho_max:.1f} Ω·m Moyenne: {df['data'].mean():.1f} Ω·m {len(df)} mesures | Prof: {df['depth'].abs().min():.1f}-{df['depth'].abs().max():.1f}m""" explanation = f"Analyse de {len(df)} mesures avec résistivité moyenne de {df['data'].mean():.1f} Ω·m." return legend, explanation def generate_text_with_streaming(llm_pipeline, prompt, max_new_tokens=400, placeholder=None): """ Génération de texte avec STREAMING TOKEN PAR TOKEN ultra-fluide Affiche chaque token au fur et à mesure pour une expérience utilisateur optimale """ try: from transformers import TextIteratorStreamer from threading import Thread import time # Extraire le modèle et tokenizer model = llm_pipeline.model tokenizer = llm_pipeline.tokenizer # Préparer les inputs inputs = tokenizer(prompt, return_tensors="pt").to(model.device) # Créer le streamer optimisé streamer = TextIteratorStreamer( tokenizer, skip_prompt=True, skip_special_tokens=True, timeout=10.0 # Timeout pour éviter blocage ) # Paramètres optimisés pour streaming fluide generation_kwargs = dict( **inputs, max_new_tokens=max_new_tokens, do_sample=True, temperature=0.6, # Légèrement réduit pour cohérence top_p=0.9, # Bon équilibre qualité/vitesse top_k=40, # Limite raisonnable repetition_penalty=1.05, # Anti-répétition léger streamer=streamer, pad_token_id=tokenizer.eos_token_id, eos_token_id=tokenizer.eos_token_id, use_cache=False, # Désactiver cache KV pour éviter DynamicCache error num_beams=1 ) # Lancer la génération dans un thread séparé thread = Thread(target=model.generate, kwargs=generation_kwargs, daemon=True) thread.start() # STREAMING FLUIDE - Affichage token par token generated_text = "" token_count = 0 if placeholder: # Mode avec placeholder - streaming visible for new_text in streamer: if new_text: # Éviter les tokens vides generated_text += new_text token_count += 1 # Mise à jour fluide du placeholder avec curseur placeholder.markdown(generated_text + "▌") # Petit délai pour lisibilité time.sleep(0.02) # Retirer le curseur final placeholder.markdown(generated_text) else: # Mode sans placeholder - accumulation simple for new_text in streamer: if new_text: generated_text += new_text token_count += 1 # Nettoyer la mémoire if torch.cuda.is_available(): torch.cuda.empty_cache() return generated_text.strip() except Exception as e: error_msg = f"Erreur streaming: {str(e)[:100]}" if placeholder: placeholder.error(error_msg) return error_msg def analyze_image_with_clip_and_llm(fig, llm_pipeline, clip_model=None, clip_processor=None, device="cpu", context="", use_cache=True): """ Analyse une image matplotlib avec CLIP + LLM pour explication intelligente Args: fig: Figure matplotlib llm_pipeline: Pipeline Mistral clip_model: Modèle CLIP (optionnel) clip_processor: Processor CLIP (optionnel) device: Device (cpu/cuda) context: Contexte additionnel use_cache: Utiliser le cache des explications Returns: Explication textuelle détaillée """ try: from PIL import Image import io import hashlib # Vérifier le cache d'abord (basé sur le contexte) if use_cache and 'explanation_cache' in st.session_state: cache_key = hashlib.md5(context.encode()).hexdigest() if cache_key in st.session_state.explanation_cache: return st.session_state.explanation_cache[cache_key] + " ♻️" # Convertir la figure matplotlib en image PIL (résolution réduite pour vitesse) buf = io.BytesIO() fig.savefig(buf, format='png', dpi=72, bbox_inches='tight') # DPI réduit de 100 à 72 buf.seek(0) image = Image.open(buf).convert('RGB') # Analyser avec CLIP SEULEMENT si explicitement fourni clip_description = "" if clip_model is not None and clip_processor is not None: # Descriptions candidates pour CLIP candidates = [ "geological cross-section with resistivity data", "scientific data visualization with color scale", "matrix heatmap showing missing data", "3D geological reconstruction", "statistical distribution plot", "spatial map with geological features" ] inputs = clip_processor(text=candidates, images=image, return_tensors="pt", padding=True).to(device) outputs = clip_model(**inputs) # Calculer les similarités logits_per_image = outputs.logits_per_image probs = logits_per_image.softmax(dim=1) # Meilleure correspondance best_idx = probs.argmax().item() best_score = probs[0, best_idx].item() clip_description = f"\nCLIP identifie: '{candidates[best_idx]}' (confiance: {best_score:.1%})" # Générer explication avec le LLM (RAPIDE: tokens réduits) prompt = f"""[INST] Tu es un expert en visualisation de données géophysiques. Analyse EN FRANÇAIS: CONTEXTE: {context}{clip_description} Explication CONCISE (3-4 phrases): 1. Ce que montrent les données 2. Signification des couleurs/échelles 3. Interprétation géologique RÉPONDS UNIQUEMENT EN FRANÇAIS. [/INST]""" if llm_pipeline: explanation = generate_text_with_streaming(llm_pipeline, prompt, max_new_tokens=250) # Réduit de 400 à 250 if '[/INST]' in explanation: explanation = explanation.split('[/INST]')[-1].strip() # Stocker dans le cache if use_cache and 'explanation_cache' in st.session_state: cache_key = hashlib.md5(context.encode()).hexdigest() st.session_state.explanation_cache[cache_key] = explanation return explanation else: return clip_description or "Image de visualisation géophysique" except Exception as e: return f"⚠️ Analyse non disponible: {str(e)[:100]}" def generate_text_with_streaming(llm_pipeline, prompt, max_new_tokens=512, placeholder=None): """ Génère du texte SANS streaming complexe (mode simple et rapide) Args: llm_pipeline: Pipeline Mistral chargé prompt: Texte du prompt max_new_tokens: Nombre max de tokens placeholder: Streamlit placeholder pour affichage dynamique Returns: Texte généré complet """ try: if placeholder: with placeholder.container(): st.info("🧠 Génération en cours...") # Génération directe sans streaming (plus fiable) result = llm_pipeline( prompt, max_new_tokens=max_new_tokens, temperature=0.7, do_sample=True, top_p=0.95, top_k=50, repetition_penalty=1.1, return_full_text=False ) generated_text = result[0].get('generated_text', '') if result and len(result) > 0 else "" if placeholder: placeholder.empty() return generated_text except Exception as e: if placeholder: placeholder.error(f"❌ Erreur: {str(e)[:100]}") return "" def generate_graph_explanation_with_llm(llm_pipeline, graph_type, data_stats, context="", use_streaming=True): """ Génère une explication DYNAMIQUE pour N'IMPORTE QUEL graphique avec le LLM Args: llm_pipeline: Pipeline Mistral chargé graph_type: Type de graphique ("forward_modeling", "kernel_matrix", "reconstruction_3d", etc.) data_stats: Dictionnaire avec statistiques du graphique context: Contexte additionnel Returns: Texte d'explication généré par le LLM """ if llm_pipeline is None: return "⚠️ LLM non chargé. Cliquez sur 'Charger le LLM Mistral' dans la sidebar pour activer les explications intelligentes." try: # Construire le prompt selon le type de graphique - TOUS EN FRANÇAIS prompts = { "forward_modeling": f"""[INST] Tu es un expert géophysique francophone. Explique ce graphique de modélisation forward en 4 parties COURTES EN FRANÇAIS: DONNÉES DU GRAPHIQUE: {data_stats} Structure: 1. MATRICE A (kernel): Rôle physique et signification des couleurs 2. MESURES SYNTHÉTIQUES: Courbes bleue et rouge 3. DISTRIBUTION: Comparaison mesures propres vs bruitées 4. MESURES MASQUÉES: Points bleus vs rouges RÉPONDS UNIQUEMENT EN FRANÇAIS. Concis, technique, basé sur les VRAIES valeurs affichées. [/INST]""", "kernel_matrix": f"""[INST] Tu es un expert géophysique francophone. Explique cette matrice kernel (matrice A) en géophysique ERT EN FRANÇAIS: STATISTIQUES: {data_stats} Fournis une explication technique courte (3-4 phrases) EN FRANÇAIS sur: - Ce que représente physiquement cette matrice - Signification des couleurs/valeurs - Impact sur les mesures électriques RÉPONDS UNIQUEMENT EN FRANÇAIS. Technique et précis. [/INST]""", "reconstruction_3d": f"""[INST] Tu es un expert géophysique francophone. Explique cette reconstruction 3D par régularisation Tikhonov EN FRANÇAIS: PARAMÈTRES: {data_stats} Fournis une explication courte (3-4 phrases) EN FRANÇAIS: - Principe de la reconstruction - Rôle du paramètre λ (lambda) - Interprétation des résultats RÉPONDS UNIQUEMENT EN FRANÇAIS. Clair et technique. [/INST]""", "spectral_analysis": f"""[INST] Tu es un expert géophysique francophone. Explique cette visualisation spectrale en 2 parties EN FRANÇAIS: STATISTIQUES RÉELLES: {data_stats} Fournis EN FRANÇAIS: 1. DISTRIBUTION (graphique gauche): Signification des pics et échelle log 2. CARTE SPATIALE (graphique droite): Interprétation des couleurs chaudes/froides RÉPONDS UNIQUEMENT EN FRANÇAIS. Basé sur les VRAIES valeurs mesurées. Concis et technique. [/INST]""", "pseudo_section": f"""[INST] Tu es un expert géophysique francophone. Analyse cette pseudo-section de résistivité EN FRANÇAIS: DONNÉES MESURÉES: {data_stats} Fournis une interprétation géologique COMPLÈTE (15-20 lignes) EN FRANÇAIS: 1. IDENTIFICATION DES FORMATIONS (5-6 lignes): - Types de formations détectées selon les plages de résistivité mesurées - Couleurs observées et leur signification géologique - Épaisseur estimée de chaque formation 2. DISTRIBUTION SPATIALE (4-5 lignes): - Répartition verticale (stratification) - Variations horizontales (zones d'intérêt) - Continuité des couches géologiques 3. IMPLICATIONS HYDROGÉOLOGIQUES (4-5 lignes): - Zones aquifères identifiées - Profondeur probable de la nappe - Qualité prévue de l'eau - Débit potentiel estimé 4. RECOMMANDATIONS (2-3 lignes): - Points optimaux pour forages - Profondeurs de forage recommandées RÉPONDS UNIQUEMENT EN FRANÇAIS. Basé sur les VRAIES valeurs du fichier .dat. Détaillé et structuré. [/INST]""", "3d_interactive_visualization": f"""[INST] Tu es un expert géophysique francophone. Explique cette visualisation 3D interactive EN FRANÇAIS: CARACTÉRISTIQUES: {data_stats} Fournis en 3 parties courtes EN FRANÇAIS: 1. INTERACTIONS: Comment manipuler la vue 3D 2. ISOSURFACES: Signification des surfaces colorées 3. INTERPRÉTATION: Zones intéressantes géologiquement RÉPONDS UNIQUEMENT EN FRANÇAIS. Technique et pratique. [/INST]""", "3d_dual_volume": f"""[INST] Tu es un expert géophysique francophone. Analyse cette visualisation bi-volume 3D EN FRANÇAIS: STATISTIQUES RÉELLES: {data_stats} Fournis une interprétation hydrogéologique (4-5 phrases) EN FRANÇAIS: - Signification volume BLEU (basse résistivité) et implications - Signification volume ROUGE (haute résistivité) et implications - Recommandations pour ciblage de forages RÉPONDS UNIQUEMENT EN FRANÇAIS. Basé sur les VRAIES statistiques mesurées. [/INST]""", } prompt = prompts.get(graph_type, f"""[INST] Tu es un expert géophysique francophone. Analyse ce graphique EN FRANÇAIS: TYPE: {graph_type} DONNÉES: {data_stats} CONTEXTE: {context} Fournis une explication COMPLÈTE (10-15 lignes) EN FRANÇAIS: 1. Description technique du graphique 2. Interprétation des données affichées 3. Signification géophysique 4. Recommandations pratiques RÉPONDS UNIQUEMENT EN FRANÇAIS. Détaillé, structuré, basé sur les VRAIES données. [/INST]""") # Utiliser le streaming optimisé if use_streaming: placeholder = st.empty() with placeholder.container(): st.info("🧠 Génération de l'analyse détaillée...") generated = generate_text_with_streaming( llm_pipeline, prompt, max_new_tokens=600, # Plus de tokens pour analyses détaillées placeholder=placeholder ) else: # Mode sans streaming mais optimisé result = llm_pipeline( prompt, max_new_tokens=600, do_sample=True, temperature=0.7, top_p=0.95, repetition_penalty=1.1 ) generated = result[0]['generated_text'] # Extraire seulement la réponse (après [/INST]) if '[/INST]' in generated: explanation = generated.split('[/INST]')[-1].strip() else: explanation = generated.strip() return explanation except Exception as e: return f"⚠️ Erreur génération: {str(e)[:100]}" def analyze_resistivity_patterns(rho_slice): """ Génère dynamiquement une légende ET une explication basée sur les VRAIES données en utilisant le LLM Mistral Args: llm_pipeline: Pipeline Mistral chargé df: DataFrame contenant les données réelles rho_min: Résistivité minimale mesurée rho_max: Résistivité maximale mesurée section_type: Type de section ("general", "seawater", "saline", "freshwater", "pure") Returns: Tuple (legend_text, explanation_text) """ if llm_pipeline is None: # Fallback basique legend = f"Résistivité : {rho_min:.1f} - {rho_max:.1f} Ω·m" explanation = f"Analyse de {len(df)} mesures de résistivité." return legend, explanation try: # Statistiques réelles des données rho_mean = df['data'].mean() rho_std = df['data'].std() rho_median = df['data'].median() n_points = len(df) # Profondeur moyenne et étendue depth_min = df['depth'].abs().min() depth_max = df['depth'].abs().max() depth_mean = df['depth'].abs().mean() # Contexte spécifique au type de section section_contexts = { "seawater": "zone d'eau de mer (0.1-1 Ω·m)", "saline": "nappe phréatique salée (1-10 Ω·m)", "freshwater": "aquifère d'eau douce (10-100 Ω·m)", "pure": "eau très pure/roche sèche (>100 Ω·m)", "general": "coupe géologique complète" } context_desc = section_contexts.get(section_type, "données géophysiques") # Prompt optimisé pour le LLM prompt = f"""[INST] Tu es un expert géophysique francophone. Analyse de {context_desc}. DONNÉES RÉELLES MESURÉES: - {n_points} points de mesure - Résistivité: min={rho_min:.2f}, max={rho_max:.2f}, moy={rho_mean:.2f}, méd={rho_median:.2f}, σ={rho_std:.2f} Ω·m - Profondeur: {depth_min:.1f} à {depth_max:.1f}m (moy={depth_mean:.1f}m) Fournis 2 parties STRUCTURÉES EN FRANÇAIS: 1. LÉGENDE (5-6 lignes): Échelle de couleurs avec VRAIES plages observées et leur signification géologique 2. INTERPRÉTATION (5-7 phrases complètes): Analyse géologique détaillée basée sur CES données spécifiques IMPORTANT: Termine toujours tes phrases complètement. RÉPONDS UNIQUEMENT EN FRANÇAIS. [/INST]""" # Génération avec le LLM - augmentation de max_new_tokens pour réponses complètes result = llm_pipeline(prompt, max_new_tokens=600, do_sample=True, temperature=0.7, top_p=0.9) generated = result[0]['generated_text'] # Parser la réponse legend_text = "" explanation_text = "" lines = generated.split('\n') current_part = None for line in lines: line_upper = line.upper() if 'LÉGENDE' in line_upper or 'LEGENDE' in line_upper or '1.' in line: current_part = 'legend' elif 'INTERPRÉTATION' in line_upper or 'INTERPRETATION' in line_upper or '2.' in line: current_part = 'explanation' elif line.strip() and current_part: if current_part == 'legend': legend_text += line.strip() + "\n" elif current_part == 'explanation': explanation_text += line.strip() + " " # Fallback si parsing échoue if not legend_text: legend_text = f"""Résistivité mesurée: {rho_min:.1f} - {rho_max:.1f} Ω·m Moyenne: {rho_mean:.1f} Ω·m | Médiane: {rho_median:.1f} Ω·m {n_points} points | Profondeur: {depth_min:.1f}-{depth_max:.1f}m""" if not explanation_text: explanation_text = f"Les mesures montrent une résistivité variant de {rho_min:.1f} à {rho_max:.1f} Ω·m sur {n_points} points entre {depth_min:.1f} et {depth_max:.1f}m de profondeur." return legend_text.strip(), explanation_text.strip() except Exception as e: st.warning(f"⚠️ Erreur génération dynamique: {str(e)[:100]}") # Fallback basique legend = f"""Résistivité: {rho_min:.1f} - {rho_max:.1f} Ω·m Moyenne: {df['data'].mean():.1f} Ω·m {len(df)} mesures | Prof: {df['depth'].abs().min():.1f}-{df['depth'].abs().max():.1f}m""" explanation = f"Analyse de {len(df)} mesures avec résistivité moyenne de {df['data'].mean():.1f} Ω·m." return legend, explanation def analyze_resistivity_patterns(rho_slice): """ Analyse détaillée des patterns de résistivité pour génération d'images Args: rho_slice: Coupe 2D de résistivité Returns: Dictionnaire d'analyse """ analysis = { 'rho_min': float(np.min(rho_slice)), 'rho_max': float(np.max(rho_slice)), 'rho_mean': float(np.mean(rho_slice)), 'rho_std': float(np.std(rho_slice)) } # Classification de la formation dominante mean_rho = analysis['rho_mean'] if mean_rho < 10: analysis['dominant_formation'] = "argile conductrice ou eau salée" analysis['color_palette'] = "tons sombres bruns et gris" analysis['texture_description'] = "texture argileuse fine et compacte" elif mean_rho < 100: analysis['dominant_formation'] = "aquifère sableux ou limon" analysis['color_palette'] = "tons beige et ocre" analysis['texture_description'] = "texture granulaire sableuse" elif mean_rho < 1000: analysis['dominant_formation'] = "roche fracturée ou grès" analysis['color_palette'] = "tons gris et beige clair" analysis['texture_description'] = "texture rocheuse fracturée" else: analysis['dominant_formation'] = "roche cristalline massive" analysis['color_palette'] = "tons gris foncé et noir" analysis['texture_description'] = "texture cristalline compacte" # Détection de couches grad_vertical = np.gradient(rho_slice, axis=0) layering_strength = np.std(grad_vertical) analysis['layering_score'] = float(layering_strength) analysis['has_clear_layers'] = layering_strength > np.std(rho_slice) * 0.5 # Estimation du contenu en eau low_resistivity_ratio = np.sum(rho_slice < 100) / rho_slice.size if low_resistivity_ratio > 0.6: analysis['water_content_description'] = "forte présence d'eau, zones saturées" elif low_resistivity_ratio > 0.3: analysis['water_content_description'] = "humidité modérée, aquifère potentiel" else: analysis['water_content_description'] = "faible humidité, sols secs" return analysis def create_geological_prompt(analysis, style="Réaliste scientifique", depth_info=""): """ Crée un prompt intelligent pour la génération d'images basé sur l'analyse géophysique Args: analysis: Dictionnaire d'analyse de résistivité style: Style artistique souhaité depth_info: Information sur la profondeur Returns: Prompt optimisé pour la génération """ base_prompts = { "Réaliste scientifique": f""" Professional geological cross-section illustration, {analysis['dominant_formation']}, subsurface layers with resistivity from {analysis['rho_min']:.0f} to {analysis['rho_max']:.0f} ohm-meter, {analysis['water_content_description']}, geological strata, sedimentary layers, {analysis['texture_description']}, natural earth tones, scientific accuracy, detailed stratigraphy, {depth_info}, high resolution, realistic lighting """, "Art géologique": f""" Artistic geological formation painting, {analysis['dominant_formation']}, beautiful {analysis['color_palette']}, flowing sedimentary layers, mineral deposits visible, dramatic natural lighting, geological art, {analysis['texture_description']}, artistic interpretation, natural earth colors, {depth_info}, aesthetic composition """, "Coupes techniques": f""" Technical geological section diagram, {analysis['dominant_formation']}, engineering quality, precise layers, {analysis['texture_description']}, technical illustration style, grid overlay, measurement annotations, professional documentation, {depth_info}, clear delineation """, "3D réaliste": f""" Photorealistic geological outcrop, {analysis['dominant_formation']}, 3D rendered, {analysis['texture_description']}, realistic rock textures, natural outdoor lighting, detailed mineralogy, professional photography style, {analysis['color_palette']}, high quality rendering, {depth_info} """ } prompt = base_prompts.get(style, base_prompts["Réaliste scientifique"]) # Ajouter des informations sur les couches si détectées if analysis.get('has_clear_layers', False): prompt += ", clear horizontal stratification, distinct geological layers" # Prompt négatif pour éviter les artefacts negative_prompt = """ blurry, low quality, distorted, cartoon, anime, unrealistic colors, artificial, modern objects, text, watermark, signature, people, animals """ return prompt.strip(), negative_prompt.strip() def generate_realistic_geological_image(rho_slice, model_name="Stable Diffusion XL", style="Réaliste scientifique", depth_info="", guidance_scale=7.5, num_inference_steps=30, use_cpu=False, llm_enhanced_prompt=None): """ Génère une image réaliste du sous-sol basée sur les données de résistivité Args: rho_slice: Coupe 2D de résistivité model_name: Nom du modèle de génération style: Style artistique depth_info: Information de profondeur guidance_scale: Force de guidance du prompt num_inference_steps: Nombre d'étapes de diffusion use_cpu: Forcer l'utilisation du CPU llm_enhanced_prompt: Prompt optimisé par Mistral LLM (optionnel) Returns: Image PIL générée, prompt utilisé """ try: # Créer le prompt if llm_enhanced_prompt: # Utiliser le prompt optimisé par le LLM st.info("🤖 Utilisation du prompt optimisé par Mistral LLM") prompt = f"{llm_enhanced_prompt}. Style: {style}. Technical details: {depth_info}" negative_prompt = "blurry, low quality, pixelated, distorted, unrealistic colors" else: # Analyser les données géophysiques et créer un prompt standard analysis = analyze_resistivity_patterns(rho_slice) prompt, negative_prompt = create_geological_prompt(analysis, style, depth_info) # Charger le pipeline pipe = load_image_generation_pipeline(model_name, use_cpu) if pipe is None: st.error("❌ Pipeline de génération non disponible") return None, prompt # Générer l'image with st.spinner(f"🎨 Génération en cours avec {model_name}..."): if "XL" in model_name: result = pipe( prompt=prompt, negative_prompt=negative_prompt, guidance_scale=guidance_scale, num_inference_steps=num_inference_steps, height=1024, width=1024 ) else: result = pipe( prompt=prompt, negative_prompt=negative_prompt, guidance_scale=guidance_scale, num_inference_steps=num_inference_steps, height=512, width=512 ) generated_image = result.images[0] return generated_image, prompt except Exception as e: st.error(f"❌ Erreur lors de la génération : {str(e)}") return None, "" def create_side_by_side_comparison(rho_slice, generated_image, title="Comparaison"): """ Crée une visualisation côte à côte des données géophysiques et de l'image générée Args: rho_slice: Données de résistivité generated_image: Image générée par IA title: Titre de la figure Returns: Figure matplotlib """ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 6)) # Données géophysiques brutes im1 = ax1.imshow(rho_slice, cmap='viridis', origin='upper', aspect='auto') ax1.set_title('Données Géophysiques (Résistivité)') ax1.set_xlabel('Position Horizontale') ax1.set_ylabel('Profondeur') plt.colorbar(im1, ax=ax1, label='ρ (Ω·m)') # Image générée ax2.imshow(generated_image) ax2.set_title('Visualisation Réaliste (IA Générative)') ax2.axis('off') plt.suptitle(title, fontsize=14, fontweight='bold') plt.tight_layout() return fig # ═══════════════════════════════════════════════════════════════ # COLORMAP PERSONNALISÉE POUR LES TYPES D'EAU (Résistivité) # ═══════════════════════════════════════════════════════════════ def create_water_resistivity_colormap(): """ Crée une colormap personnalisée basée sur les valeurs typiques pour l'eau Tableau de référence: - Eau de mer : 0.1 - 1 Ω·m → Rouge vif / Orange - Eau salée (nappe) : 1 - 10 Ω·m → Jaune / Orange - Eau douce : 10 - 100 Ω·m → Vert / Bleu clair - Eau très pure : > 100 Ω·m → Bleu foncé """ # Définir les couleurs selon le tableau (format RGB normalisé 0-1) colors = [ (0.80, 0.00, 0.00), # 0.1 Ω·m - Rouge foncé (eau de mer très conductrice) (1.00, 0.30, 0.00), # 0.5 Ω·m - Rouge-Orange (eau de mer) (1.00, 0.65, 0.00), # 1 Ω·m - Orange (transition mer/salée) (1.00, 1.00, 0.00), # 5 Ω·m - Jaune (eau salée nappe) (1.00, 0.85, 0.40), # 10 Ω·m - Jaune clair (transition salée/douce) (0.50, 1.00, 0.50), # 30 Ω·m - Vert clair (eau douce) (0.40, 0.80, 1.00), # 60 Ω·m - Bleu clair (eau douce peu minéralisée) (0.20, 0.60, 1.00), # 100 Ω·m - Bleu (transition douce/pure) (0.00, 0.00, 0.80), # 200 Ω·m - Bleu foncé (eau très pure) ] # Positions logarithmiques correspondantes positions = [0.0, 0.15, 0.25, 0.40, 0.50, 0.65, 0.75, 0.85, 1.0] # Créer la colormap cmap = LinearSegmentedColormap.from_list('water_resistivity', list(zip(positions, colors)), N=256) return cmap def get_water_type_color(resistivity): """ Retourne la couleur hexadécimale selon le type d'eau basé sur la résistivité Args: resistivity: Valeur de résistivité en Ω·m Returns: Tuple (couleur_hex, type_eau, description) """ if resistivity < 0.1: return '#CC0000', 'Eau hypersalée', 'Eau de mer très conductrice' elif resistivity <= 1: return '#FF4500', 'Eau de mer', 'Rouge vif / Orange (0.1 - 1 Ω·m)' elif resistivity <= 10: return '#FFD700', 'Eau salée (nappe)', 'Jaune / Orange (1 - 10 Ω·m)' elif resistivity <= 100: return '#7FFF7F', 'Eau douce', 'Vert / Bleu clair (10 - 100 Ω·m)' else: return '#0066CC', 'Eau très pure', 'Bleu foncé (> 100 Ω·m)' # Créer la colormap globale WATER_CMAP = create_water_resistivity_colormap() def apply_water_colormap_to_plot(ax, X, Z, resistivity_data, title="", xlabel="", ylabel="", vmin=None, vmax=None, show_colorbar=True): """ Applique la colormap d'eau prioritaire à un graphique Args: ax: Axes matplotlib X, Z: Grilles de coordonnées resistivity_data: Données de résistivité title, xlabel, ylabel: Labels du graphique vmin, vmax: Limites de résistivité (auto si None) show_colorbar: Afficher la barre de couleur Returns: pcm: L'objet pcolormesh créé """ if vmin is None: vmin = max(0.1, np.nanmin(resistivity_data)) if vmax is None: vmax = np.nanmax(resistivity_data) # Utiliser TOUJOURS la colormap d'eau avec échelle logarithmique pcm = ax.pcolormesh(X, Z, resistivity_data, cmap=WATER_CMAP, norm=LogNorm(vmin=vmin, vmax=vmax), shading='auto') if show_colorbar: cbar = plt.colorbar(pcm, ax=ax, label='Résistivité (Ω·m)') # Ajouter des annotations de type d'eau sur la colorbar cbar.ax.axhline(1, color='white', linewidth=1.5, linestyle='--', alpha=0.7) cbar.ax.axhline(10, color='white', linewidth=1.5, linestyle='--', alpha=0.7) cbar.ax.axhline(100, color='white', linewidth=1.5, linestyle='--', alpha=0.7) # Ajouter des labels de type d'eau cbar.ax.text(1.5, 0.5, 'Mer', fontsize=8, color='white', fontweight='bold', transform=cbar.ax.transAxes, ha='left', va='center') cbar.ax.text(1.5, 5, 'Salée', fontsize=8, color='white', fontweight='bold', transform=cbar.ax.transAxes, ha='left', va='center') cbar.ax.text(1.5, 30, 'Douce', fontsize=8, color='white', fontweight='bold', transform=cbar.ax.transAxes, ha='left', va='center') cbar.ax.text(1.5, 200, 'Pure', fontsize=8, color='white', fontweight='bold', transform=cbar.ax.transAxes, ha='left', va='center') ax.set_title(title, fontsize=12, fontweight='bold') ax.set_xlabel(xlabel, fontsize=10) ax.set_ylabel(ylabel, fontsize=10) ax.invert_yaxis() ax.grid(True, alpha=0.3, linestyle='--', linewidth=0.5) return pcm # --- Table de réglage température (Ts) --- temperature_control_table = { 36: {0:31, 5:31, 10:32, 15:33, 20:34, 25:34, 30:35, 35:36, 40:37, 45:37, 50:38, 55:39, 60:40, 65:40, 70:41, 75:42, 80:43, 85:43, 90:44, 95:45}, 38: {0:32, 5:33, 10:34, 15:35, 20:35, 25:36, 30:37, 35:38, 40:39, 45:39, 50:40, 55:41, 60:41, 65:42, 70:43, 75:44, 80:44, 85:45, 90:46, 95:47}, 40: {0:34, 5:35, 10:36, 15:36, 20:37, 25:38, 30:39, 35:39, 40:40, 45:41, 50:42, 55:42, 60:43, 65:44, 70:45, 75:45, 80:46, 85:47, 90:48, 95:48}, 42: {0:36, 5:36, 10:37, 15:38, 20:39, 25:39, 30:40, 35:41, 40:42, 45:42, 50:43, 55:44, 60:45, 65:45, 70:46, 75:47, 80:48, 85:48, 90:49, 95:50}, 44: {0:37, 5:38, 10:39, 15:40, 20:40, 25:41, 30:42, 35:43, 40:43, 45:44, 50:45, 55:46, 60:46, 65:47, 70:48, 75:49, 80:49, 85:50, 90:51, 95:52}, 46: {0:39, 5:40, 10:41, 15:41, 20:42, 25:43, 30:44, 35:44, 40:45, 45:46, 50:47, 55:47, 60:48, 65:49, 70:50, 75:50, 80:51, 85:52, 90:53, 95:53}, 48: {0:41, 5:42, 10:42, 15:43, 20:44, 25:45, 30:45, 35:46, 40:47, 45:48, 50:48, 55:49, 60:50, 65:51, 70:51, 75:52, 80:53, 85:54, 90:54, 95:55}, 50: {0:43, 5:43, 10:44, 15:45, 20:45, 25:46, 30:47, 35:48, 40:49, 45:49, 50:50, 55:51, 60:52, 65:52, 70:53, 75:54, 80:55, 85:55, 90:56, 95:57}, 52: {0:44, 5:45, 10:46, 15:46, 20:47, 25:48, 30:49, 35:49, 40:50, 45:51, 50:52, 55:52, 60:53, 65:54, 70:55, 75:55, 80:56, 85:57, 90:58, 95:58}, 54: {0:46, 5:47, 10:47, 15:48, 20:49, 25:50, 30:50, 35:51, 40:52, 45:53, 50:53, 55:54, 60:55, 65:56, 70:55, 75:57, 80:58, 85:59, 90:59, 95:60}, 56: {0:48, 5:48, 10:49, 15:50, 20:51, 25:51, 30:52, 35:53, 40:54, 45:54, 50:55, 55:56, 60:57, 65:57, 70:58, 75:59, 80:60, 85:60, 90:61, 95:62}, 58: {0:49, 5:50, 10:51, 15:52, 20:52, 25:53, 30:54, 35:55, 40:55, 45:56, 50:57, 55:58, 60:58, 65:59, 70:60, 75:61, 80:61, 85:62, 90:63, 95:64}, 60: {0:51, 5:52, 10:53, 15:53, 20:54, 25:55, 30:56, 35:56, 40:57, 45:58, 50:59, 55:59, 60:60, 65:61, 70:62, 75:62, 80:63, 85:64, 90:65, 95:65}, 62: {0:53, 5:53, 10:54, 15:55, 20:56, 25:56, 30:57, 35:58, 40:59, 45:59, 50:60, 55:61, 60:62, 65:62, 70:63, 75:64, 80:65, 85:65, 90:66, 95:67}, 64: {0:54, 5:55, 10:56, 15:57, 20:57, 25:58, 30:59, 35:60, 40:60, 45:61, 50:62, 55:63, 60:63, 65:64, 70:65, 75:66, 80:66, 85:67, 90:68, 95:69}, 66: {0:56, 5:57, 10:58, 15:58, 20:59, 25:60, 30:61, 35:61, 40:62, 45:63, 50:64, 55:64, 60:65, 65:66, 70:67, 75:67, 80:68, 85:69, 90:70, 95:70}, 68: {0:58, 5:59, 10:59, 15:60, 20:61, 25:62, 30:62, 35:63, 40:64, 45:65, 50:65, 55:66, 60:67, 65:68, 70:68, 75:69, 80:70, 85:71, 90:71, 95:72}, 70: {0:60, 5:60, 10:61, 15:62, 20:63, 25:63, 30:64, 35:65, 40:66, 45:66, 50:67, 55:68, 60:69, 65:69, 70:70, 75:71, 80:72, 85:72, 90:73, 95:74}, 72: {0:61, 5:62, 10:63, 15:63, 20:64, 25:65, 30:66, 35:66, 40:67, 45:68, 50:69, 55:70, 60:71, 65:72, 70:72, 75:73, 80:74, 85:75, 90:75, 95:75}, 74: {0:63, 5:64, 10:64, 15:65, 20:66, 25:67, 30:67, 35:68, 40:69, 45:70, 50:70, 55:71, 60:72, 65:73, 70:73, 75:74, 80:75, 85:76, 90:76, 95:77}, 76: {0:65, 5:65, 10:66, 15:67, 20:68, 25:68, 30:69, 35:70, 40:71, 45:71, 50:72, 55:73, 60:74, 65:74, 70:75, 75:76, 80:77, 85:77, 90:78, 95:79}, 78: {0:66, 5:67, 10:68, 15:69, 20:69, 25:70, 30:71, 35:72, 40:72, 45:73, 50:74, 55:75, 60:75, 65:76, 70:77, 75:78, 80:78, 85:79, 90:80, 95:81}, 80: {0:68, 5:69, 10:70, 15:70, 20:71, 25:72, 30:73, 35:73, 40:74, 45:75, 50:76, 55:76, 60:77, 65:78, 70:79, 75:79, 80:80, 85:81, 90:82, 95:82}, 82: {0:70, 5:70, 10:71, 15:72, 20:73, 25:73, 30:74, 35:75, 40:76, 45:76, 50:77, 55:78, 60:79, 65:79, 70:80, 75:81, 80:82, 85:82, 90:83, 95:84}, 84: {0:71, 5:72, 10:73, 15:74, 20:74, 25:75, 30:76, 35:77, 40:77, 45:78, 50:79, 55:80, 60:80, 65:81, 70:82, 75:83, 80:83, 85:84, 90:85, 95:86}, 86: {0:73, 5:74, 10:75, 15:75, 20:76, 25:77, 30:78, 35:78, 40:79, 45:80, 50:81, 55:81, 60:82, 65:83, 70:84, 75:84, 80:85, 85:86, 90:87, 95:87}, 88: {0:75, 5:76, 10:76, 15:77, 20:78, 25:79, 30:79, 35:80, 40:81, 45:82, 50:82, 55:83, 60:84, 65:85, 70:85, 75:86, 80:87, 85:88, 90:88, 95:89}, 90: {0:77, 5:77, 10:78, 15:79, 20:80, 25:80, 30:81, 35:82, 40:83, 45:83, 50:84, 55:85, 60:86, 65:86, 70:87, 75:88, 80:89, 85:89, 90:90, 95:91} } def get_ts(tw_f: float, tg_f: float) -> int: tw = int(tw_f / 2 + 0.5) * 2 tg = int(tg_f / 5 + 0.5) * 5 tw = max(36, min(90, tw)) tg = max(0, min(95, tg)) return temperature_control_table[tw][tg] # --- Fonction pour générer le tableau HTML d'interprétation avec probabilités --- def get_interpretation_probability_table(): """ Retourne un tableau HTML complet avec interprétations géologiques et probabilités selon les plages de résistivité. """ return """
Couleur Résistivité (Ω·m) Interprétations Possibles Probabilités selon contexte Critères de différenciation
🔵 Bleu foncé 0.1 - 1 • Eau de mer hypersalée
• Argile saturée salée
• Argile marine
80% Eau salée si < 0.5 Ω·m
60% Argile saturée si 0.5-1 Ω·m
20% Minéral conducteur (rare)
• Proximité côte → Eau salée
• En profondeur → Argile
• Faible TDS → Argile saturée
🔵 Bleu 1 - 10 • Argile compacte
• Eau saumâtre
• Limon saturé
70% Argile si > 5 Ω·m
50% Eau saumâtre si 1-3 Ω·m
40% Limon humide
• Texture au forage
• Analyse chimique eau
• Profondeur de la nappe
🟦 Cyan 10 - 50 • Argile peu saturée
• Sable fin saturé
• Eau douce peu minéralisée
60% Sable fin si 20-50 Ω·m
50% Argile si 10-20 Ω·m
30% Eau très douce
• Granulométrie
• Perméabilité
• Minéralisation eau
🟢 Vert 50 - 100 • Sable moyen humide
• Gravier fin saturé
• Aquifère sableux
80% Sable aquifère
40% Gravier fin
20% Calcaire poreux
ZONE CIBLE pour forage
• Bonne perméabilité
• Débit potentiel élevé
🟡 Jaune 100 - 300 • Sable grossier sec
• Gravier moyen
• Calcaire fissuré
75% Gravier si 150-300 Ω·m
60% Sable grossier si 100-150 Ω·m
30% Roche altérée
BON AQUIFÈRE
• Excellente perméabilité
• Recharge rapide
🟠 Orange 300 - 1000 • Gravier sec
• Roche altérée
• Calcaire compact
70% Roche altérée
50% Gravier très sec
25% Calcaire
• Profondeur importante
• Faible saturation
• Contexte géologique
🔴 Rouge > 1000 • Roche sédimentaire dure
• Granite/Basalte
• Socle cristallin
85% Roche consolidée
40% Socle si > 5000 Ω·m
10% Aquifère de socle fracturé
• Forage difficile et coûteux
• Potentiel aquifère si fracturé
• Débit faible à modéré

Légende des probabilités :

Recommandation : Combiner avec des données de forage, analyse d'eau, et profil géologique local pour confirmation.

""" # --- Fonction pour créer un rapport PDF complet --- def create_pdf_report(df, unit, figures_dict): """ Crée un rapport PDF complet avec tous les tableaux et graphiques Args: df: DataFrame avec les données unit: Unité de mesure figures_dict: Dictionnaire contenant toutes les figures matplotlib Returns: Bytes du fichier PDF """ buffer = io.BytesIO() with PdfPages(buffer) as pdf: # Page 1: Page de titre fig_title = plt.figure(figsize=(8.5, 11)) fig_title.text(0.5, 0.7, 'Rapport d\'Analyse ERT', ha='center', va='center', fontsize=24, fontweight='bold') fig_title.text(0.5, 0.6, 'Ravensgate Sonic Water Level Meter', ha='center', va='center', fontsize=16) fig_title.text(0.5, 0.5, f'Date: {datetime.now().strftime("%d/%m/%Y %H:%M")}', ha='center', va='center', fontsize=12) fig_title.text(0.5, 0.4, f'Total mesures: {len(df)}', ha='center', va='center', fontsize=12) fig_title.text(0.5, 0.35, f'Points de sondage: {df["survey_point"].nunique()}', ha='center', va='center', fontsize=12) fig_title.text(0.5, 0.3, f'Unité: {unit}', ha='center', va='center', fontsize=12) plt.axis('off') pdf.savefig(fig_title, bbox_inches='tight') plt.close(fig_title) # Page 2: Statistiques descriptives fig_stats = plt.figure(figsize=(8.5, 11)) ax_stats = fig_stats.add_subplot(111) stats_data = [ ['Total mesures', len(df)], ['Points de sondage', df['survey_point'].nunique()], ['Profondeurs uniques', df['depth'].nunique()], [f'DTW moyen ({unit})', f"{df['data'].mean():.2f}"], [f'DTW min ({unit})', f"{df['data'].min():.2f}"], [f'DTW max ({unit})', f"{df['data'].max():.2f}"], [f'Écart-type ({unit})', f"{df['data'].std():.2f}"], ] table_stats = ax_stats.table(cellText=stats_data, colLabels=['Statistique', 'Valeur'], cellLoc='left', loc='center', colWidths=[0.6, 0.4]) table_stats.auto_set_font_size(False) table_stats.set_fontsize(10) table_stats.scale(1, 2) ax_stats.axis('off') ax_stats.set_title('Statistiques descriptives', fontsize=16, fontweight='bold', pad=20) pdf.savefig(fig_stats, bbox_inches='tight') plt.close(fig_stats) # Page 3+: Statistiques par profondeur depth_stats = df.groupby('depth')['data'].agg(['mean', 'min', 'max', 'std']).round(2) fig_depth = plt.figure(figsize=(8.5, 11)) ax_depth = fig_depth.add_subplot(111) depth_data = [[f"{idx:.1f}", f"{row['mean']:.2f}", f"{row['min']:.2f}", f"{row['max']:.2f}", f"{row['std']:.2f}"] for idx, row in depth_stats.iterrows()] table_depth = ax_depth.table(cellText=depth_data, colLabels=['Profondeur', 'Moyenne DTW', 'Min DTW', 'Max DTW', 'Écart-type'], cellLoc='center', loc='center', colWidths=[0.2, 0.2, 0.2, 0.2, 0.2]) table_depth.auto_set_font_size(False) table_depth.set_fontsize(9) table_depth.scale(1, 1.5) ax_depth.axis('off') ax_depth.set_title(f'Statistiques par profondeur ({unit})', fontsize=16, fontweight='bold', pad=20) pdf.savefig(fig_depth, bbox_inches='tight') plt.close(fig_depth) # Ajouter toutes les figures fournies for fig_name, fig in figures_dict.items(): if fig is not None: try: # Vérifier si la figure est encore valide if not plt.fignum_exists(fig.number): continue # Figure fermée, passer à la suivante pdf.savefig(fig, bbox_inches='tight') except Exception as e: # Créer une page d'erreur pour cette figure fig_err = plt.figure(figsize=(8.5, 11)) fig_err.text(0.5, 0.6, f'ERREUR Figure: {fig_name}', ha='center', va='center', fontsize=16, color='red', fontweight='bold') fig_err.text(0.5, 0.5, f'Figure corrompue: {str(e)}', ha='center', va='center', fontsize=12) plt.axis('off') pdf.savefig(fig_err, bbox_inches='tight') plt.close(fig_err) # Ajouter les images générées par IA si disponibles if 'generated_spectral_image' in st.session_state: fig_gen = plt.figure(figsize=(8.5, 11)) ax_gen = fig_gen.add_subplot(111) ax_gen.imshow(st.session_state['generated_spectral_image']) ax_gen.axis('off') ax_gen.set_title('Visualisation Réaliste du Sous-Sol (IA Générative)', fontsize=14, fontweight='bold', pad=20) if 'spectral_prompt' in st.session_state: fig_gen.text(0.5, 0.05, f"Prompt: {st.session_state['spectral_prompt'][:200]}...", ha='center', va='bottom', fontsize=8, wrap=True, style='italic') pdf.savefig(fig_gen, bbox_inches='tight') plt.close(fig_gen) if 'generated_3d_image' in st.session_state: fig_gen3d = plt.figure(figsize=(8.5, 11)) ax_gen3d = fig_gen3d.add_subplot(111) ax_gen3d.imshow(st.session_state['generated_3d_image']) ax_gen3d.axis('off') ax_gen3d.set_title('Coupe Géologique Réaliste 3D (IA Générative)', fontsize=14, fontweight='bold', pad=20) if '3d_prompt' in st.session_state: fig_gen3d.text(0.5, 0.05, f"Prompt: {st.session_state['3d_prompt'][:200]}...", ha='center', va='bottom', fontsize=8, wrap=True, style='italic') pdf.savefig(fig_gen3d, bbox_inches='tight') plt.close(fig_gen3d) # Métadonnées du PDF d = pdf.infodict() d['Title'] = 'Rapport Analyse ERT - Ravensgate Sonic' d['Author'] = 'ERTest Application' d['Subject'] = 'Analyse des niveaux d\'eau souterraine' d['Keywords'] = 'ERT, Ravensgate, Water Level, DTW' d['CreationDate'] = datetime.now() buffer.seek(0) return buffer.getvalue() def create_stratigraphy_pdf_report(df, figures_strat_dict): """ Crée un rapport PDF complet pour l'analyse stratigraphique Args: df: DataFrame avec les données de résistivité figures_strat_dict: Dictionnaire contenant toutes les figures stratigraphiques Returns: Bytes du fichier PDF """ buffer = io.BytesIO() with PdfPages(buffer) as pdf: # Page 1: Page de titre fig_title = plt.figure(figsize=(8.5, 11), dpi=150) fig_title.text(0.5, 0.75, '🪨 RAPPORT STRATIGRAPHIQUE COMPLET', ha='center', va='center', fontsize=22, fontweight='bold') fig_title.text(0.5, 0.68, 'Classification Géologique avec Résistivités', ha='center', va='center', fontsize=16, style='italic') fig_title.text(0.5, 0.6, f'📅 Date: {datetime.now().strftime("%d/%m/%Y %H:%M")}', ha='center', va='center', fontsize=12) # Statistiques du sondage rho_data = pd.to_numeric(df['data'], errors='coerce').dropna() depth_data = np.abs(pd.to_numeric(df['depth'], errors='coerce').dropna()) fig_title.text(0.5, 0.5, '📊 RÉSUMÉ DES DONNÉES', ha='center', va='center', fontsize=14, fontweight='bold') fig_title.text(0.5, 0.44, f'Nombre total de mesures: {len(df)}', ha='center', va='center', fontsize=11) fig_title.text(0.5, 0.40, f'Profondeur maximale: {depth_data.max():.3f} m (≈{depth_data.max()*1000:.0f} mm)', ha='center', va='center', fontsize=11) fig_title.text(0.5, 0.36, f'Résistivité min: {rho_data.min():.3f} Ω·m', ha='center', va='center', fontsize=11) fig_title.text(0.5, 0.32, f'Résistivité max: {rho_data.max():.0f} Ω·m', ha='center', va='center', fontsize=11) fig_title.text(0.5, 0.28, f'Résistivité moyenne: {rho_data.mean():.2f} Ω·m', ha='center', va='center', fontsize=11) # Catégories identifiées fig_title.text(0.5, 0.18, '🎯 CATÉGORIES GÉOLOGIQUES IDENTIFIÉES', ha='center', va='center', fontsize=12, fontweight='bold') categories = [ ('💧 Eaux', (0.1, 1000)), ('🧱 Argiles & Sols saturés', (1, 100)), ('🏖️ Sables & Graviers', (50, 1000)), ('🪨 Roches sédimentaires', (100, 5000)), ('🌋 Roches ignées', (1000, 100000)), ('💎 Minéraux & Minerais', (0.001, 1000000)) ] y_pos = 0.12 for cat_name, (rho_min, rho_max) in categories: mask = (rho_data >= rho_min) & (rho_data <= rho_max) count = mask.sum() if count > 0: fig_title.text(0.5, y_pos, f'{cat_name}: {count} mesures', ha='center', va='center', fontsize=9) y_pos -= 0.03 fig_title.text(0.5, 0.02, '© Belikan M. - Analyse ERT - Novembre 2025', ha='center', va='center', fontsize=8, style='italic', color='gray') plt.axis('off') pdf.savefig(fig_title, bbox_inches='tight') plt.close(fig_title) # Ajouter toutes les figures du dictionnaire for fig_name, fig in figures_strat_dict.items(): pdf.savefig(fig, bbox_inches='tight', dpi=150) plt.close(fig) # Ajouter les visualisations générées par IA si disponibles if 'generated_spectral_image' in st.session_state: fig_gen_strat = plt.figure(figsize=(8.5, 11)) ax_gen_strat = fig_gen_strat.add_subplot(111) ax_gen_strat.imshow(st.session_state['generated_spectral_image']) ax_gen_strat.axis('off') ax_gen_strat.set_title('🎨 Visualisation Réaliste des Couches Géologiques (IA)', fontsize=14, fontweight='bold', pad=20) pdf.savefig(fig_gen_strat, bbox_inches='tight', dpi=150) plt.close(fig_gen_strat) if 'generated_3d_image' in st.session_state: fig_gen3d_strat = plt.figure(figsize=(8.5, 11)) ax_gen3d_strat = fig_gen3d_strat.add_subplot(111) ax_gen3d_strat.imshow(st.session_state['generated_3d_image']) ax_gen3d_strat.axis('off') ax_gen3d_strat.set_title('🎨 Coupe Stratigraphique 3D Réaliste (IA)', fontsize=14, fontweight='bold', pad=20) pdf.savefig(fig_gen3d_strat, bbox_inches='tight', dpi=150) plt.close(fig_gen3d_strat) # Métadonnées du PDF d = pdf.infodict() d['Title'] = 'Rapport Stratigraphique Complet' d['Author'] = 'Belikan M. - ERTest Application' d['Subject'] = 'Classification géologique par résistivité électrique' d['Keywords'] = 'ERT, Stratigraphie, Résistivité, Géologie, Minéraux' d['CreationDate'] = datetime.now() buffer.seek(0) return buffer.getvalue() # --- Parsing .dat robuste avec cache --- @st.cache_data def detect_encoding(file_bytes): """Détecte l'encodage depuis les bytes du fichier""" result = chardet.detect(file_bytes[:100000]) return result['encoding'] or 'utf-8' @st.cache_data def parse_dat(file_content, encoding): """Parse le contenu du fichier .dat avec mise en cache""" try: from io import StringIO df = pd.read_csv( StringIO(file_content.decode(encoding)), sep=r'\s+', header=None, comment='#', names=['survey_point', 'depth', 'data', 'project'], on_bad_lines='skip', engine='python' ) df['survey_point'] = pd.to_numeric(df['survey_point'], errors='coerce') df['depth'] = pd.to_numeric(df['depth'], errors='coerce') df['data'] = pd.to_numeric(df['data'], errors='coerce') df = df.dropna(subset=['survey_point', 'depth', 'data']) return df except Exception as e: st.error(f"Erreur parsing : {e}") return pd.DataFrame() @st.cache_data def parse_freq_dat(file_content, encoding): """Parse le fichier freq.dat avec fréquences en MHz""" try: from io import StringIO import pandas as pd # Décoder le contenu avec gestion du BOM UTF-8 content = file_content.decode(encoding, errors='replace') # Supprimer le BOM s'il existe if content.startswith('\ufeff'): content = content[1:] # Lire avec pandas, en ignorant les lignes vides df = pd.read_csv(StringIO(content), sep=',', header=0, engine='python') # Nettoyer les noms de colonnes (supprimer les espaces et caractères spéciaux) df.columns = [col.strip().replace('MHz', '').replace(',', '') for col in df.columns] # La première colonne devrait être le projet, la deuxième le point de sondage # Les colonnes suivantes sont les fréquences if len(df.columns) < 3: return pd.DataFrame() # Renommer les colonnes freq_columns = df.columns[2:] # Colonnes de fréquences df.columns = ['project', 'survey_point'] + [f'freq_{col}' for col in freq_columns] # Convertir survey_point en numérique df['survey_point'] = pd.to_numeric(df['survey_point'], errors='coerce') # Convertir les colonnes de fréquence en numérique for col in df.columns[2:]: df[col] = pd.to_numeric(df[col], errors='coerce') # Supprimer les lignes avec survey_point NaN df = df.dropna(subset=['survey_point']) return df except Exception as e: st.error(f"Erreur parsing freq.dat : {e}") return pd.DataFrame() # --- Tableau des types d'eau --- water_html = """
Type d'eau Résistivité (Ω.m) Couleur associée Description
Eau de mer 0.1 – 1 Rouge vif / Orange Eau océanique hautement salée (∼35 g/L de sel). Très forte conductivité électrique due aux ions Na⁺ et Cl⁻. Typique des mers et océans.
Eau salée (nappe) 1 – 10 Jaune / Orange Eau saumâtre dans les nappes phréatiques côtières (intrusion saline). Salinité intermédiaire, souvent non potable sans traitement.
Eau douce 10 – 100 Vert / Bleu clair Eau potable standard (rivières, lacs, nappes intérieures). Faiblement minéralisée, conductivité modérée.
Eau très pure > 100 Bleu foncé Eau ultra-pure (distillée, déminéralisée, pluie). Presque pas d'ions → très faible conductivité. Utilisée en laboratoire/industrie.
""" # --- Tableau complet des matériaux géologiques (sols, roches, minéraux et eaux) --- geology_html = """
📊 CLASSIFICATION COMPLÈTE DES RÉSISTIVITÉS GÉOLOGIQUES
Catégorie Matériau Résistivité (Ω.m) Couleur Description / Usage
💧
EAUX
Eau de mer 0.1 – 1 🔴 Rouge Océans, forte salinité (35 g/L NaCl)
Eau salée/saumâtre 1 – 10 🟡 Jaune-Orange Nappes côtières, intrusion saline
Eau douce 10 – 100 🟢 Vert-Bleu clair Nappes phréatiques, rivières, lacs
Eau ultra-pure 100 – 1000 🔵 Bleu foncé Eau distillée, pluie, laboratoire
🧱
ARGILES
& SOLS
SATURÉS
Argile marine saturée 1 – 10 🟤 Brun rouge Très conductrice, riche en sels
Argile compacte humide 10 – 50 🟫 Brun Formations imperméables, rétention d'eau
Limon/Silt saturé 20 – 100 🟨 Beige Sol fin avec eau interstitielle
🏖️
SABLES
& GRAVIERS
Sable saturé (eau douce) 50 – 200 🟧 Sable Aquifère perméable, bon pour puits
Sable sec 200 – 1000 🟨 Beige clair Zone non saturée, faible conductivité
Gravier saturé 100 – 500 ⚫ Gris-vert Très perméable, aquifère productif
🪨
ROCHES
SÉDIMEN-
TAIRES
Calcaire fissuré (saturé) 100 – 1000 ⚪ Gris clair Karst, aquifère calcaire, grottes
Calcaire compact 1000 – 5000 ⚪ Gris Peu poreux, faible perméabilité
Grès poreux saturé 200 – 2000 🟫 Or terne Réservoir aquifère important
Schiste argileux 10 – 100 ⚫ Gris foncé Conducteur, riche en minéraux argileux
🌋
ROCHES
IGNÉES
& MÉTA.
Granite 5000 – 100000 🩷 Rose Très résistif, socle cristallin
Basalte compact 1000 – 10000 ⚫ Noir-gris Roche volcanique dense
Basalte fracturé (saturé) 200 – 2000 🟢 Vert sombre Aquifère volcanique
Quartzite 10000 – 100000 ⚪ Blanc cassé Métamorphique, très résistant
💎
MINÉRAUX
& ORES
Minerais métalliques (cuivre, or) 0.01 – 1 🟡 Doré Très conducteurs, cibles minières
Graphite 0.001 – 0.1 ⚫ Noir Extrêmement conducteur
Quartz pur > 100000 ⚪ Transparent Isolant électrique parfait
""" # --- Seed pour reproductibilité des exemples --- np.random.seed(42) # --- Logo SETRAF en base64 (intégré) --- SETRAF_LOGO_BASE64 = None try: # Charger le logo depuis le fichier logo_base64_code.py from logo_base64_code import SETRAF_LOGO_BASE64 except ImportError: try: # Fallback: charger depuis logo_embed_complete.py with open('logo_embed_complete.py', 'r') as f: exec(f.read()) except: pass # --- Charger le logo SETRAF au démarrage --- @st.cache_data def load_setraf_logo(): """Charge le logo SETRAF depuis base64""" try: if SETRAF_LOGO_BASE64 is not None and len(SETRAF_LOGO_BASE64) > 100: import base64 from io import BytesIO from PIL import Image # Décoder le base64 logo_bytes = base64.b64decode(SETRAF_LOGO_BASE64) # Créer un buffer buffer = BytesIO(logo_bytes) # Vérifier que c'est bien une image valide img = Image.open(buffer) img.verify() # Vérifier l'intégrité # Remettre le buffer au début buffer.seek(0) return buffer else: # Return None without st. calls return None except Exception as e: # Return None without st. calls return None # --- Interface Streamlit --- st.set_page_config( page_title="SETRAF - Subaquifère ERT Analysis", page_icon="💧", layout="wide", initial_sidebar_state="expanded" ) # Charger le logo une seule fois après set_page_config if 'setraf_logo_buffer' not in st.session_state: logo_buffer = load_setraf_logo() if logo_buffer is None: st.warning("⚠️ Logo SETRAF non disponible (base64 vide ou manquant)") st.info("💡 Le logo par défaut sera utilisé") st.session_state.setraf_logo_buffer = logo_buffer st.title("💧 SETRAF - Subaquifère ERT Analysis Tool (08 Novembre 2025)") # LOGO DE L'APPLICATION if st.session_state.setraf_logo_buffer is not None: try: st.sidebar.image(st.session_state.setraf_logo_buffer, width=200, caption="SETRAF - Analyse Géophysique ERT") except Exception as e: st.sidebar.error(f"❌ Erreur affichage logo: {str(e)}") st.sidebar.markdown("### 💧 SETRAF") st.sidebar.markdown("*Subaquifère ERT Analysis Tool*") else: # Logo de fallback avec style amélioré st.sidebar.markdown("""

💧 SETRAF

Subaquifère ERT Analysis Tool

""", unsafe_allow_html=True) st.sidebar.markdown("---") # ========== PALETTES DE COULEURS ET THÈMES ========== st.sidebar.markdown("### 🎨 Thème de l'Application") # Définition des palettes de couleurs prédéfinies THEME_PALETTES = { "🌊 Océan (Défaut)": { "primary": "#1e3a8a", # Bleu foncé "secondary": "#3b82f6", # Bleu moyen "accent": "#06b6d4", # Cyan "background": "#f0f9ff", # Bleu très clair "surface": "#ffffff", # Blanc "text": "#1e293b", # Gris foncé "text_secondary": "#64748b", # Gris moyen "success": "#10b981", # Vert "warning": "#f59e0b", # Orange "error": "#ef4444", # Rouge "border": "#e2e8f0" # Gris clair }, "🌿 Nature": { "primary": "#166534", # Vert foncé "secondary": "#22c55e", # Vert moyen "accent": "#84cc16", # Vert clair "background": "#f0fdf4", # Vert très clair "surface": "#ffffff", # Blanc "text": "#14532d", # Vert très foncé "text_secondary": "#4b5563", # Gris "success": "#10b981", # Vert "warning": "#f59e0b", # Orange "error": "#ef4444", # Rouge "border": "#d1fae5" # Vert pâle }, "🌙 Nuit": { "primary": "#1e1b4b", # Indigo foncé "secondary": "#6366f1", # Indigo "accent": "#a78bfa", # Violet "background": "#0f0a19", # Noir bleuté "surface": "#1e1b4b", # Indigo foncé "text": "#f8fafc", # Blanc "text_secondary": "#cbd5e1", # Gris clair "success": "#10b981", # Vert "warning": "#f59e0b", # Orange "error": "#ef4444", # Rouge "border": "#334155" # Gris foncé }, "🌅 Aube": { "primary": "#7c2d12", # Orange foncé "secondary": "#ea580c", # Orange "accent": "#f97316", # Orange clair "background": "#fff7ed", # Orange très clair "surface": "#ffffff", # Blanc "text": "#9a3412", # Orange très foncé "text_secondary": "#64748b", # Gris "success": "#10b981", # Vert "warning": "#f59e0b", # Orange "error": "#ef4444", # Rouge "border": "#fed7aa" # Orange pâle }, "⚫ Classique": { "primary": "#374151", # Gris foncé "secondary": "#6b7280", # Gris moyen "accent": "#9ca3af", # Gris clair "background": "#f9fafb", # Gris très clair "surface": "#ffffff", # Blanc "text": "#111827", # Noir "text_secondary": "#6b7280", # Gris "success": "#10b981", # Vert "warning": "#f59e0b", # Orange "error": "#ef4444", # Rouge "border": "#e5e7eb" # Gris pâle }, "🌈 Arc-en-ciel": { "primary": "#7c3aed", # Violet "secondary": "#ec4899", # Rose "accent": "#06b6d4", # Cyan "background": "#fef7ff", # Violet très clair "surface": "#ffffff", # Blanc "text": "#581c87", # Violet foncé "text_secondary": "#64748b", # Gris "success": "#10b981", # Vert "warning": "#f59e0b", # Orange "error": "#ef4444", # Rouge "border": "#e9d5ff" # Violet pâle } } # Initialiser le thème dans session_state if 'selected_theme' not in st.session_state: st.session_state.selected_theme = "🌊 Océan (Défaut)" # Sélecteur de thème selected_theme = st.sidebar.selectbox( "🎨 Choisir un thème", options=list(THEME_PALETTES.keys()), index=list(THEME_PALETTES.keys()).index(st.session_state.selected_theme), help="Changez l'apparence de l'application" ) # Mettre à jour le thème sélectionné if selected_theme != st.session_state.selected_theme: st.session_state.selected_theme = selected_theme st.rerun() # Redémarrer pour appliquer le thème # Récupérer les couleurs du thème sélectionné current_theme = THEME_PALETTES[st.session_state.selected_theme] # Appliquer le CSS personnalisé pour le thème theme_css = f""" """ # Injecter le CSS personnalisé st.markdown(theme_css, unsafe_allow_html=True) st.sidebar.markdown("---") # TEMPORAIREMENT DÉSACTIVÉ POUR DIAGNOSTIC # Initialiser et charger le LLM AUTOMATIQUEMENT au premier démarrage if 'llm_pipeline' not in st.session_state: st.session_state.llm_pipeline = None st.session_state.llm_loaded = False st.session_state.llm_loading_attempted = False st.session_state.clip_model = None st.session_state.clip_processor = None st.session_state.clip_device = 'cpu' st.session_state.clip_loaded = False st.session_state.use_clip = False # Par défaut désactivé st.session_state.explanation_cache = {} # Cache des explications st.session_state.enable_llm = True # Mode LLM activé par défaut st.session_state.llm_pipeline = None st.session_state.llm_loaded = False st.session_state.llm_loading_attempted = False st.session_state.clip_model = None st.session_state.clip_processor = None st.session_state.clip_device = 'cpu' st.session_state.clip_loaded = False st.session_state.figures_dict = {} # Dictionnaire pour stocker les figures PDF st.session_state.use_clip = False # Par défaut désactivé st.session_state.explanation_cache = {} # Cache des explications st.session_state.enable_llm = True # Mode LLM activé par défaut # ===== MODE RAPIDE : OPTION POUR DÉSACTIVER LE LLM ===== st.sidebar.markdown("### ⚡ Mode de Performance") enable_llm = st.sidebar.checkbox( "🤖 Activer le LLM (analyses IA)", value=st.session_state.get('enable_llm', True), help="✅ Activé : Analyses IA complètes avec Phi-3-mini\n❌ Désactivé : Interprétation rapide sans IA" ) st.session_state.enable_llm = enable_llm if not enable_llm: st.sidebar.info("⚡ Mode Rapide : LLM désactivé\nInterprétation basique uniquement") # Ne pas charger le LLM en mode rapide st.session_state.llm_loaded = False else: # OPTIONS DE PERFORMANCE (après initialisation du session_state) use_clip = st.sidebar.checkbox("🖼️ Activer CLIP (analyse visuelle)", value=st.session_state.use_clip, help="⚠️ CLIP est lent ! Désactivez pour des explications plus rapides (LLM seul)") st.session_state.use_clip = use_clip # Chargement automatique au premier lancement try: if not st.session_state.llm_loaded and not st.session_state.llm_loading_attempted: # Essai rapide d'initialisation automatique silencieuse try: st.session_state.llm_pipeline = load_mistral_llm(use_cpu=True, quantize=True) st.session_state.llm_loaded = True # Initialiser RAG immédiatement après try: initialize_rag_system() except: pass # Ne pas bloquer si RAG échoue except: pass # Échec silencieux, on passera au manuel finally: st.session_state.llm_loading_attempted = True except: pass # Sécurité maximale # BOUTON DE CHARGEMENT MANUEL TRÈS VISIBLE if not st.session_state.llm_loaded: st.sidebar.markdown("---") st.sidebar.markdown("### 🚀 CHARGEMENT IA MANUEL") st.sidebar.warning("⚠️ LLM non chargé - Analyses basiques uniquement") if st.sidebar.button("🚀 CHARGER LE LLM MAINTENANT", key="manual_llm_load", help="Cliquez pour activer les analyses IA intelligentes", type="primary"): with st.sidebar.status("🤖 Chargement du LLM en cours...", expanded=True) as status: try: status.write("📥 Téléchargement et initialisation du modèle IA...") st.session_state.llm_pipeline = load_mistral_llm(use_cpu=True, quantize=True) st.session_state.llm_loaded = True status.write("🖼️ Chargement des capacités visuelles (CLIP)...") if use_clip and not st.session_state.clip_loaded: clip_model, clip_processor, clip_device = load_clip_model() st.session_state.clip_model = clip_model st.session_state.clip_processor = clip_processor st.session_state.clip_device = clip_device st.session_state.clip_loaded = (clip_model is not None) status.update(label="✅ IA activée avec succès !", state="complete") st.sidebar.success("💡 Analyses IA complètes activées !") # Initialiser RAG après succès status.write("📚 Activation de la base de connaissances...") try: rag_initialized = initialize_rag_system() if rag_initialized: st.sidebar.success("✅ Base de connaissances activée") else: st.sidebar.warning("⚠️ Base limitée - Mode IA seul") except Exception as rag_error: st.sidebar.warning(f"⚠️ Base partielle : {str(rag_error)[:25]}") st.rerun() # Actualiser l'interface except Exception as e: status.update(label="❌ Échec du chargement", state="error") st.sidebar.error(f"❌ Erreur : {str(e)[:60]}") st.sidebar.info("Réessayez ou continuez avec analyses basiques") if st.session_state.llm_loaded: st.sidebar.success("✅ LLM Mistral actif - Analyses intelligentes activées") if st.session_state.clip_loaded and use_clip: st.sidebar.success("✅ CLIP actif - Analyse visuelle activée") elif use_clip and not st.session_state.clip_loaded: st.sidebar.info("⏳ Cochez la case pour charger CLIP") # SYSTÈME RAG st.sidebar.markdown("---") st.sidebar.subheader("📚 Système RAG") # État du RAG if 'ert_knowledge_base' in st.session_state and st.session_state.ert_knowledge_base.vectorstore: kb = st.session_state.ert_knowledge_base nb_chunks = len(kb.documents) if kb.documents else 0 st.sidebar.success(f"✅ RAG Actif: {nb_chunks} chunks indexés") # Calculer le nombre de mots total nb_words = sum(len(doc.split()) for doc in kb.documents) if kb.documents else 0 st.sidebar.caption(f"📊 {nb_chunks} chunks | {nb_words:,} mots") # Option pour activer/désactiver la recherche web use_web_search = st.sidebar.checkbox( "🌐 Recherche web (Tavily)", value=kb.web_search_enabled, help="Active la recherche sur internet pour enrichir les explications" ) kb.web_search_enabled = use_web_search # Upload de documents PDF st.sidebar.markdown("##### 📤 Ajouter des documents PDF") uploaded_pdf = st.sidebar.file_uploader( "Choisir un fichier PDF", type=['pdf'], help="Ajoutez des documents scientifiques sur la géophysique ERT" ) if uploaded_pdf is not None: if st.sidebar.button("📚 Indexer le document", key="index_pdf"): with st.sidebar.status(f"📄 Indexation de {uploaded_pdf.name}...", expanded=True): try: # Créer le dossier si nécessaire os.makedirs(RAG_DOCUMENTS_PATH, exist_ok=True) # Sauvegarder le PDF pdf_path = os.path.join(RAG_DOCUMENTS_PATH, uploaded_pdf.name) with open(pdf_path, 'wb') as f: f.write(uploaded_pdf.getbuffer()) st.write(f"✅ PDF sauvegardé: {uploaded_pdf.name}") # Indexation incrémentale automatique kb = st.session_state.ert_knowledge_base if kb.add_pdf_to_vectorstore(pdf_path): st.sidebar.success(f"✅ '{uploaded_pdf.name}' indexé automatiquement!") st.sidebar.info(f"📊 Total chunks: {len(kb.documents)}") st.rerun() else: st.sidebar.warning("⚠️ Indexation partielle, régénérez la base") except Exception as e: st.sidebar.error(f"❌ Erreur indexation : {str(e)[:100]}") # Bouton pour régénérer la base if st.sidebar.button("🔄 Régénérer base RAG", key="regenerate_rag"): with st.sidebar.status("🔄 Reconstruction de la base RAG...", expanded=True): try: # Supprimer l'ancienne instance if 'ert_knowledge_base' in st.session_state: del st.session_state.ert_knowledge_base # Créer une nouvelle instance et forcer l'initialisation st.session_state.ert_knowledge_base = ERTKnowledgeBase() st.write("📂 Nouvelle instance créée") # Initialiser avec le nouveau système rag_initialized = initialize_rag_system() if rag_initialized: kb = st.session_state.ert_knowledge_base nb_chunks = len(kb.documents) if kb.documents else 0 nb_words = sum(len(doc.split()) for doc in kb.documents) if kb.documents else 0 st.sidebar.success(f"✅ Base RAG régénérée!") st.sidebar.info(f"📊 {nb_chunks} chunks | {nb_words:,} mots") st.rerun() else: st.sidebar.error("❌ Échec régénération") except Exception as e: st.sidebar.error(f"❌ Erreur : {str(e)[:100]}") else: st.sidebar.warning("⚠️ Système RAG non initialisé") if st.sidebar.button("🚀 Initialiser RAG", key="init_rag"): with st.sidebar.status("🔄 Initialisation RAG...", expanded=True): try: rag_initialized = initialize_rag_system() if rag_initialized: st.sidebar.success("✅ RAG initialisé !") st.rerun() else: st.sidebar.error("❌ Échec initialisation RAG") except Exception as e: st.sidebar.error(f"❌ Erreur RAG : {str(e)[:50]}") # Statistiques du cache if 'explanation_cache' in st.session_state: cache_size = len(st.session_state.explanation_cache) if cache_size > 0: st.sidebar.caption(f"💾 Cache: {cache_size} explication(s)") if st.sidebar.button("🔄 Recharger le LLM + CLIP"): st.session_state.llm_pipeline = None st.session_state.llm_loaded = False st.session_state.llm_loading_attempted = False st.session_state.clip_model = None st.session_state.clip_processor = None st.session_state.clip_loaded = False st.session_state.explanation_cache = {} st.rerun() else: st.sidebar.warning("⚠️ LLM non chargé - Analyses basiques uniquement") if st.sidebar.button("🚀 Réessayer le chargement"): st.session_state.llm_loading_attempted = False st.rerun() # Bouton de téléchargement de la thèse doctorale st.sidebar.markdown("---") st.sidebar.subheader("📚 Documentation Académique") if st.sidebar.button("📖 Télécharger le Mémoire Technique Complet", help="Mémoire technique de 500+ pages sur le système STGI"): with st.spinner("📄 Génération du mémoire technique en cours..."): try: # Importer le générateur de mémoire technique import sys sys.path.append('/home/belikan/KIbalione8/SETRAF') from generate_thesis import generate_complete_technical_report # Générer le PDF thesis_pdf = generate_complete_technical_report() # Bouton de téléchargement st.sidebar.download_button( label="💾 Télécharger Memoire_STGI_NYUNDU_2025.pdf", data=thesis_pdf, file_name=f"Memoire_Technique_STGI_Francis_Arnaud_NYUNDU_{datetime.now().strftime('%Y')}.pdf", mime="application/pdf", key="download_thesis" ) st.sidebar.success("✅ Mémoire technique généré avec succès !") except Exception as e: st.sidebar.error(f"❌ Erreur lors de la génération : {str(e)}") # Indicateur de backend # try: # from auth_module import BACKEND_URL, USE_PRODUCTION # backend_status = "🌐 Production (Render)" if USE_PRODUCTION else "💻 Local" # backend_color = "green" if USE_PRODUCTION else "blue" # st.markdown(f"**Backend:** :{backend_color}[{backend_status}] - `{BACKEND_URL.replace('/api', '')}`") # except: # pass # Message de bienvenue pour utilisateur authentifié # if AUTH_ENABLED and st.session_state.authenticated: # user = st.session_state.user # st.success(f"👋 Bienvenue, {user.get('fullName', user.get('username'))} !") # # with st.expander("ℹ️ Informations de session", expanded=False): # col1, col2, col3 = st.columns(3) # with col1: # st.metric("👤 Utilisateur", user.get('username')) # with col2: # st.metric("📧 Email", user.get('email')) # with col3: # st.metric("🎯 Rôle", user.get('role', 'user').upper()) # ========== DASHBOARD RAG ET EXPLICATIONS ========== if st.session_state.get('llm_loaded', False): st.markdown("---") col_rag1, col_rag2, col_rag3 = st.columns([1, 2, 1]) with col_rag1: if st.button("🧠 Dashboard Explications RAG", key="btn_show_rag_dashboard"): st.session_state['show_rag_dashboard'] = not st.session_state.get('show_rag_dashboard', False) with col_rag2: if 'ert_knowledge_base' in st.session_state and st.session_state.ert_knowledge_base.vectorstore: kb = st.session_state.ert_knowledge_base nb_docs = len(kb.documents) if kb.documents else 0 cache_count = len(st.session_state.get('explanation_cache', {})) # Calculer la taille totale de la base total_chars = sum(len(doc) for doc in kb.documents) if kb.documents else 0 total_words = sum(len(doc.split()) for doc in kb.documents) if kb.documents else 0 st.success(f"✅ RAG Actif: {nb_docs} chunks | {total_words} mots | Cache: {cache_count} explications") else: st.warning("⚠️ RAG non initialisé - Explications LLM seules") with col_rag3: if st.button("🔍 Test RAG", key="test_rag"): if 'ert_knowledge_base' in st.session_state: kb = st.session_state.ert_knowledge_base test_results = kb.search_knowledge_base("résistivité géophysique ERT", k=2) if test_results: st.info(f"🧪 Test RAG réussi : {len(test_results)} résultats trouvés") else: st.warning("🧪 Test RAG : Aucun résultat") else: st.error("🧪 RAG non disponible") # Afficher le dashboard si demandé if st.session_state.get('show_rag_dashboard', False): show_explanation_dashboard() # ========== BARRE DE NAVIGATION HORIZONTALE GLISSANTE ========== st.markdown("---") st.markdown("---") # CSS pour rendre la barre d'onglets horizontalement scrollable st.markdown(""" """, unsafe_allow_html=True) # Créer les onglets principaux tab1, tab2, tab3, tab4, tab5, tab6 = st.tabs([ "🌡️ Calculateur Réglage Température", "📊 Analyse Fichiers .dat", "🌍 ERT Pseudo-sections 2D/3D", "🪨 Stratigraphie Complète (Sols + Eaux)", "🔬 Inversion pyGIMLi - ERT Avancée", "🖼️ Analyse Spectrale d'Images (Imputation + Reconstruction)" ]) # ===================== TAB 1 : TEMPÉRATURE ===================== # ===================== TAB 1 : TEMPÉRATURE ===================== with tab1: st.header("Calculateur de réglage Ts (Table officielle Ravensgate)") st.markdown(""" Entrez la température de l'eau du puits (**Tw**) et la température moyenne quotidienne de surface (**Tg**). L'app arrondit **conventionnellement (half-up)** aux pas du tableau et clamp automatiquement. **Exemple du manuel** : Tw = 58 °F (14 °C), Tg = 85 °F (29 °C) → **Ts = 62 °F** (17 °C). """) unit = st.radio("Unité", options=["°F", "°C"], horizontal=True) if unit == "°C": col1, col2 = st.columns(2) with col1: tw_c = st.number_input("Tw – Température eau puits (°C)", value=10.0, min_value=-10.0, max_value=50.0, step=0.1) with col2: tg_c = st.number_input("Tg – Température surface moyenne (°C)", value=20.0, min_value=-30.0, max_value=50.0, step=0.1) tw_f = tw_c * 9/5 + 32 tg_f = tg_c * 9/5 + 32 else: col1, col2 = st.columns(2) with col1: tw_f = st.number_input("Tw – Température eau puits (°F)", value=60.0, min_value=20.0, max_value=120.0, step=0.5) with col2: tg_f = st.number_input("Tg – Température surface moyenne (°F)", value=70.0, min_value=-20.0, max_value=120.0, step=0.5) if st.button("🔥 Calculer Ts", type="primary", use_container_width=True): ts = get_ts(tw_f, tg_f) tw_used = max(36, min(90, int(tw_f / 2 + 0.5) * 2)) tg_used = max(0, min(95, int(tg_f / 5 + 0.5) * 5)) st.success(f"**Réglage recommandé sur l'appareil → Ts = {ts} °F**") if unit == "°C": st.info(f"Tw utilisée → {tw_used} °F ({(tw_used - 32)*5/9:.1f} °C) | Tg utilisée → {tg_used} °F ({(tg_used - 32)*5/9:.1f} °C)") else: st.info(f"Tw utilisée → {tw_used} °F | Tg utilisée → {tg_used} °F") with st.expander("📋 Tableau complet Ravensgate (cliquer pour déplier)"): tg_cols = list(range(0, 96, 5)) df_table = pd.DataFrame.from_dict(temperature_control_table, orient='index', columns=tg_cols) df_table.index.name = "Tw \\ Tg" df_table = df_table.sort_index() df_table.insert(0, "Tw (°F)", df_table.index) st.dataframe(df_table.style.background_gradient(cmap='coolwarm', axis=None), use_container_width=True) with st.expander("💧 Valeurs typiques pour l'eau – Résistivité & Couleurs associées"): st.markdown("### **2. Valeurs typiques pour l'eau**") st.markdown(water_html, unsafe_allow_html=True) st.caption("Ces valeurs sont indicatives. Les couleurs sont couramment utilisées dans les cartes de résistivité électrique (ERT) pour visualiser la salinité/qualité de l'eau souterraine.") # ===================== TAB 2 : ANALYSE .DAT ===================== with tab2: st.header("2 Analyse de fichiers .dat de Ravensgate Sonic Water Level Meter") st.markdown(""" ### Format attendu dans le .dat : - **Date** : Format YYYY/MM/DD HH:MM:SS - **Survey Point** (Point de forage) - **Depth From** et **Depth To** (Profondeur de mesure) - **Data** : Niveau d'eau (DTW - Depth To Water) """) # Initialiser l'état de session if 'uploaded_data' not in st.session_state: st.session_state['uploaded_data'] = None uploaded_file = st.file_uploader("📂 Uploader un fichier .dat", type=["dat"]) if uploaded_file is not None: # Lire le contenu du fichier en bytes (avec cache) file_bytes = uploaded_file.read() encoding = detect_encoding(file_bytes) # Parser le fichier (avec cache) df = parse_dat(file_bytes, encoding) # Déterminer l'unité unit = 'm' # Par défaut if not df.empty: st.success(f"✅ {len(df)} lignes chargées") # Vérification rapide si fichier existe if 'rag_kb' in st.session_state and st.session_state.rag_kb is not None: filename = uploaded_file.name hash_id = st.session_state.rag_kb._compute_dat_hash(df, filename) if hash_id and hash_id in st.session_state.rag_kb.dat_files_registry: st.info(f"🔖 Fichier connu : {hash_id[:8]}") # Sauvegarder dans l'état de session pour l'onglet 3 st.session_state['uploaded_data'] = df.copy() st.session_state['unit'] = unit # Affichage du DataFrame st.dataframe(df.head(50), use_container_width=True) # Statistiques de base st.subheader("📊 Statistiques descriptives") col1, col2, col3, col4 = st.columns(4) with col1: st.metric("Total mesures", len(df)) with col2: st.metric("Points de sondage", df['survey_point'].nunique()) with col3: st.metric(f"DTW moyen ({unit})", f"{df['data'].mean():.2f}") with col4: st.metric(f"DTW max ({unit})", f"{df['data'].max():.2f}") # 💬 CHAT INTERACTIF AVEC L'IA SUR LES DONNÉES st.markdown("---") st.subheader("💬 Discussion IA sur les données") if st.session_state.get('llm_loaded', False): st.info("ℹ️ **Utilisez le chat en bas de page** pour discuter avec l'IA sur vos données.") # Sauvegarder le contexte pour le chat global if 'current_data_context' not in st.session_state: st.session_state['current_data_context'] = {} st.session_state['current_data_context'] = { 'filename': uploaded_file.name, 'n_measures': len(df), 'n_points': df['survey_point'].nunique(), 'resistivity_range': f"{df['data'].min():.1f}-{df['data'].max():.1f} {unit}", 'mean': f"{df['data'].mean():.1f}", 'median': f"{df['data'].median():.1f}" } else: st.info("💡 Chargez le LLM pour activer le chat IA") st.markdown("---") # 🤖 PRÉDICTIONS ML (en option) if 'rag_kb' in st.session_state and st.session_state.rag_kb is not None: if st.session_state.rag_kb.models_initialized: with st.expander("🎨 Voir les prédictions ML", expanded=False): st.info("🧠 Prédictions ML") # Prédictions simplifiées prediction_data = [] sample_size = min(5, len(df)) # Réduit à 5 au lieu de 10 sample_df = df.sample(sample_size) for _, row in sample_df.iterrows(): pred = st.session_state.rag_kb.predict_resistivity( row.get('survey_point', 0), row.get('depth_from', 0), row.get('depth_to', 0) ) if pred: prediction_data.append({ 'Point': int(row.get('survey_point', 0)), 'Résistivité réelle': f"{row.get('data', 0):.1f}", 'Résistivité prédite': f"{pred['resistivity']:.1f}", 'Interprétation': pred['geological_interpretation'][:50] + "..." }) if prediction_data: st.dataframe(pd.DataFrame(prediction_data), use_container_width=True, hide_index=True) with col1: st.metric("Total mesures", len(df)) with col2: st.metric("Points de sondage", df['survey_point'].nunique()) with col3: st.metric(f"DTW moyen ({unit})", f"{df['data'].mean():.2f}") with col4: st.metric(f"DTW max ({unit})", f"{df['data'].max():.2f}") # Graphique temporel st.subheader("📈 Évolution temporelle du niveau d'eau") # Dictionnaire pour stocker toutes les figures figures_dict = {} # Vérifier si colonne 'date' existe if 'date' in df.columns: fig_time, ax = plt.subplots(figsize=(12, 5), dpi=150) for sp in sorted(df['survey_point'].unique()): subset = df[df['survey_point'] == sp] ax.plot(subset['date'], subset['data'], marker='o', label=f'SP {int(sp)}', markersize=4) ax.set_xlabel('Date', fontsize=11) ax.set_ylabel(f'DTW ({unit})', fontsize=11) ax.set_title('Niveau d\'eau par point de sondage', fontsize=13, fontweight='bold') ax.legend(bbox_to_anchor=(1.05, 1), loc='upper left', fontsize=9) ax.grid(True, alpha=0.3) plt.xticks(rotation=45) plt.tight_layout() st.pyplot(fig_time, clear_figure=False, use_container_width=True) # Sauvegarder pour PDF figures_dict['temporal_evolution'] = fig_time else: st.info("⚠️ Pas de colonne 'date' dans le fichier - graphique temporel indisponible") fig_time = None # Détection d'anomalies st.subheader("🔍 Détection d'anomalies (K-Means)") n_clusters = st.slider("Nombre de clusters", 2, 5, 3, key='kmeans_slider') # Cache du calcul KMeans basé sur les données + nombre de clusters @st.cache_data def compute_kmeans(data_hash, n_clust): """Calcul KMeans avec cache""" X = df[['survey_point', 'depth', 'data']].values kmeans = KMeans(n_clusters=n_clust, random_state=42, n_init=10) return kmeans.fit_predict(X) # Hash unique des données pour invalidation du cache data_hash = hash(tuple(df[['survey_point', 'depth', 'data']].values.flatten())) clusters = compute_kmeans(data_hash, n_clusters) df_viz = df.copy() df_viz['cluster'] = clusters fig_cluster, ax = plt.subplots(figsize=(12, 6), dpi=150) # Utiliser les valeurs de résistivité avec colormap d'eau au lieu des clusters scatter = ax.scatter(df_viz['survey_point'], df_viz['depth'], c=df_viz['data'], cmap=WATER_CMAP, norm=LogNorm(vmin=max(0.1, df_viz['data'].min()), vmax=df_viz['data'].max()), s=50, alpha=0.8, edgecolors='black', linewidths=0.5) cbar = plt.colorbar(scatter, ax=ax, label='Résistivité (Ω·m)') # Ajouter annotations types d'eau sur colorbar cbar.ax.axhline(1, color='white', linewidth=1, linestyle='--', alpha=0.6) cbar.ax.axhline(10, color='white', linewidth=1, linestyle='--', alpha=0.6) cbar.ax.axhline(100, color='white', linewidth=1, linestyle='--', alpha=0.6) ax.set_xlabel('Point de sondage', fontsize=11) ax.set_ylabel(f'Profondeur ({unit})', fontsize=11) ax.set_title(f'Classification en {n_clusters} groupes', fontsize=13, fontweight='bold') ax.grid(True, alpha=0.3) plt.tight_layout() st.pyplot(fig_cluster, clear_figure=False, use_container_width=True) # Sauvegarder pour PDF figures_dict['kmeans_clustering'] = fig_cluster # Coupe de niveaux d'eau avec couleurs de résistivité st.subheader("🌊 Coupe géologique - Niveaux d'eau avec résistivité") # Préparer les données pour la coupe survey_points = sorted(df['survey_point'].unique()) depths = sorted(df['depth'].unique()) if len(survey_points) >= 2 and len(depths) >= 2: # Créer une grille 2D from scipy.interpolate import griddata X_grid = [] Z_grid = [] DTW_grid = [] for sp in survey_points: for depth in depths: subset = df[(df['survey_point'] == sp) & (df['depth'] == depth)] if len(subset) > 0: X_grid.append(float(sp)) Z_grid.append(abs(float(depth))) DTW_grid.append(float(subset['data'].values[0])) X_grid = np.array(X_grid) Z_grid = np.array(Z_grid) DTW_grid = np.array(DTW_grid) # Interpolation pour avoir une grille lisse xi = np.linspace(X_grid.min(), X_grid.max(), 150) zi = np.linspace(Z_grid.min(), Z_grid.max(), 100) Xi, Zi = np.meshgrid(xi, zi) DTWi = griddata((X_grid, Z_grid), DTW_grid, (Xi, Zi), method='cubic') # Convertir DTW en résistivité apparente (simulation) # Plus le DTW est élevé, plus l'eau est profonde, donc moins conductrice # Résistivité ~ proportionnelle au DTW (valeurs indicatives) rho_apparent = np.where(DTWi < 5, 2, # Eau très peu profonde → salée (2 Ω·m) np.where(DTWi < 15, 8, # Eau peu profonde → saumâtre (8 Ω·m) np.where(DTWi < 30, 40, # Eau moyenne profondeur → douce (40 Ω·m) np.where(DTWi < 50, 150, # Eau profonde → pure (150 Ω·m) 500)))) # Très profond → roche sèche (500 Ω·m) # Créer la figure avec colormap personnalisée pour l'eau fig_water, ax_water = plt.subplots(figsize=(14, 7), dpi=150) # Utiliser la colormap personnalisée basée sur les types d'eau # Rouge/Orange: eau mer/salée, Jaune: salée nappe, Vert/Bleu clair: douce, Bleu foncé: très pure pcm = ax_water.pcolormesh(Xi, Zi, rho_apparent, cmap=WATER_CMAP, norm=LogNorm(vmin=0.1, vmax=1000), shading='auto') # Ajouter les points de mesure scatter = ax_water.scatter(X_grid, Z_grid, c=DTW_grid, cmap='coolwarm', s=80, edgecolors='black', linewidths=1, alpha=0.8, zorder=10, marker='o') # Colorbar pour la résistivité cbar = fig_water.colorbar(pcm, ax=ax_water, label='Résistivité apparente (Ω·m)', extend='both') ax_water.invert_yaxis() ax_water.set_xlabel('Point de sondage (Survey Point)', fontsize=11) ax_water.set_ylabel(f'Profondeur ({unit})', fontsize=11) ax_water.set_title('Coupe géologique - Distribution des niveaux d\'eau et résistivité', fontsize=13, fontweight='bold') ax_water.grid(True, alpha=0.3, linestyle='--', color='white', linewidth=0.5) plt.tight_layout() st.pyplot(fig_water, clear_figure=False, use_container_width=True) # Sauvegarder pour PDF figures_dict['water_level_section'] = fig_water # Générer légende et explication dynamiques avec le LLM st.markdown("### 📝 Interprétation Automatique (LLM)") # Utiliser le LLM seulement si mode activé llm = None if st.session_state.get('enable_llm', True) and st.session_state.get('llm_loaded', False): llm = st.session_state.get('llm_pipeline', None) if llm is not None: with st.spinner("🧠 Génération de l'interprétation avec le LLM..."): legend_dynamic, explanation_dynamic = generate_dynamic_legend_and_explanation( llm, df, df['data'].min(), df['data'].max(), section_type="general" ) st.markdown(f""" **Légende générée automatiquement :** {legend_dynamic} **Interprétation géologique :** {explanation_dynamic} **Points de mesure** : {len(df)} données réelles du fichier .dat """) else: # Fallback si LLM non disponible st.markdown(f""" **Interprétation basique (LLM non disponible) :** - Résistivité mesurée : {df['data'].min():.1f} - {df['data'].max():.1f} Ω·m - Moyenne : {df['data'].mean():.1f} Ω·m - {len(df)} points de mesure - Profondeur : {df['depth'].abs().min():.1f} - {df['depth'].abs().max():.1f} m """) else: st.warning("⚠️ Pas assez de points de mesure pour créer une coupe 2D (minimum 2 points de sondage et 2 profondeurs)") # Coupes détaillées par type d'eau avec mesures réelles st.markdown("---") st.subheader("📊 Coupes détaillées par type d'eau - Mesures de résistivité réelles") # Afficher le tableau de référence st.markdown(""" ### 📋 Tableau de référence - Valeurs typiques pour l'eau """) water_reference = pd.DataFrame({ 'Type d\'eau': ['Eau de mer', 'Eau salée (nappe)', 'Eau douce', 'Eau très pure'], 'Résistivité (Ω.m)': ['0.1 - 1', '1 - 10', '10 - 100', '> 100'], 'Couleur associée': ['🔴 Rouge vif / Orange', '🟡 Jaune / Orange', '🟢 Vert / Bleu clair', '🔵 Bleu foncé'] }) st.dataframe(water_reference, use_container_width=True, hide_index=True) # Afficher une barre de couleur de la colormap personnalisée st.markdown("#### 🎨 Échelle de couleurs - Résistivité des eaux") try: fig_cbar, ax_cbar = plt.subplots(figsize=(12, 1.5), dpi=100) # Créer un gradient pour montrer la colormap resistivity_values = np.logspace(-1, 3, 256).reshape(1, -1) # 0.1 à 1000 Ω·m im_cbar = ax_cbar.imshow(resistivity_values, cmap=WATER_CMAP, aspect='auto', norm=LogNorm(vmin=0.1, vmax=1000)) # Configuration de l'affichage ax_cbar.set_yticks([]) ax_cbar.set_xlabel('Résistivité (Ω·m)', fontsize=11, fontweight='bold') # Ajouter des marqueurs pour les transitions transitions = [0.1, 1, 10, 100, 1000] trans_labels = ['0.1', '1\n(Eau mer)', '10\n(Eau salée)', '100\n(Eau douce)', '1000\n(Eau pure)'] trans_positions = [np.log10(t) - np.log10(0.1) for t in transitions] trans_positions_norm = [p / (np.log10(1000) - np.log10(0.1)) * 255 for p in trans_positions] ax_cbar.set_xticks(trans_positions_norm) ax_cbar.set_xticklabels(trans_labels, fontsize=9) ax_cbar.set_xlim(0, 255) # Ajouter des lignes verticales pour les transitions for pos in trans_positions_norm[1:-1]: ax_cbar.axvline(pos, color='white', linewidth=2, linestyle='--', alpha=0.8) plt.tight_layout() st.pyplot(fig_cbar, clear_figure=False, use_container_width=True) plt.close(fig_cbar) except Exception as e: st.warning(f"⚠️ Erreur affichage échelle couleurs : {str(e)[:100]}") # Coupe 1: Zone Eau de Mer (0.1 - 1 Ω·m) with st.expander("🔴 Coupe 1 - Zone d'eau de mer (0.1 - 1 Ω·m)", expanded=False): # Filtrer les données correspondant à cette plage seawater_mask = (df['data'] <= 1.0) if seawater_mask.sum() > 0: df_sea = df[seawater_mask] fig_sea, ax_sea = plt.subplots(figsize=(14, 6), dpi=150) # Créer des données synthétiques représentatives x_sea = np.linspace(0, 200, 100) z_sea = np.linspace(0, 30, 60) X_sea, Z_sea = np.meshgrid(x_sea, z_sea) # Résistivité pour eau de mer (0.1-1 Ω·m) - Couleur Rouge vif/Orange rho_sea = np.ones_like(X_sea) * 0.5 + np.random.rand(*X_sea.shape) * 0.4 pcm_sea = ax_sea.pcolormesh(X_sea, Z_sea, rho_sea, cmap=WATER_CMAP, norm=LogNorm(vmin=0.1, vmax=1.0), shading='auto') # Ajouter les mesures réelles si disponibles if len(df_sea) > 0: ax_sea.scatter(df_sea['survey_point'], df_sea['depth'], c='darkred', s=100, edgecolors='black', linewidths=2, marker='s', zorder=10, label=f'Mesures réelles ({len(df_sea)} points)') fig_sea.colorbar(pcm_sea, ax=ax_sea, label='Résistivité (Ω.m)') ax_sea.invert_yaxis() ax_sea.set_xlabel('Distance (m, précision: mm)', fontsize=11) ax_sea.set_ylabel('Profondeur (m, précision: mm)', fontsize=11) ax_sea.set_title('Zone d\'eau de mer - Résistivité 0.1-1 Ω·m (Précision mm)', fontsize=13, fontweight='bold') ax_sea.legend(loc='upper right') ax_sea.grid(True, alpha=0.3) # Définir ticks avec valeurs mesurées if len(df_sea) > 0: unique_depths_sea = np.unique(np.abs(df_sea['depth'].values)) unique_dist_sea = np.unique(df_sea['survey_point'].values) if len(unique_depths_sea) > 20: ax_sea.set_yticks(unique_depths_sea[::len(unique_depths_sea)//20]) else: ax_sea.set_yticks(unique_depths_sea) if len(unique_dist_sea) > 20: ax_sea.set_xticks(unique_dist_sea[::len(unique_dist_sea)//20]) else: ax_sea.set_xticks(unique_dist_sea) # Format des axes avec 3 décimales ax_sea.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'{x:.3f}')) ax_sea.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'{x:.3f}')) plt.tight_layout() st.pyplot(fig_sea, clear_figure=False, use_container_width=True) figures_dict['seawater_section'] = fig_sea # Générer explication dynamique avec le LLM (seulement si activé) if st.session_state.get('enable_llm', True) and st.session_state.get('llm_loaded', False): llm = st.session_state.get('llm_pipeline', None) if llm is not None: legend_sea, explanation_sea = generate_dynamic_legend_and_explanation( llm, df_sea, df_sea['data'].min(), df_sea['data'].max(), section_type="seawater" ) st.markdown(f""" **Analyse automatique (LLM) - Zone eau de mer :** **Légende :** {legend_sea} **Interprétation :** {explanation_sea} """) else: st.info("💡 LLM non disponible - Analyses basiques uniquement") else: st.markdown(f""" **Caractéristiques mesurées :** - **Résistivité** : {df_sea['data'].min():.2f} - {df_sea['data'].max():.2f} Ω·m (moy: {df_sea['data'].mean():.2f}) - **Nombre de mesures** : {len(df_sea)} points - **Profondeur** : {df_sea['depth'].abs().min():.1f} - {df_sea['depth'].abs().max():.1f} m - **Zone** : Eau océanique fortement salée """) else: st.info("Aucune mesure dans cette plage de résistivité dans vos données") # Coupe 2: Zone Eau Salée Nappe (1 - 10 Ω·m) with st.expander("🟡 Coupe 2 - Nappe d'eau salée (1 - 10 Ω·m)", expanded=False): saline_mask = (df['data'] > 1.0) & (df['data'] <= 10.0) if saline_mask.sum() > 0: df_saline = df[saline_mask] fig_saline, ax_saline = plt.subplots(figsize=(14, 6), dpi=150) x_sal = np.linspace(0, 250, 120) z_sal = np.linspace(0, 40, 70) X_sal, Z_sal = np.meshgrid(x_sal, z_sal) # Gradient de résistivité pour nappe salée rho_sal = 3 + np.random.rand(*X_sal.shape) * 5 + Z_sal * 0.05 rho_sal = np.clip(rho_sal, 1, 10) # Eau salée (1-10 Ω·m) - Couleur Jaune/Orange pcm_sal = ax_saline.pcolormesh(X_sal, Z_sal, rho_sal, cmap=WATER_CMAP, norm=LogNorm(vmin=1, vmax=10), shading='auto') if len(df_saline) > 0: ax_saline.scatter(df_saline['survey_point'], df_saline['depth'], c='orange', s=100, edgecolors='black', linewidths=2, marker='o', zorder=10, label=f'Mesures réelles ({len(df_saline)} points)') fig_saline.colorbar(pcm_sal, ax=ax_saline, label='Résistivité (Ω.m)') ax_saline.invert_yaxis() ax_saline.set_xlabel('Distance (m, précision: mm)', fontsize=11) ax_saline.set_ylabel('Profondeur (m, précision: mm)', fontsize=11) ax_saline.set_title('Nappe phréatique salée - Résistivité 1-10 Ω·m (Précision mm)', fontsize=13, fontweight='bold') ax_saline.legend(loc='upper right') ax_saline.grid(True, alpha=0.3) # Définir ticks avec valeurs mesurées if len(df_saline) > 0: unique_depths_sal = np.unique(np.abs(df_saline['depth'].values)) unique_dist_sal = np.unique(df_saline['survey_point'].values) if len(unique_depths_sal) > 20: ax_saline.set_yticks(unique_depths_sal[::len(unique_depths_sal)//20]) else: ax_saline.set_yticks(unique_depths_sal) if len(unique_dist_sal) > 20: ax_saline.set_xticks(unique_dist_sal[::len(unique_dist_sal)//20]) else: ax_saline.set_xticks(unique_dist_sal) # Format des axes avec 3 décimales ax_saline.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'{x:.3f}')) ax_saline.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'{x:.3f}')) plt.tight_layout() st.pyplot(fig_saline, clear_figure=False, use_container_width=True) figures_dict['saline_section'] = fig_saline # Générer explication dynamique avec le LLM (si activé) llm = None if st.session_state.get('enable_llm', True) and st.session_state.get('llm_loaded', False): llm = st.session_state.get('llm_pipeline', None) if llm is not None: legend_saline, explanation_saline = generate_dynamic_legend_and_explanation( llm, df_saline, df_saline['data'].min(), df_saline['data'].max(), section_type="saline" ) st.markdown(f""" **Analyse automatique (LLM) - Nappe d'eau salée :** **Légende :** {legend_saline} **Interprétation :** {explanation_saline} """) else: st.markdown(f""" **Caractéristiques mesurées :** - **Résistivité** : {df_saline['data'].min():.2f} - {df_saline['data'].max():.2f} Ω·m (moy: {df_saline['data'].mean():.2f}) - **Nombre de mesures** : {len(df_saline)} points - **Profondeur** : {df_saline['depth'].abs().min():.1f} - {df_saline['depth'].abs().max():.1f} m - **Zone** : Eau saumâtre dans nappe phréatique """) else: st.info("Aucune mesure dans cette plage de résistivité dans vos données") # Coupe 3: Zone Eau Douce (10 - 100 Ω·m) with st.expander("🟢 Coupe 3 - Aquifère d'eau douce (10 - 100 Ω·m)", expanded=False): fresh_mask = (df['data'] > 10.0) & (df['data'] <= 100.0) if fresh_mask.sum() > 0: df_fresh = df[fresh_mask] fig_fresh, ax_fresh = plt.subplots(figsize=(14, 6), dpi=150) x_fresh = np.linspace(0, 300, 140) z_fresh = np.linspace(0, 50, 80) X_fresh, Z_fresh = np.meshgrid(x_fresh, z_fresh) # Résistivité pour eau douce (10-100 Ω·m) - Couleur Vert/Bleu clair rho_fresh = 30 + np.random.rand(*X_fresh.shape) * 50 + Z_fresh * 0.3 rho_fresh = np.clip(rho_fresh, 10, 100) pcm_fresh = ax_fresh.pcolormesh(X_fresh, Z_fresh, rho_fresh, cmap=WATER_CMAP, norm=LogNorm(vmin=10, vmax=100), shading='auto') if len(df_fresh) > 0: ax_fresh.scatter(df_fresh['survey_point'], df_fresh['depth'], c='green', s=100, edgecolors='black', linewidths=2, marker='D', zorder=10, label=f'Mesures réelles ({len(df_fresh)} points)') fig_fresh.colorbar(pcm_fresh, ax=ax_fresh, label='Résistivité (Ω.m)') ax_fresh.invert_yaxis() ax_fresh.set_xlabel('Distance (m, précision: mm)', fontsize=11) ax_fresh.set_ylabel('Profondeur (m, précision: mm)', fontsize=11) ax_fresh.set_title('Aquifère d\'eau douce - Résistivité 10-100 Ω·m (Précision mm)', fontsize=13, fontweight='bold') ax_fresh.legend(loc='upper right') ax_fresh.grid(True, alpha=0.3) # Définir ticks avec valeurs mesurées if len(df_fresh) > 0: unique_depths_fresh = np.unique(np.abs(df_fresh['depth'].values)) unique_dist_fresh = np.unique(df_fresh['survey_point'].values) if len(unique_depths_fresh) > 20: ax_fresh.set_yticks(unique_depths_fresh[::len(unique_depths_fresh)//20]) else: ax_fresh.set_yticks(unique_depths_fresh) if len(unique_dist_fresh) > 20: ax_fresh.set_xticks(unique_dist_fresh[::len(unique_dist_fresh)//20]) else: ax_fresh.set_xticks(unique_dist_fresh) # Format des axes avec 3 décimales ax_fresh.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'{x:.3f}')) ax_fresh.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'{x:.3f}')) plt.tight_layout() st.pyplot(fig_fresh, clear_figure=False, use_container_width=True) figures_dict['freshwater_section'] = fig_fresh # Générer explication dynamique avec le LLM (si activé) llm = None if st.session_state.get('enable_llm', True) and st.session_state.get('llm_loaded', False): llm = st.session_state.get('llm_pipeline', None) if llm is not None: legend_fresh, explanation_fresh = generate_dynamic_legend_and_explanation( llm, df_fresh, df_fresh['data'].min(), df_fresh['data'].max(), section_type="freshwater" ) st.markdown(f""" **Analyse automatique (LLM) - Aquifère d'eau douce :** **Légende :** {legend_fresh} **Interprétation :** {explanation_fresh} """) else: st.markdown(f""" **Caractéristiques mesurées :** - **Résistivité** : {df_fresh['data'].min():.2f} - {df_fresh['data'].max():.2f} Ω·m (moy: {df_fresh['data'].mean():.2f}) - **Nombre de mesures** : {len(df_fresh)} points - **Profondeur** : {df_fresh['depth'].abs().min():.1f} - {df_fresh['depth'].abs().max():.1f} m - **Zone** : Eau douce continentale """) else: st.info("Aucune mesure dans cette plage de résistivité dans vos données") # Coupe 4: Zone Eau Très Pure (> 100 Ω·m) with st.expander("🔵 Coupe 4 - Eau très pure / Roche sèche (> 100 Ω·m)", expanded=False): pure_mask = (df['data'] > 100.0) if pure_mask.sum() > 0: df_pure = df[pure_mask] fig_pure, ax_pure = plt.subplots(figsize=(14, 6), dpi=150) x_pure = np.linspace(0, 200, 100) z_pure = np.linspace(0, 60, 90) X_pure, Z_pure = np.meshgrid(x_pure, z_pure) # Résistivité pour eau très pure/roche (>100 Ω·m) - Couleur Bleu foncé rho_pure = 200 + np.random.rand(*X_pure.shape) * 300 + Z_pure * 2 rho_pure = np.clip(rho_pure, 100, 1000) pcm_pure = ax_pure.pcolormesh(X_pure, Z_pure, rho_pure, cmap=WATER_CMAP, shading='auto', norm=LogNorm(vmin=100, vmax=1000)) if len(df_pure) > 0: ax_pure.scatter(df_pure['survey_point'], df_pure['depth'], c='darkblue', s=100, edgecolors='black', linewidths=2, marker='^', zorder=10, label=f'Mesures réelles ({len(df_pure)} points)') fig_pure.colorbar(pcm_pure, ax=ax_pure, label='Résistivité (Ω.m)') ax_pure.invert_yaxis() ax_pure.set_xlabel('Distance (m, précision: mm)', fontsize=11) ax_pure.set_ylabel('Profondeur (m, précision: mm)', fontsize=11) ax_pure.set_title('Eau très pure / Roche résistive - Résistivité > 100 Ω·m (Précision mm)', fontsize=13, fontweight='bold') ax_pure.legend(loc='upper right') ax_pure.grid(True, alpha=0.3) # Définir ticks avec valeurs mesurées if len(df_pure) > 0: unique_depths_pure = np.unique(np.abs(df_pure['depth'].values)) unique_dist_pure = np.unique(df_pure['survey_point'].values) if len(unique_depths_pure) > 20: ax_pure.set_yticks(unique_depths_pure[::len(unique_depths_pure)//20]) else: ax_pure.set_yticks(unique_depths_pure) if len(unique_dist_pure) > 20: ax_pure.set_xticks(unique_dist_pure[::len(unique_dist_pure)//20]) else: ax_pure.set_xticks(unique_dist_pure) # Format des axes avec 3 décimales ax_pure.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'{x:.3f}')) ax_pure.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'{x:.3f}')) plt.tight_layout() st.pyplot(fig_pure, clear_figure=False, use_container_width=True) figures_dict['purewater_section'] = fig_pure # Générer explication dynamique avec le LLM (si activé) llm = None if st.session_state.get('enable_llm', True) and st.session_state.get('llm_loaded', False): llm = st.session_state.get('llm_pipeline', None) if llm is not None: legend_pure, explanation_pure = generate_dynamic_legend_and_explanation( llm, df_pure, df_pure['data'].min(), df_pure['data'].max(), section_type="pure" ) st.markdown(f""" **Analyse automatique (LLM) - Eau très pure / Roche sèche :** **Légende :** {legend_pure} **Interprétation :** {explanation_pure} """) else: st.markdown(f""" **Caractéristiques mesurées :** - **Résistivité** : {df_pure['data'].min():.2f} - {df_pure['data'].max():.2f} Ω·m (moy: {df_pure['data'].mean():.2f}) - **Nombre de mesures** : {len(df_pure)} points - **Profondeur** : {df_pure['depth'].abs().min():.1f} - {df_pure['depth'].abs().max():.1f} m - **Zone** : Eau très pure ou formation rocheuse résistive """) else: st.info("Aucune mesure dans cette plage de résistivité dans vos données") # ========== COUPE 5 - PSEUDO-SECTION RÉELLE (FORMAT CLASSIQUE) ========== with st.expander("📊 Coupe 5 - Pseudo-Section de Résistivité Apparente (Format Classique)", expanded=True): st.markdown(""" **Carte de pseudo-section au format géophysique standard** Cette représentation respecte le format classique des prospections ERT avec : - 🎨 Échelle de couleurs rainbow continue (bleu → vert → jaune → orange → rouge) - 📏 Axes en mètres avec positions réelles des électrodes - 🌡️ Barre de couleur graduée montrant les résistivités mesurées - 🗺️ Visualisation directe des résistivités apparentes du sous-sol """) # Créer la figure au format classique fig_pseudo, ax_pseudo = plt.subplots(figsize=(16, 8), dpi=150) # Utiliser les VRAIES valeurs mesurées X_real = df['survey_point'].values Z_real = np.abs(df['depth'].values) Rho_real = df['data'].values # Créer une grille fine pour la visualisation from scipy.interpolate import griddata xi_pseudo = np.linspace(X_real.min(), X_real.max(), 500) zi_pseudo = np.linspace(Z_real.min(), Z_real.max(), 300) Xi_pseudo, Zi_pseudo = np.meshgrid(xi_pseudo, zi_pseudo) # Interpolation linear pour un rendu lisse mais fidèle Rhoi_pseudo = griddata( (X_real, Z_real), Rho_real, (Xi_pseudo, Zi_pseudo), method='linear', fill_value=np.median(Rho_real) ) # Utiliser la colormap rainbow classique from matplotlib.colors import LogNorm # Définir les limites de résistivité (échelle logarithmique) vmin_pseudo = max(0.1, Rho_real.min()) vmax_pseudo = Rho_real.max() # Créer la pseudo-section avec colormap eau personnalisée pcm_pseudo = ax_pseudo.contourf( Xi_pseudo, Zi_pseudo, Rhoi_pseudo, levels=50, cmap=WATER_CMAP, # Colormap eau personnalisée norm=LogNorm(vmin=vmin_pseudo, vmax=vmax_pseudo), extend='both' ) # Ajouter les contours contours = ax_pseudo.contour( Xi_pseudo, Zi_pseudo, Rhoi_pseudo, levels=10, colors='black', linewidths=0.5, alpha=0.3 ) # Superposer les points de mesure scatter_real = ax_pseudo.scatter( X_real, Z_real, c='white', s=20, edgecolors='black', linewidths=0.5, alpha=0.7, zorder=5, label='Points de mesure' ) # Barre de couleur cbar_pseudo = plt.colorbar(pcm_pseudo, ax=ax_pseudo, pad=0.02, aspect=30) cbar_pseudo.set_label('Résistivité Apparente (Ω·m)', fontsize=12, fontweight='bold') cbar_pseudo.ax.tick_params(labelsize=10) # Configuration des axes ax_pseudo.set_xlabel('Position (m)', fontsize=12, fontweight='bold') ax_pseudo.set_ylabel('Profondeur (m)', fontsize=12, fontweight='bold') ax_pseudo.set_title( 'Pseudo-Section de Résistivité Apparente\nMeasured Apparent Resistivity Pseudosection', fontsize=14, fontweight='bold' ) ax_pseudo.invert_yaxis() ax_pseudo.grid(True, alpha=0.2, linestyle='--', linewidth=0.5) ax_pseudo.legend(loc='upper right', fontsize=10, framealpha=0.9) plt.tight_layout() st.pyplot(fig_pseudo, clear_figure=False, use_container_width=True) plt.close() # Statistiques col1_ps, col2_ps, col3_ps = st.columns(3) with col1_ps: st.metric("📏 Points de mesure", f"{len(Rho_real)}") with col2_ps: st.metric("📊 Plage de résistivité", f"{vmin_pseudo:.1f} - {vmax_pseudo:.1f} Ω·m") with col3_ps: st.metric("🎯 Résistivité médiane", f"{np.median(Rho_real):.2f} Ω·m") # Interprétation dynamique avec le LLM st.markdown("### 📖 Interprétation Automatique (LLM)") llm = st.session_state.get('llm_pipeline', None) if llm is not None: with st.spinner("🧠 Génération de l'interprétation..."): data_stats_pseudo = f""" - Points de mesure: {len(Rho_real)} - Résistivité min: {vmin_pseudo:.2f} Ω·m - Résistivité max: {vmax_pseudo:.2f} Ω·m - Résistivité médiane: {np.median(Rho_real):.2f} Ω·m - Résistivité moyenne: {np.mean(Rho_real):.2f} Ω·m - Écart-type: {np.std(Rho_real):.2f} Ω·m - Profondeur min: {Z_real.min():.2f} m - Profondeur max: {Z_real.max():.2f} m """ # Bouton pour générer l'explication LLM if st.button("🧠 Générer l'interprétation LLM de la pseudo-section", key="llm_pseudo_btn"): with st.spinner("Génération de l'interprétation..."): interpretation_pseudo = generate_graph_explanation_with_llm( llm, "pseudo_section", data_stats_pseudo, context="Pseudo-section de résistivité apparente en format géophysique classique" ) st.info(interpretation_pseudo) else: st.warning("⚠️ LLM non chargé. Cliquez sur '🚀 Charger le LLM Mistral' dans la sidebar.") # Fallback avec vraies valeurs st.markdown(f""" **Interprétation basée sur les données mesurées :** **Statistiques :** - {len(Rho_real)} points de mesure - Résistivité : {vmin_pseudo:.1f} à {vmax_pseudo:.1f} Ω·m (médiane: {np.median(Rho_real):.2f}) - Profondeur : {Z_real.min():.2f} à {Z_real.max():.2f} m **Échelle de couleurs observée (rainbow) :** Les couleurs représentent les résistivités réellement mesurées dans votre fichier .dat, du bleu (faible résistivité) au rouge (forte résistivité). """) # Export st.subheader("💾 Exporter les résultats") col1, col2, col3 = st.columns(3) with col1: csv = df.to_csv(index=False).encode('utf-8') st.download_button("📥 CSV", csv, "analysis.csv", "text/csv", key='download_csv') with col2: # Créer Excel uniquement à la demande (lazy loading) if st.button("� Préparer Excel", key='prepare_excel'): buffer = io.BytesIO() with pd.ExcelWriter(buffer, engine='openpyxl') as writer: df.to_excel(writer, index=False, sheet_name='Data') st.session_state['excel_buffer'] = buffer.getvalue() st.success("✅ Excel prêt !") if 'excel_buffer' in st.session_state: st.download_button("📥 Excel", st.session_state['excel_buffer'], "analysis.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", key='download_excel') with col3: # Générer PDF avec tous les graphiques et tableaux if st.button("📄 Générer Rapport PDF", key='generate_pdf'): with st.spinner('Génération du PDF en cours...'): # Fusionner les figures du session_state (coupes géologiques) if 'figures_dict' in st.session_state: figures_dict.update(st.session_state['figures_dict']) # DEBUG: Afficher le contenu de figures_dict st.write(f"DEBUG: figures_dict contient {len(figures_dict)} figures") for fig_name, fig in figures_dict.items(): st.write(f"DEBUG: Figure '{fig_name}': {'Valide' if fig is not None and plt.fignum_exists(fig.number) else 'Invalide/None'}") pdf_bytes = create_pdf_report(df, unit, figures_dict) st.session_state['pdf_buffer'] = pdf_bytes st.success("✅ PDF prêt !") # Nettoyer les figures après génération du PDF pour éviter les fuites mémoire for fig_name, fig in figures_dict.items(): if fig is not None and plt.fignum_exists(fig.number): plt.close(fig) figures_dict.clear() if 'pdf_buffer' in st.session_state: st.download_button( "📥 PDF Complet", st.session_state['pdf_buffer'], f"rapport_ert_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf", "application/pdf", key='download_pdf' ) # ===================== TAB 3 : ERT PSEUDO-SECTIONS 2D/3D ===================== with tab3: st.header("4 Interprétation des pseudo-sections et modèles de résistivité (FicheERT.pdf)") st.subheader("4.1 Définition d'une pseudo-section") st.markdown(""" La première étape dans l'interprétation des données en tomographie électrique consiste à construire une **pseudo-section**. Une pseudo-section est une carte de résultat qui présente les valeurs des résistivités apparentes calculées à partir de la différence de potentiel mesurée aux bornes de deux électrodes de mesure ainsi que de la valeur du courant injecté entre les deux électrodes d'injection. La couleur d'un point sur la pseudo-section représente donc la valeur de la résistivité apparente en ce point. """) # Vérifier si des données ont été chargées dans l'onglet 2 if st.session_state.get('uploaded_data') is not None: df = st.session_state['uploaded_data'] unit = st.session_state.get('unit', 'm') st.success(f"✅ Utilisation des données du fichier uploadé : {len(df)} mesures") st.markdown("**Pseudo-sections générées à partir de vos données réelles**") # Cache de la préparation des données 2D @st.cache_data def prepare_2d_data(data_hash): """Prépare les données pour visualisation 2D avec cache""" survey_points = sorted(df['survey_point'].unique()) depths = sorted(df['depth'].unique()) X_real = [] Z_real = [] Rho_real = [] for sp in survey_points: for depth in depths: subset = df[(df['survey_point'] == sp) & (df['depth'] == depth)] if len(subset) > 0: X_real.append(float(sp)) Z_real.append(abs(float(depth))) Rho_real.append(float(subset['data'].values[0])) return np.array(X_real), np.array(Z_real), np.array(Rho_real) # Cache de l'interpolation (très coûteuse) @st.cache_data def interpolate_grid(X, Z, Rho, data_hash): """Interpolation cubique avec cache""" from scipy.interpolate import griddata xi = np.linspace(X.min(), X.max(), 100) zi = np.linspace(Z.min(), Z.max(), 50) Xi, Zi = np.meshgrid(xi, zi) Rhoi = griddata((X, Z), Rho, (Xi, Zi), method='cubic') return Xi, Zi, Rhoi, xi, zi # Hash unique des données data_hash = hash(tuple(df[['survey_point', 'depth', 'data']].values.flatten())) st.subheader("📊 Pseudo-section 2D - Données réelles du fichier .dat") # Dictionnaire pour stocker les figures du Tab 3 figures_tab3 = {} # Préparer les données (avec cache) X_real, Z_real, Rho_real = prepare_2d_data(data_hash) # Interpoler (avec cache) Xi, Zi, Rhoi, xi, zi = interpolate_grid(X_real, Z_real, Rho_real, data_hash) # Pseudo-section 2D avec données réelles (haute résolution pour PDF) fig_real, ax = plt.subplots(figsize=(14, 7), dpi=150) # Utiliser colormap personnalisée pour les types d'eau (Rouge: mer/salée → Bleu: pure) vmin, vmax = max(0.1, Rho_real.min()), Rho_real.max() pcm = ax.pcolormesh(Xi, Zi, Rhoi, cmap=WATER_CMAP, shading='auto', norm=LogNorm(vmin=vmin, vmax=vmax)) # Ajouter les points de mesure réels scatter = ax.scatter(X_real, Z_real, c=Rho_real, cmap=WATER_CMAP, s=50, edgecolors='black', linewidths=0.5, norm=LogNorm(vmin=vmin, vmax=vmax), zorder=10) fig_real.colorbar(pcm, ax=ax, label=f'Niveau d\'eau DTW ({unit})', extend='both') ax.invert_yaxis() ax.set_xlabel('Point de sondage (Survey Point)', fontsize=11) ax.set_ylabel(f'Profondeur totale ({unit})', fontsize=11) ax.set_title(f'Pseudo-section 2D - Données réelles ({len(df)} mesures)', fontsize=13, fontweight='bold') ax.grid(True, alpha=0.3, linestyle='--') plt.tight_layout() st.pyplot(fig_real, clear_figure=False, use_container_width=True) # Sauvegarder pour PDF figures_tab3['pseudo_section_2d'] = fig_real # Légende des couleurs basée sur les valeurs réelles st.markdown(f""" **Interprétation des couleurs (basée sur vos données) :** - Valeur minimale : **{vmin:.2f} {unit}** (niveau d'eau le plus bas) → couleur bleue - Valeur moyenne : **{Rho_real.mean():.2f} {unit}** → couleur intermédiaire - Valeur maximale : **{vmax:.2f} {unit}** (niveau d'eau le plus haut) → couleur rouge Les zones rouges indiquent des niveaux d'eau plus élevés (DTW plus grand). Les zones bleues indiquent des niveaux d'eau plus bas (nappe plus proche de la surface). """) # Vue 3D des données réelles survey_points = sorted(df['survey_point'].unique()) depths = sorted(df['depth'].unique()) if len(survey_points) > 2 and len(depths) > 2: st.subheader("🌐 Modèle 3D - Volume d'eau (données réelles)") fig3d_real = go.Figure(data=go.Scatter3d( x=X_real, y=np.zeros_like(X_real), # Y=0 pour profil 2D z=-Z_real, # Négatif pour afficher en profondeur mode='markers', marker=dict( size=8, color=Rho_real, colorscale='Jet', showscale=True, colorbar=dict(title=f'DTW ({unit})'), line=dict(width=0.5, color='black') ), text=[f'SP: {int(X_real[i])}
Depth: {Z_real[i]:.1f}{unit}
DTW: {Rho_real[i]:.2f}{unit}' for i in range(len(X_real))], hoverinfo='text' )) fig3d_real.update_layout( scene=dict( xaxis_title='Point de sondage', yaxis_title='Transect (m)', zaxis_title=f'Profondeur ({unit})', aspectmode='data' ), title='Visualisation 3D des mesures de niveau d\'eau', height=600 ) st.plotly_chart(fig3d_real, use_container_width=True) # Statistiques par profondeur st.subheader("📈 Analyse par profondeur") # Cache du calcul statistique @st.cache_data def compute_depth_stats(data_hash): """Calcul des statistiques par profondeur avec cache""" depth_stats = df.groupby('depth')['data'].agg(['mean', 'min', 'max', 'std']).round(2) depth_stats.columns = ['Moyenne DTW', 'Min DTW', 'Max DTW', 'Écart-type'] return depth_stats depth_stats = compute_depth_stats(data_hash) st.dataframe(depth_stats.style.background_gradient(cmap='RdYlBu_r', axis=0), use_container_width=True) # Coupes comparatives avec mesures réelles incrustées st.markdown("---") st.subheader("🎯 Coupes comparatives - Mesures réelles vs Modèles théoriques") # Coupe comparative 1: Intrusion saline with st.expander("🌊 Coupe comparative 1 - Intrusion saline côtière avec mesures", expanded=False): fig_comp1, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 6), dpi=150) # Modèle théorique x_model = np.linspace(0, 300, 150) z_model = np.linspace(0, 40, 80) X_model, Z_model = np.meshgrid(x_model, z_model) # Gradient d'intrusion saline (mer vers terre) rho_model = np.ones_like(X_model) * 0.5 # Eau de mer rho_model[Z_model > 10 + 0.05 * X_model] = 3 # Eau salée nappe rho_model[Z_model > 25] = 50 # Eau douce profonde rho_model *= (1 + np.random.randn(*rho_model.shape) * 0.1) rho_model = np.clip(rho_model, 0.1, 100) # Graphique modèle avec colormap eau personnalisée pcm1 = ax1.pcolormesh(X_model, Z_model, rho_model, cmap=WATER_CMAP, norm=LogNorm(vmin=0.1, vmax=100), shading='auto') ax1.invert_yaxis() ax1.set_title('Modèle théorique - Intrusion saline (Précision mm)', fontsize=12, fontweight='bold') ax1.set_xlabel('Distance depuis la côte (m, précision: mm)') ax1.set_ylabel('Profondeur (m, précision: mm)') # Format des axes avec 3 décimales ax1.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'{x:.3f}')) ax1.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'{x:.3f}')) fig_comp1.colorbar(pcm1, ax=ax1, label='Résistivité (Ω.m)') # Annoter les zones ax1.text(50, 5, 'Eau de mer\n0.1-1 Ω·m', bbox=dict(boxstyle='round', facecolor='red', alpha=0.7), fontsize=9, ha='center', color='white', fontweight='bold') ax1.text(150, 18, 'Eau salée\n1-10 Ω·m', bbox=dict(boxstyle='round', facecolor='orange', alpha=0.7), fontsize=9, ha='center', fontweight='bold') ax1.text(250, 32, 'Eau douce\n10-100 Ω·m', bbox=dict(boxstyle='round', facecolor='yellow', alpha=0.7), fontsize=9, ha='center', fontweight='bold') # Données réelles if len(df) > 0: # Interpoler les données réelles - Conversion explicite en float X_real_data = pd.to_numeric(df['survey_point'], errors='coerce').values Z_real_data = np.abs(pd.to_numeric(df['depth'], errors='coerce').values) Rho_real_data = pd.to_numeric(df['data'], errors='coerce').values # Filtrer les valeurs NaN mask = ~(np.isnan(X_real_data) | np.isnan(Z_real_data) | np.isnan(Rho_real_data)) X_real_data = X_real_data[mask] Z_real_data = Z_real_data[mask] Rho_real_data = Rho_real_data[mask] # Créer une grille pour les données réelles from scipy.interpolate import griddata if len(X_real_data) > 0: xi_real = np.linspace(X_real_data.min(), X_real_data.max(), 100) zi_real = np.linspace(Z_real_data.min(), Z_real_data.max(), 60) Xi_real, Zi_real = np.meshgrid(xi_real, zi_real) Rhoi_real = griddata((X_real_data, Z_real_data), Rho_real_data, (Xi_real, Zi_real), method='cubic') # Données réelles avec colormap eau pcm2 = ax2.pcolormesh(Xi_real, Zi_real, Rhoi_real, cmap=WATER_CMAP, norm=LogNorm(vmin=max(0.1, Rho_real_data.min()), vmax=Rho_real_data.max()), shading='auto') ax2.scatter(X_real_data, Z_real_data, c='black', s=50, edgecolors='white', linewidths=1.5, marker='o', zorder=10, label=f'{len(X_real_data)} mesures') ax2.invert_yaxis() ax2.set_title(f'Données réelles - {len(X_real_data)} mesures (Précision mm)', fontsize=12, fontweight='bold') # Format des axes avec 3 décimales ax2.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'{x:.3f}')) ax2.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'{x:.3f}')) ax2.set_xlabel('Point de sondage (précision: mm)') ax2.set_ylabel('Profondeur (m, précision: mm)') ax2.legend(loc='upper right') fig_comp1.colorbar(pcm2, ax=ax2, label='Résistivité mesurée (Ω.m)') plt.tight_layout() st.pyplot(fig_comp1, clear_figure=False, use_container_width=True) figures_tab3['comparative_1'] = fig_comp1 st.markdown(""" **Analyse comparative :** - **Gauche** : Modèle théorique d'intrusion saline typique - **Droite** : Vos mesures réelles interpolées avec points de mesure (noirs) - Permet d'identifier les zones d'intrusion marine dans vos données """) # Coupe comparative 2: Aquifère multicouche with st.expander("🏔️ Coupe comparative 2 - Aquifère multicouche avec résistivités", expanded=False): fig_comp2, ax_multi = plt.subplots(figsize=(14, 7), dpi=150) # Créer un modèle multicouche x_multi = np.linspace(0, 250, 140) z_multi = np.linspace(0, 50, 90) X_multi, Z_multi = np.meshgrid(x_multi, z_multi) # Couches avec résistivités différentes rho_multi = np.ones_like(X_multi) * 200 # Sol sec surface rho_multi[(Z_multi > 8) & (Z_multi < 15)] = 60 # Aquifère peu profond (eau douce) rho_multi[(Z_multi >= 15) & (Z_multi < 25)] = 5 # Argile conductive rho_multi[(Z_multi >= 25) & (Z_multi < 40)] = 80 # Aquifère profond (eau douce) rho_multi[Z_multi >= 40] = 400 # Substrat rocheux # Ajouter du bruit rho_multi *= (1 + np.random.randn(*rho_multi.shape) * 0.08) rho_multi = np.clip(rho_multi, 1, 500) # Multi-fréquence avec colormap eau personnalisée pcm_multi = ax_multi.pcolormesh(X_multi, Z_multi, rho_multi, cmap=WATER_CMAP, norm=LogNorm(vmin=1, vmax=500), shading='auto') # Superposer les mesures réelles si disponibles if len(df) > 0: ax_multi.scatter(df['survey_point'], np.abs(df['depth']), c=df['data'], cmap=WATER_CMAP, s=120, edgecolors='black', linewidths=2, marker='s', norm=LogNorm(vmin=max(0.1, df['data'].min()), vmax=df['data'].max()), zorder=10, label='Mesures réelles') # Annoter quelques points avec leurs valeurs for i in range(min(5, len(df))): row = df.iloc[i] ax_multi.annotate(f'{row["data"]:.2f} Ω·m\n@{np.abs(row["depth"]):.3f}m', xy=(row['survey_point'], np.abs(row['depth'])), xytext=(10, 10), textcoords='offset points', fontsize=7, ha='left', bbox=dict(boxstyle='round,pad=0.3', facecolor='yellow', alpha=0.7), arrowprops=dict(arrowstyle='->', connectionstyle='arc3,rad=0')) fig_comp2.colorbar(pcm_multi, ax=ax_multi, label='Résistivité (Ω.m)') ax_multi.invert_yaxis() ax_multi.set_xlabel('Distance (m, précision: mm)', fontsize=11) ax_multi.set_ylabel('Profondeur (m, précision: mm)', fontsize=11) # Format des axes avec 3 décimales ax_multi.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'{x:.3f}')) ax_multi.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'{x:.3f}')) ax_multi.set_title('Modèle multicouche avec mesures réelles (Précision mm)', fontsize=13, fontweight='bold') if len(df) > 0: ax_multi.legend(loc='upper right') ax_multi.grid(True, alpha=0.2, color='white', linestyle='--') # Ajouter légende des couches ax_multi.text(0.02, 0.98, 'Couches géologiques:', transform=ax_multi.transAxes, fontsize=10, va='top', fontweight='bold', bbox=dict(boxstyle='round', facecolor='white', alpha=0.8)) ax_multi.text(0.02, 0.92, '• 0-8m: Sol sec (200 Ω·m)', transform=ax_multi.transAxes, fontsize=8, va='top') ax_multi.text(0.02, 0.88, '• 8-15m: Aquifère peu profond (60 Ω·m)', transform=ax_multi.transAxes, fontsize=8, va='top') ax_multi.text(0.02, 0.84, '• 15-25m: Argile conductive (5 Ω·m)', transform=ax_multi.transAxes, fontsize=8, va='top') ax_multi.text(0.02, 0.80, '• 25-40m: Aquifère profond (80 Ω·m)', transform=ax_multi.transAxes, fontsize=8, va='top') ax_multi.text(0.02, 0.76, '• >40m: Substrat rocheux (400 Ω·m)', transform=ax_multi.transAxes, fontsize=8, va='top') plt.tight_layout() st.pyplot(fig_comp2, clear_figure=False, use_container_width=True) figures_tab3['comparative_2'] = fig_comp2 st.markdown(""" **Interprétation multicouche :** - **Carrés noirs** : Vos mesures réelles avec annotations de valeurs - **Fond coloré** : Modèle théorique multicouche - Les zones bleues (haute résistivité) indiquent des formations sèches ou rocheuses - Les zones rouges/orange (faible résistivité) indiquent de l'argile ou de l'eau salée - Les zones vertes/jaunes (résistivité moyenne) indiquent des aquifères d'eau douce """) # Export PDF des pseudo-sections st.subheader("📄 Export PDF des Pseudo-sections") col_pdf1, col_pdf2 = st.columns([1, 2]) with col_pdf1: if st.button("📄 Générer PDF Pseudo-sections", key='generate_pdf_tab3'): with st.spinner('Génération du PDF des pseudo-sections...'): pdf_bytes = create_pdf_report(df, unit, figures_tab3) st.session_state['pdf_tab3_buffer'] = pdf_bytes st.success("✅ PDF pseudo-sections prêt !") with col_pdf2: if 'pdf_tab3_buffer' in st.session_state: st.download_button( "📥 Télécharger PDF Pseudo-sections", st.session_state['pdf_tab3_buffer'], f"pseudo_sections_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf", "application/pdf", key='download_pdf_tab3' ) # ========== COUPE SUPPLÉMENTAIRE - PSEUDO-SECTION RÉELLE (FORMAT CLASSIQUE) ========== st.markdown("---") with st.expander("📊 Pseudo-Section de Résistivité Apparente (Format Classique)", expanded=True): st.markdown(""" **Carte de pseudo-section au format géophysique standard** Cette représentation respecte le format classique des prospections ERT avec : - 🎨 Échelle de couleurs rainbow continue (bleu → vert → jaune → orange → rouge) - 📏 Axes en mètres avec positions réelles des électrodes - 🌡️ Barre de couleur graduée montrant les résistivités mesurées - 🗺️ Visualisation directe des résistivités apparentes du sous-sol """) # Créer la figure au format classique fig_pseudo_t3, ax_pseudo_t3 = plt.subplots(figsize=(16, 8), dpi=150) # Utiliser les VRAIES valeurs mesurées X_real_t3 = X_real Z_real_t3 = Z_real Rho_real_t3 = Rho_real # Créer une grille fine pour la visualisation xi_pseudo_t3 = np.linspace(X_real_t3.min(), X_real_t3.max(), 500) zi_pseudo_t3 = np.linspace(Z_real_t3.min(), Z_real_t3.max(), 300) Xi_pseudo_t3, Zi_pseudo_t3 = np.meshgrid(xi_pseudo_t3, zi_pseudo_t3) # Interpolation linear pour un rendu lisse mais fidèle Rhoi_pseudo_t3 = griddata( (X_real_t3, Z_real_t3), Rho_real_t3, (Xi_pseudo_t3, Zi_pseudo_t3), method='linear', fill_value=np.median(Rho_real_t3) ) # Utiliser la colormap rainbow classique from matplotlib.colors import LogNorm # Définir les limites de résistivité vmin_pseudo_t3 = max(0.1, Rho_real_t3.min()) vmax_pseudo_t3 = Rho_real_t3.max() # Créer la pseudo-section avec colormap eau personnalisée pcm_pseudo_t3 = ax_pseudo_t3.contourf( Xi_pseudo_t3, Zi_pseudo_t3, Rhoi_pseudo_t3, levels=50, cmap=WATER_CMAP, # Colormap eau personnalisée norm=LogNorm(vmin=vmin_pseudo_t3, vmax=vmax_pseudo_t3), extend='both' ) # Ajouter les contours contours_t3 = ax_pseudo_t3.contour( Xi_pseudo_t3, Zi_pseudo_t3, Rhoi_pseudo_t3, levels=10, colors='black', linewidths=0.5, alpha=0.3 ) # Superposer les points de mesure scatter_real_t3 = ax_pseudo_t3.scatter( X_real_t3, Z_real_t3, c='white', s=20, edgecolors='black', linewidths=0.5, alpha=0.7, zorder=5, label='Points de mesure' ) # Barre de couleur cbar_pseudo_t3 = plt.colorbar(pcm_pseudo_t3, ax=ax_pseudo_t3, pad=0.02, aspect=30) cbar_pseudo_t3.set_label('Résistivité Apparente (Ω·m)', fontsize=12, fontweight='bold') cbar_pseudo_t3.ax.tick_params(labelsize=10) # Configuration des axes ax_pseudo_t3.set_xlabel('Position (m)', fontsize=12, fontweight='bold') ax_pseudo_t3.set_ylabel('Profondeur (m)', fontsize=12, fontweight='bold') ax_pseudo_t3.set_title( 'Pseudo-Section de Résistivité Apparente\nMeasured Apparent Resistivity Pseudosection', fontsize=14, fontweight='bold' ) ax_pseudo_t3.invert_yaxis() ax_pseudo_t3.grid(True, alpha=0.2, linestyle='--', linewidth=0.5) ax_pseudo_t3.legend(loc='upper right', fontsize=10, framealpha=0.9) plt.tight_layout() st.pyplot(fig_pseudo_t3, clear_figure=False, use_container_width=True) plt.close() # Statistiques col1_ps_t3, col2_ps_t3, col3_ps_t3 = st.columns(3) with col1_ps_t3: st.metric("📏 Points de mesure", f"{len(Rho_real_t3)}") with col2_ps_t3: st.metric("📊 Plage de résistivité", f"{vmin_pseudo_t3:.1f} - {vmax_pseudo_t3:.1f} Ω·m") with col3_ps_t3: st.metric("🎯 Résistivité médiane", f"{np.median(Rho_real_t3):.2f} Ω·m") st.markdown(""" **Interprétation des couleurs (échelle rainbow) :** | Couleur | Résistivité | Interprétation Géologique | |---------|-------------|---------------------------| | 🔵 **Bleu foncé** | < 10 Ω·m | Argiles saturées, eau salée | | 🟦 **Cyan** | 10-50 Ω·m | Argiles compactes, limons | | 🟢 **Vert** | 50-100 Ω·m | Sables fins, aquifères potentiels | | 🟡 **Jaune** | 100-300 Ω·m | Sables grossiers, bons aquifères | | 🟠 **Orange** | 300-1000 Ω·m | Graviers, roches altérées | | 🔴 **Rouge** | > 1000 Ω·m | Roches consolidées, socle | """) else: st.warning("⚠️ Aucune donnée chargée. Veuillez d'abord uploader un fichier .dat dans l'onglet 'Analyse Fichiers .dat'") st.info("💡 Uploadez un fichier .dat dans l'onglet 'Analyse Fichiers .dat' pour visualiser vos données avec interprétation des couleurs de résistivité.") # ===================== TAB 4 : STRATIGRAPHIE COMPLÈTE ===================== with tab4: st.header("🪨 Stratigraphie Complète - Classification Géologique avec Résistivités") st.markdown(""" ### 📊 Vue d'ensemble des matériaux géologiques Cette section présente **toutes les formations géologiques** (eaux, sols, roches, minéraux) avec leurs résistivités caractéristiques. Cela permet d'identifier précisément la **nature des couches** à chaque niveau de profondeur. """) # Afficher le tableau complet st.markdown(geology_html, unsafe_allow_html=True) st.markdown("---") # Section graphiques de stratigraphie if 'uploaded_data' in st.session_state and st.session_state['uploaded_data'] is not None: df = st.session_state['uploaded_data'] if len(df) > 0: st.subheader("🎨 Coupes Stratigraphiques Multi-Niveaux") st.markdown(""" Ces coupes montrent la **distribution des matériaux géologiques** selon les valeurs de résistivité mesurées. **Colormap unique basée sur les types d'eau** (Rouge: mer/salée → Jaune: salée → Vert/Bleu: douce → Bleu foncé: pure). Les matériaux géologiques sont identifiés par leur plage de résistivité correspondante. """) # Créer les plages de résistivité étendues - AVEC COLORMAP EAU PRIORITAIRE resistivity_ranges = { 'Minéraux métalliques\n(Graphite, Cuivre, Or)': (0.001, 1, WATER_CMAP, 'Très conducteurs - Cibles minières'), 'Eaux de mer + Argiles marines': (0.1, 10, WATER_CMAP, 'Zone conductrice - Salinité élevée'), 'Argiles compactes + Eaux salées': (10, 50, WATER_CMAP, 'Formations imperméables saturées'), 'Eaux douces + Limons + Schistes': (50, 200, WATER_CMAP, 'Aquifères argileux-sableux'), 'Sables saturés + Graviers': (200, 1000, WATER_CMAP, 'Aquifères perméables productifs'), 'Calcaires + Grès + Basaltes fracturés': (1000, 5000, WATER_CMAP, 'Formations carbonatées/volcaniques'), 'Roches ignées + Granites': (5000, 100000, WATER_CMAP, 'Socle cristallin - Très résistif'), 'Quartzites + Minéraux isolants': (10000, 1000000, WATER_CMAP, 'Formations ultra-résistives') } cols_strat = st.columns(2) for idx, (name, (rho_min, rho_max, cmap, description)) in enumerate(resistivity_ranges.items()): with cols_strat[idx % 2]: with st.expander(f"📍 **{name}** ({rho_min}-{rho_max} Ω·m)", expanded=False): st.caption(f"*{description}*") # Filtrer les données dans cette plage mask = (df['data'] >= rho_min) & (df['data'] <= rho_max) df_filtered = df[mask] if len(df_filtered) > 3: fig_strat, ax_strat = plt.subplots(figsize=(10, 6)) # Convertir les données en float X_strat = pd.to_numeric(df_filtered['survey_point'], errors='coerce').values Z_strat = np.abs(pd.to_numeric(df_filtered['depth'], errors='coerce').values) Rho_strat = pd.to_numeric(df_filtered['data'], errors='coerce').values # Filtrer NaN mask_valid = ~(np.isnan(X_strat) | np.isnan(Z_strat) | np.isnan(Rho_strat)) X_strat = X_strat[mask_valid] Z_strat = Z_strat[mask_valid] Rho_strat = Rho_strat[mask_valid] if len(X_strat) > 3: # Interpolation from scipy.interpolate import griddata xi_strat = np.linspace(X_strat.min(), X_strat.max(), 120) zi_strat = np.linspace(Z_strat.min(), Z_strat.max(), 80) Xi_strat, Zi_strat = np.meshgrid(xi_strat, zi_strat) Rhoi_strat = griddata((X_strat, Z_strat), Rho_strat, (Xi_strat, Zi_strat), method='cubic') # Affichage avec échelle log si plage large if rho_max / rho_min > 10: pcm_strat = ax_strat.pcolormesh(Xi_strat, Zi_strat, Rhoi_strat, cmap=cmap, shading='auto', norm=LogNorm(vmin=rho_min, vmax=rho_max)) else: pcm_strat = ax_strat.pcolormesh(Xi_strat, Zi_strat, Rhoi_strat, cmap=cmap, shading='auto', vmin=rho_min, vmax=rho_max) # Points de mesure ax_strat.scatter(X_strat, Z_strat, c='black', s=30, edgecolors='white', linewidths=1, marker='o', alpha=0.6, zorder=10) ax_strat.invert_yaxis() ax_strat.set_xlabel('Distance (m, précision: mm)', fontsize=11, fontweight='bold') ax_strat.set_ylabel('Profondeur (m, précision: mm)', fontsize=11, fontweight='bold') ax_strat.set_title(f'{name}\n{len(df_filtered)} mesures - Résistivité : {rho_min}-{rho_max} Ω·m', fontsize=11, fontweight='bold', pad=15) ax_strat.grid(True, alpha=0.3, linestyle='--') # Définir les ticks avec TOUTES les valeurs mesurées unique_depths = np.unique(Z_strat) unique_distances = np.unique(X_strat) # Limiter à 20 ticks max pour lisibilité if len(unique_depths) > 20: step_depth = len(unique_depths) // 20 ax_strat.set_yticks(unique_depths[::step_depth]) else: ax_strat.set_yticks(unique_depths) if len(unique_distances) > 20: step_dist = len(unique_distances) // 20 ax_strat.set_xticks(unique_distances[::step_dist]) else: ax_strat.set_xticks(unique_distances) # Format des ticks avec 3 décimales ax_strat.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'{x:.3f}')) ax_strat.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'{x:.3f}')) cbar_strat = plt.colorbar(pcm_strat, ax=ax_strat, pad=0.02) cbar_strat.set_label('Résistivité (Ω·m)', fontsize=10, fontweight='bold') plt.tight_layout() st.pyplot(fig_strat, clear_figure=False, use_container_width=True) plt.close() else: st.info(f"✓ {len(df_filtered)} mesure(s) détectée(s) mais insuffisantes pour interpolation") else: st.info(f"ℹ️ Aucune ou trop peu de mesures ({len(df_filtered)}) dans cette plage de résistivité") st.markdown("---") # Graphique synthétique de distribution st.subheader("📊 Distribution des Matériaux par Profondeur") fig_dist, (ax_hist, ax_depth) = plt.subplots(1, 2, figsize=(14, 6)) # Histogramme des résistivités (échelle log) rho_data = pd.to_numeric(df['data'], errors='coerce').dropna() ax_hist.hist(rho_data, bins=50, color='steelblue', edgecolor='black', alpha=0.7) ax_hist.set_xscale('log') ax_hist.set_xlabel('Résistivité (Ω·m) - Échelle log', fontsize=11, fontweight='bold') ax_hist.set_ylabel('Nombre de mesures', fontsize=11, fontweight='bold') ax_hist.set_title('Distribution des Résistivités Mesurées', fontsize=12, fontweight='bold') ax_hist.grid(True, alpha=0.3, axis='y') # Zones colorées pour les matériaux ax_hist.axvspan(0.001, 1, alpha=0.2, color='gold', label='Minéraux métalliques') ax_hist.axvspan(1, 10, alpha=0.2, color='red', label='Eaux salées + Argiles') ax_hist.axvspan(10, 100, alpha=0.2, color='yellow', label='Eaux douces + Sols') ax_hist.axvspan(100, 1000, alpha=0.2, color='green', label='Sables + Graviers') ax_hist.axvspan(1000, 10000, alpha=0.2, color='blue', label='Roches sédimentaires') ax_hist.axvspan(10000, 1000000, alpha=0.2, color='purple', label='Roches ignées') ax_hist.legend(loc='upper right', fontsize=8) # Profil résistivité vs profondeur depth_data = np.abs(pd.to_numeric(df['depth'], errors='coerce').dropna()) rho_for_depth = pd.to_numeric(df.loc[depth_data.index, 'data'], errors='coerce') scatter = ax_depth.scatter(rho_for_depth, depth_data, c=rho_for_depth, cmap=WATER_CMAP, # Colormap eau personnalisée s=50, alpha=0.6, edgecolors='black', linewidths=0.5, norm=LogNorm(vmin=max(0.1, rho_for_depth.min()), vmax=rho_for_depth.max())) ax_depth.set_xscale('log') ax_depth.invert_yaxis() ax_depth.set_xlabel('Résistivité (Ω·m) - Échelle log', fontsize=11, fontweight='bold') ax_depth.set_ylabel('Profondeur (m, précision: mm)', fontsize=11, fontweight='bold') ax_depth.set_title('Résistivité en fonction de la Profondeur (Précision Millimétrique)', fontsize=12, fontweight='bold') ax_depth.grid(True, alpha=0.3) # Définir ticks avec toutes les profondeurs mesurées unique_depths_all = np.unique(depth_data) if len(unique_depths_all) > 20: ax_depth.set_yticks(unique_depths_all[::len(unique_depths_all)//20]) else: ax_depth.set_yticks(unique_depths_all) # Format Y axis avec 3 décimales ax_depth.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'{x:.3f}')) cbar_dist = plt.colorbar(scatter, ax=ax_depth) cbar_dist.set_label('Résistivité (Ω·m)', fontsize=10, fontweight='bold') plt.tight_layout() st.pyplot(fig_dist, clear_figure=False, use_container_width=True) plt.close() st.markdown("---") # ========== VISUALISATION 3D DES MINÉRAUX PAR COUCHES ========== st.subheader("🌐 Coupe Stratigraphique 3D") st.markdown(""" Vue tridimensionnelle montrant les **couches géologiques** basées sur la résistivité. - **Axe X (horizontal)** : Distance le long du profil ERT (m) - **Axe Y (horizontal)** : Log₁₀ de la Résistivité - forme des **couches** - **Axe Z (VERTICAL)** : ⬇️ Profondeur (m) - descend vers le bas Les **couleurs** représentent les **8 catégories géologiques** (même résistivité = même couche). **Rotation interactive** : Clic + glisser pour explorer les couches en 3D. """) # Préparer les données 3D # X = Distance horizontale du profil, Y = Offset transversal (jitter pour visualisation), Z = Profondeur X_3d_dist = pd.to_numeric(df['survey_point'], errors='coerce').values Z_3d_depth = -np.abs(pd.to_numeric(df['depth'], errors='coerce').values) # Négatif pour descendre Y_3d_rho = pd.to_numeric(df['data'], errors='coerce').values # Filtrer les NaN mask_3d = ~(np.isnan(X_3d_dist) | np.isnan(Z_3d_depth) | np.isnan(Y_3d_rho)) X_3d_dist = X_3d_dist[mask_3d] Z_3d_depth = Z_3d_depth[mask_3d] Y_3d_rho = Y_3d_rho[mask_3d] if len(X_3d_dist) > 0: # Créer la figure 3D avec plotly pour interactivité import plotly.graph_objects as go # Pour une vraie stratigraphie, utiliser directement la résistivité comme Y # Cela crée des "couches" géologiques visibles dans le profil Y_3d_rho_log = np.log10(Y_3d_rho + 0.001) # Échelle logarithmique simple # Définir les catégories avec couleurs def get_material_category(resistivity): if resistivity < 1: return '💎 Minéraux métalliques', '#FFD700' elif resistivity < 10: return '💧 Eaux salées + Argiles', '#FF4500' elif resistivity < 50: return '🧱 Argiles compactes', '#8B4513' elif resistivity < 200: return '💧 Eaux douces + Sols', '#90EE90' elif resistivity < 1000: return '🏖️ Sables + Graviers', '#F4A460' elif resistivity < 5000: return '🪨 Roches sédimentaires', '#87CEEB' elif resistivity < 100000: return '🌋 Roches ignées (Granite)', '#FFB6C1' else: return '💎 Quartzite', '#E0E0E0' # Classifier chaque point categories_3d = [get_material_category(rho) for rho in Y_3d_rho] materials = [cat[0] for cat in categories_3d] colors = [cat[1] for cat in categories_3d] # Créer le scatter 3D fig_3d = go.Figure() # Grouper par catégorie pour la légende unique_materials = list(set(materials)) for material in unique_materials: mask_mat = np.array([m == material for m in materials]) fig_3d.add_trace(go.Scatter3d( x=X_3d_dist[mask_mat], y=Y_3d_rho_log[mask_mat], # Log(résistivité) - couches horizontales z=Z_3d_depth[mask_mat], # Profondeur verticale (négatif = vers le bas) mode='markers', name=material, marker=dict( size=6, color=colors[materials.index(material)], opacity=0.8, line=dict(color='white', width=0.5) ), text=[f'Distance: {x:.3f} m
Profondeur: {abs(z):.3f} m (≈{abs(z)*1000:.0f} mm)
Résistivité: {rho:.2f} Ω·m
Matériau: {mat}' for x, z, rho, mat in zip(X_3d_dist[mask_mat], Z_3d_depth[mask_mat], Y_3d_rho[mask_mat], np.array(materials)[mask_mat])], hovertemplate='%{text}' )) fig_3d.update_layout( title=dict( text='Coupe Stratigraphique 3D
Profondeur verticale | Couches par résistivité', font=dict(size=16, family='Arial Black') ), scene=dict( xaxis=dict(title='Distance (m, précision: mm)', backgroundcolor='lightgray'), yaxis=dict(title='Log₁₀(Résistivité)', backgroundcolor='lightgray'), zaxis=dict(title='⬇️ Profondeur (m, précision: mm)', backgroundcolor='lightgray'), camera=dict( eye=dict(x=1.5, y=-1.5, z=1.2) # Vue latérale pour voir les couches ), aspectmode='manual', aspectratio=dict(x=3, y=1.5, z=2) # Profil étiré, couches visibles ), width=900, height=700, showlegend=True, legend=dict( title='Catégories', yanchor='top', y=0.99, xanchor='left', x=0.01, bgcolor='rgba(255,255,255,0.8)' ) ) st.plotly_chart(fig_3d, use_container_width=True) # Sauvegarder la figure 3D pour le PDF (version matplotlib) from mpl_toolkits.mplot3d import Axes3D fig_3d_pdf = plt.figure(figsize=(12, 8), dpi=150) ax_3d_pdf = fig_3d_pdf.add_subplot(111, projection='3d') # Plot par catégorie for material in unique_materials: mask_mat = np.array([m == material for m in materials]) color_hex = colors[materials.index(material)] ax_3d_pdf.scatter(X_3d_dist[mask_mat], Y_3d_rho_log[mask_mat], # Log simple sans multiplication Z_3d_depth[mask_mat], c=color_hex, s=50, alpha=0.7, edgecolors='white', linewidths=0.5, label=material) ax_3d_pdf.set_xlabel('Distance (m, précision: mm)', fontsize=11, fontweight='bold') ax_3d_pdf.set_ylabel('Log₁₀(Résistivité)', fontsize=11, fontweight='bold') ax_3d_pdf.set_zlabel('⬇️ Profondeur (m, précision: mm)', fontsize=11, fontweight='bold') ax_3d_pdf.set_title('Coupe Stratigraphique 3D\nCouches Géologiques par Résistivité (Précision Millimétrique)', fontsize=13, fontweight='bold', pad=20) ax_3d_pdf.legend(loc='upper left', fontsize=8, framealpha=0.9) ax_3d_pdf.grid(True, alpha=0.3) # Ajuster le ratio pour voir les couches horizontales ax_3d_pdf.set_box_aspect([3, 1.5, 2]) # Profil étiré, couches visibles plt.tight_layout() st.success(f""" ✅ **Visualisation 3D générée avec succès** - {len(X_3d_dist)} points cartographiés - {len(unique_materials)} catégories géologiques distinctes - Modèle interactif avec rotation 360° """) else: st.warning("⚠️ Données insuffisantes pour la visualisation 3D") fig_3d_pdf = None st.markdown("---") # ========== EXPORT PDF DU RAPPORT STRATIGRAPHIQUE ========== st.subheader("📄 Génération du Rapport PDF Complet") st.markdown(""" Téléchargez un **rapport PDF professionnel** incluant : - 📊 Tableau de classification complète (30+ matériaux) - 📈 Graphiques de distribution (histogramme + profil) - 🌐 Visualisation 3D des couches géologiques - 📋 Statistiques détaillées et interprétation """) if st.button("🎯 Générer le Rapport PDF Stratigraphique", key="btn_pdf_strat"): with st.spinner("🔄 Génération du rapport PDF en cours..."): # Créer un dictionnaire avec toutes les figures figures_strat = {} # Figure 1: Distribution figures_strat['distribution'] = fig_dist # Figure 2: 3D (si disponible) if fig_3d_pdf is not None: figures_strat['3d_view'] = fig_3d_pdf # Générer le PDF pdf_bytes = create_stratigraphy_pdf_report(df, figures_strat) # Bouton de téléchargement st.download_button( label="⬇️ Télécharger le Rapport Stratigraphique (PDF)", data=pdf_bytes, file_name=f"Rapport_Stratigraphie_ERT_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf", mime="application/pdf", key="download_pdf_strat" ) st.success("✅ Rapport PDF généré avec succès ! Cliquez sur le bouton ci-dessus pour télécharger.") st.markdown("---") st.success(f""" ✅ **Analyse complète effectuée** - {len(df)} mesures analysées - Profondeur max : {depth_data.max():.3f} m (≈{depth_data.max()*1000:.0f} mm) - Résistivité min/max : {rho_data.min():.2f} - {rho_data.max():.0f} Ω·m - Identification automatique des formations géologiques - Visualisation 3D interactive disponible - Export PDF professionnel prêt """) # ========== COUPE SUPPLÉMENTAIRE - PSEUDO-SECTION RÉELLE (FORMAT CLASSIQUE) ========== st.markdown("---") with st.expander("📊 Pseudo-Section de Résistivité Apparente (Format Classique)", expanded=True): st.markdown(""" **Carte de pseudo-section au format géophysique standard** Cette représentation respecte le format classique des prospections ERT avec : - 🎨 Échelle de couleurs rainbow continue (bleu → vert → jaune → orange → rouge) - 📏 Axes en mètres avec positions réelles des électrodes - 🌡️ Barre de couleur graduée montrant les résistivités mesurées - 🗺️ Visualisation directe des résistivités apparentes du sous-sol """) # Créer la figure au format classique fig_pseudo_t4, ax_pseudo_t4 = plt.subplots(figsize=(16, 8), dpi=150) # Utiliser les VRAIES valeurs mesurées depuis le DataFrame X_real_t4 = pd.to_numeric(df['survey_point'], errors='coerce').values Z_real_t4 = np.abs(pd.to_numeric(df['depth'], errors='coerce').values) Rho_real_t4 = pd.to_numeric(df['data'], errors='coerce').values # Filtrer les valeurs NaN mask_t4 = ~(np.isnan(X_real_t4) | np.isnan(Z_real_t4) | np.isnan(Rho_real_t4)) X_real_t4 = X_real_t4[mask_t4] Z_real_t4 = Z_real_t4[mask_t4] Rho_real_t4 = Rho_real_t4[mask_t4] if len(X_real_t4) > 3: # Créer une grille fine pour la visualisation from scipy.interpolate import griddata xi_pseudo_t4 = np.linspace(X_real_t4.min(), X_real_t4.max(), 500) zi_pseudo_t4 = np.linspace(Z_real_t4.min(), Z_real_t4.max(), 300) Xi_pseudo_t4, Zi_pseudo_t4 = np.meshgrid(xi_pseudo_t4, zi_pseudo_t4) # Interpolation linear pour un rendu lisse mais fidèle Rhoi_pseudo_t4 = griddata( (X_real_t4, Z_real_t4), Rho_real_t4, (Xi_pseudo_t4, Zi_pseudo_t4), method='linear', fill_value=np.median(Rho_real_t4) ) # Utiliser la colormap rainbow classique from matplotlib.colors import LogNorm # Définir les limites de résistivité vmin_pseudo_t4 = max(0.1, Rho_real_t4.min()) vmax_pseudo_t4 = Rho_real_t4.max() # Créer la pseudo-section avec colormap eau personnalisée pcm_pseudo_t4 = ax_pseudo_t4.contourf( Xi_pseudo_t4, Zi_pseudo_t4, Rhoi_pseudo_t4, levels=50, cmap=WATER_CMAP, # Colormap eau personnalisée norm=LogNorm(vmin=vmin_pseudo_t4, vmax=vmax_pseudo_t4), extend='both' ) # Ajouter les contours contours_t4 = ax_pseudo_t4.contour( Xi_pseudo_t4, Zi_pseudo_t4, Rhoi_pseudo_t4, levels=10, colors='black', linewidths=0.5, alpha=0.3 ) # Superposer les points de mesure scatter_real_t4 = ax_pseudo_t4.scatter( X_real_t4, Z_real_t4, c='white', s=20, edgecolors='black', linewidths=0.5, alpha=0.7, zorder=5, label='Points de mesure' ) # Barre de couleur cbar_pseudo_t4 = plt.colorbar(pcm_pseudo_t4, ax=ax_pseudo_t4, pad=0.02, aspect=30) cbar_pseudo_t4.set_label('Résistivité Apparente (Ω·m)', fontsize=12, fontweight='bold') cbar_pseudo_t4.ax.tick_params(labelsize=10) # Configuration des axes ax_pseudo_t4.set_xlabel('Position (m)', fontsize=12, fontweight='bold') ax_pseudo_t4.set_ylabel('Profondeur (m)', fontsize=12, fontweight='bold') ax_pseudo_t4.set_title( 'Pseudo-Section de Résistivité Apparente\nMeasured Apparent Resistivity Pseudosection', fontsize=14, fontweight='bold' ) ax_pseudo_t4.invert_yaxis() ax_pseudo_t4.grid(True, alpha=0.2, linestyle='--', linewidth=0.5) ax_pseudo_t4.legend(loc='upper right', fontsize=10, framealpha=0.9) plt.tight_layout() st.pyplot(fig_pseudo_t4, clear_figure=False, use_container_width=True) plt.close() # Statistiques col1_ps_t4, col2_ps_t4, col3_ps_t4 = st.columns(3) with col1_ps_t4: st.metric("📏 Points de mesure", f"{len(Rho_real_t4)}") with col2_ps_t4: st.metric("📊 Plage de résistivité", f"{vmin_pseudo_t4:.1f} - {vmax_pseudo_t4:.1f} Ω·m") with col3_ps_t4: st.metric("🎯 Résistivité médiane", f"{np.median(Rho_real_t4):.2f} Ω·m") st.markdown(""" **Interprétation des couleurs (échelle rainbow) :** | Couleur | Résistivité | Interprétation Géologique | |---------|-------------|---------------------------| | 🔵 **Bleu foncé** | < 10 Ω·m | Argiles saturées, eau salée | | 🟦 **Cyan** | 10-50 Ω·m | Argiles compactes, limons | | 🟢 **Vert** | 50-100 Ω·m | Sables fins, aquifères potentiels | | 🟡 **Jaune** | 100-300 Ω·m | Sables grossiers, bons aquifères | | 🟠 **Orange** | 300-1000 Ω·m | Graviers, roches altérées | | 🔴 **Rouge** | > 1000 Ω·m | Roches consolidées, socle | """) else: st.warning("⚠️ Pas assez de données valides pour générer la pseudo-section") else: st.info("ℹ️ Le fichier uploadé ne contient pas de données valides.") else: st.warning("⚠️ Aucune donnée chargée. Veuillez d'abord uploader un fichier .dat dans l'onglet 'Analyse Fichiers .dat'") st.info("💡 Une fois les données chargées, vous pourrez visualiser la stratigraphie complète avec identification automatique des formations.") # ===================== TAB 5 : INVERSION PYGIMLI - ERT AVANCÉE ===================== with tab5: st.header("🔬 Inversion pyGIMLi - Analyse ERT Avancée") st.markdown(""" ### 🛡️ Inversion Géophysique avec pyGIMLi Cette section utilise **pyGIMLi** (Python Geophysical Inversion and Modelling Library) pour effectuer une **inversion complète** des données ERT. **Fonctionnalités :** - 📁 Upload de fichiers .dat ERT (fichiers binaires Ravensgate Sonic) - � Upload de fichiers freq.dat (résistivité par fréquence MHz) - �🔄 Inversion automatique avec algorithme optimisé - 🎨 Visualisation avec palette hydrogéologique (4 classes) - 📊 Classification lithologique complète (9 formations) - � Classification hydrogéologique (4 types d'eau) - 📈 Détection automatique des interfaces géologiques - 💾 Export CSV interprété avec classifications """) # Upload fichier freq.dat directement (sans sélection de type) uploaded_freq = st.file_uploader("📂 Uploader un fichier freq.dat", type=["dat"], key="pygimli_upload_freq") if uploaded_freq is not None: # Lire le contenu du fichier en bytes (avec cache) file_bytes = uploaded_freq.read() encoding = detect_encoding(file_bytes) # Parser le fichier freq.dat df_pygimli = parse_freq_dat(file_bytes, encoding) file_desc = "freq.dat" if not df_pygimli.empty: st.write(f"**📊 Données {file_desc} parsées :**") st.dataframe(df_pygimli.head()) st.success(f"✅ {len(df_pygimli)} mesures chargées depuis le fichier freq.dat") # Traitement pour freq.dat (toujours actif maintenant) st.info("🔄 Conversion des données de fréquence en format ERT...") # Les fréquences deviennent des "profondeurs" (plus haute fréquence = surface) freq_columns = [col for col in df_pygimli.columns if col.startswith('freq_')] survey_points = sorted(df_pygimli['survey_point'].unique()) # Créer un DataFrame au format ERT (survey_point, depth, data) ert_data = [] for sp in survey_points: sp_data = df_pygimli[df_pygimli['survey_point'] == sp] if not sp_data.empty: for i, freq_col in enumerate(freq_columns): # Extraire la valeur numérique de la fréquence freq_value = float(freq_col.replace('freq_', '')) rho_value = sp_data[freq_col].values[0] if not pd.isna(rho_value): # Fréquence haute = profondeur faible (surface) # On inverse : haute fréquence = faible profondeur depth = 1000 / freq_value # Conversion arbitraire pour visualisation ert_data.append({ 'survey_point': sp, 'depth': -depth, # Négatif pour convention ERT 'data': rho_value, 'frequency': freq_value }) df_pygimli = pd.DataFrame(ert_data) st.success(f"✅ Conversion terminée : {len(df_pygimli)} mesures ERT créées à partir de {len(freq_columns)} fréquences") # Afficher le DataFrame converti st.write("**📊 Données converties en format ERT :**") st.dataframe(df_pygimli.head(20)) # ===== VISUALISATION PSEUDO-SECTION IMMÉDIATE ===== st.subheader("🎨 Pseudo-section de Résistivité (freq.dat)") # Préparer les données pour la visualisation - UTILISER LES VRAIES VALEURS X_freq = df_pygimli['survey_point'].values Z_freq = np.abs(df_pygimli['depth'].values) Rho_freq = df_pygimli['data'].values # DIAGNOSTIC DES VRAIES VALEURS MESURÉES st.info(f""" **📊 Analyse des VRAIES résistivités mesurées :** - **Minimum** : {Rho_freq.min():.3f} Ω·m - **Maximum** : {Rho_freq.max():.3f} Ω·m - **Moyenne** : {Rho_freq.mean():.3f} Ω·m - **Médiane** : {np.median(Rho_freq):.3f} Ω·m - **Nombre de mesures** : {len(Rho_freq)} **Classification automatique :** - < 1 Ω·m (Eau de mer) : {(Rho_freq < 1).sum()} mesures ({(Rho_freq < 1).sum()/len(Rho_freq)*100:.1f}%) - 1-10 Ω·m (Eau salée) : {((Rho_freq >= 1) & (Rho_freq < 10)).sum()} mesures ({((Rho_freq >= 1) & (Rho_freq < 10)).sum()/len(Rho_freq)*100:.1f}%) - 10-100 Ω·m (Eau douce) : {((Rho_freq >= 10) & (Rho_freq < 100)).sum()} mesures ({((Rho_freq >= 10) & (Rho_freq < 100)).sum()/len(Rho_freq)*100:.1f}%) - > 100 Ω·m (Eau pure) : {(Rho_freq >= 100).sum()} mesures ({(Rho_freq >= 100).sum()/len(Rho_freq)*100:.1f}%) """) # CRÉER UNE GRILLE AVEC LES VRAIES VALEURS (nearest pour préserver les valeurs exactes) from scipy.interpolate import griddata xi_freq = np.linspace(X_freq.min(), X_freq.max(), 100) zi_freq = np.linspace(Z_freq.min(), Z_freq.max(), 80) Xi_freq, Zi_freq = np.meshgrid(xi_freq, zi_freq) # CORRECTION: Utiliser 'nearest' au lieu de 'cubic' pour préserver les vraies valeurs Rhoi_freq = griddata((X_freq, Z_freq), Rho_freq, (Xi_freq, Zi_freq), method='nearest') # Créer la figure fig_freq_pseudo, ax_freq = plt.subplots(figsize=(14, 7), dpi=150) # Définir les limites de résistivité pour les couleurs - VRAIES VALEURS vmin_freq = max(0.01, Rho_freq.min()) vmax_freq = Rho_freq.max() # Afficher avec colormap eau personnalisée - VRAIES VALEURS pcm_freq = ax_freq.pcolormesh(Xi_freq, Zi_freq, Rhoi_freq, cmap=WATER_CMAP, shading='auto', norm=LogNorm(vmin=vmin_freq, vmax=vmax_freq)) # Superposer les points de mesure scatter_freq = ax_freq.scatter(X_freq, Z_freq, c=Rho_freq, cmap=WATER_CMAP, s=60, edgecolors='black', linewidths=1, norm=LogNorm(vmin=vmin_freq, vmax=vmax_freq), zorder=10, alpha=0.8) # Annoter quelques points avec leurs fréquences si disponible if 'frequency' in df_pygimli.columns: # Annoter 5 points représentatifs for i in range(0, len(df_pygimli), max(1, len(df_pygimli)//5)): row = df_pygimli.iloc[i] ax_freq.annotate(f'{row["frequency"]:.1f} MHz\nρ={row["data"]:.3f}', xy=(row['survey_point'], np.abs(row['depth'])), xytext=(5, 5), textcoords='offset points', fontsize=7, ha='left', bbox=dict(boxstyle='round,pad=0.3', facecolor='yellow', alpha=0.7), arrowprops=dict(arrowstyle='->', connectionstyle='arc3,rad=0.2', color='black', lw=0.5)) ax_freq.invert_yaxis() ax_freq.set_xlabel('Point de sondage', fontsize=12, fontweight='bold') ax_freq.set_ylabel('Profondeur équivalente (m)', fontsize=12, fontweight='bold') ax_freq.set_title(f'Pseudo-section ERT - Données Fréquence\n{len(survey_points)} points × {len(freq_columns)} fréquences', fontsize=13, fontweight='bold') ax_freq.grid(True, alpha=0.3, linestyle='--', color='white') # Colorbar cbar_freq = fig_freq_pseudo.colorbar(pcm_freq, ax=ax_freq, extend='both') cbar_freq.set_label('Résistivité (Ω·m)', fontsize=11, fontweight='bold') plt.tight_layout() st.pyplot(fig_freq_pseudo, clear_figure=False, use_container_width=True) plt.close() # Légende d'interprétation st.markdown(f""" **Interprétation des couleurs :** - 🔴 **Rouge/Orange** (faible résistivité) : Matériaux conducteurs - Eau salée, argiles saturées - 🟡 **Jaune** (résistivité moyenne) : Eau douce, sols humides - 🟢 **Vert** (résistivité élevée) : Sables secs, graviers - 🔵 **Bleu** (très haute résistivité) : Roches sèches, formations résistives **Plage mesurée :** {vmin_freq:.3f} - {vmax_freq:.3f} Ω·m **Points noirs :** Mesures réelles annotées avec fréquences (MHz) """) # Graphique fréquence vs résistivité st.subheader("📊 Profil Résistivité par Fréquence") fig_freq_profile, ax_prof = plt.subplots(figsize=(12, 6), dpi=150) # Grouper par fréquence et calculer la moyenne freq_stats = df_pygimli.groupby('frequency')['data'].agg(['mean', 'std', 'min', 'max']).reset_index() freq_stats = freq_stats.sort_values('frequency', ascending=False) # Tracer avec barres d'erreur ax_prof.errorbar(freq_stats['frequency'], freq_stats['mean'], yerr=freq_stats['std'], fmt='o-', linewidth=2, markersize=8, capsize=5, capthick=2, color='steelblue', ecolor='gray', alpha=0.8, label='Moyenne ± σ') ax_prof.fill_between(freq_stats['frequency'], freq_stats['min'], freq_stats['max'], alpha=0.2, color='lightblue', label='Min-Max') ax_prof.set_xlabel('Fréquence (MHz)', fontsize=11, fontweight='bold') ax_prof.set_ylabel('Résistivité moyenne (Ω·m)', fontsize=11, fontweight='bold') ax_prof.set_title('Variation de la Résistivité en fonction de la Fréquence', fontsize=12, fontweight='bold') ax_prof.set_xscale('log') ax_prof.set_yscale('log') ax_prof.grid(True, alpha=0.3, which='both') ax_prof.legend(loc='best', fontsize=10) plt.tight_layout() st.pyplot(fig_freq_profile, clear_figure=False, use_container_width=True) plt.close() # ========== 3 COUPES GÉOLOGIQUES SUPPLÉMENTAIRES DU SOUS-SOL ========== st.markdown("---") st.subheader("🌍 Coupes Géologiques Détaillées du Sous-Sol") st.markdown(""" Visualisation multi-niveaux des formations géologiques basées sur les valeurs de résistivité mesurées. Ces coupes permettent d'identifier la **nature des matériaux** à différentes profondeurs. """) # COUPE 1: Classification par zones de résistivité (4 classes) with st.expander("📊 Coupe 1 - Classification Hydrogéologique (4 classes d'eau)", expanded=True): fig_geo1, ax_geo1 = plt.subplots(figsize=(14, 7), dpi=150) # Définir 4 classes de résistivité pour l'eau - UTILISER LES VRAIES VALEURS # RESPECT DU TABLEAU DE RÉFÉRENCE EXACT def classify_water(rho): if rho < 1: return 0, 'Eau de mer (0.1-1 Ω·m)', '#DC143C' # Crimson (Rouge vif) elif rho < 10: return 1, 'Eau salée nappe (1-10 Ω·m)', '#FFA500' # Orange elif rho < 100: return 2, 'Eau douce (10-100 Ω·m)', '#FFD700' # Gold (Jaune) else: return 3, 'Eau très pure (>100 Ω·m)', '#1E90FF' # DodgerBlue (Bleu vif) # UTILISER nearest pour conserver les VRAIES valeurs mesurées water_classes = np.zeros_like(Rhoi_freq) for i in range(Rhoi_freq.shape[0]): for j in range(Rhoi_freq.shape[1]): if not np.isnan(Rhoi_freq[i, j]) and Rhoi_freq[i, j] > 0: water_classes[i, j], _, _ = classify_water(Rhoi_freq[i, j]) else: water_classes[i, j] = np.nan # Compter les classes présentes et leurs proportions basées sur les VRAIES valeurs unique_classes, counts = np.unique(water_classes[~np.isnan(water_classes)], return_counts=True) total_pixels = (~np.isnan(water_classes)).sum() # Créer une colormap discrète avec couleurs EXACTES selon le tableau de référence from matplotlib.colors import ListedColormap, BoundaryNorm colors_water = ['#DC143C', '#FFA500', '#FFD700', '#1E90FF'] # Rouge vif, Orange, Jaune/Or, Bleu vif cmap_water = ListedColormap(colors_water) bounds_water = [0, 1, 2, 3, 4] norm_water = BoundaryNorm(bounds_water, cmap_water.N) # Afficher pcm_geo1 = ax_geo1.pcolormesh(Xi_freq, Zi_freq, water_classes, cmap=cmap_water, norm=norm_water, shading='auto') # Superposer les points de mesure for rho_val in [0.5, 5, 50, 150]: mask_class = (Rho_freq >= rho_val*0.5) & (Rho_freq < rho_val*2) if mask_class.sum() > 0: ax_geo1.scatter(X_freq[mask_class], Z_freq[mask_class], s=40, edgecolors='black', linewidths=1.5, facecolors='none', alpha=0.8, zorder=10) ax_geo1.invert_yaxis() ax_geo1.set_xlabel('Distance (m)', fontsize=12, fontweight='bold') ax_geo1.set_ylabel('Profondeur (m)', fontsize=12, fontweight='bold') ax_geo1.set_title('Coupe 1: Classification Hydrogéologique\n4 Types d\'Eau identifiés', fontsize=13, fontweight='bold') ax_geo1.grid(True, alpha=0.3, linestyle='--', color='gray') # Colorbar cbar_geo1 = fig_geo1.colorbar(pcm_geo1, ax=ax_geo1, ticks=[0.5, 1.5, 2.5, 3.5]) cbar_geo1.ax.set_yticklabels(['Eau de mer\n0.1-1 Ω·m', 'Eau salée (nappe)\n1-10 Ω·m', 'Eau douce\n10-100 Ω·m', 'Eau très pure\n> 100 Ω·m']) cbar_geo1.set_label('Type d\'Eau', fontsize=11, fontweight='bold') plt.tight_layout() st.pyplot(fig_geo1, clear_figure=False, use_container_width=True) plt.close() st.markdown(""" **Interprétation (selon tableau de référence) :** - 🔴 **Rouge vif/Orange** (0.1-1 Ω·m) : Eau de mer, intrusion marine - � **Jaune/Orange** (1-10 Ω·m) : Eau salée (nappe saumâtre) - � **Vert/Bleu clair** (10-100 Ω·m) : Eau douce exploitable - 🔵 **Bleu foncé** (> 100 Ω·m) : Eau très pure ou roches sèches """) # COUPE 2: Gradient vertical de résistivité (changements de couches) with st.expander("📈 Coupe 2 - Gradient Vertical de Résistivité (Interfaces géologiques)", expanded=False): fig_geo2, (ax_geo2a, ax_geo2b) = plt.subplots(1, 2, figsize=(16, 7), dpi=150) # Calculer le gradient vertical (dérivée selon la profondeur) gradient_z = np.gradient(Rhoi_freq, axis=0) gradient_magnitude = np.abs(gradient_z) # Afficher la résistivité avec colormap eau personnalisée pcm_geo2a = ax_geo2a.pcolormesh(Xi_freq, Zi_freq, Rhoi_freq, cmap=WATER_CMAP, shading='auto', norm=LogNorm(vmin=vmin_freq, vmax=vmax_freq)) ax_geo2a.invert_yaxis() ax_geo2a.set_xlabel('Distance (m)', fontsize=11, fontweight='bold') ax_geo2a.set_ylabel('Profondeur (m)', fontsize=11, fontweight='bold') ax_geo2a.set_title('Résistivité Mesurée', fontsize=12, fontweight='bold') ax_geo2a.grid(True, alpha=0.3) cbar_2a = fig_geo2.colorbar(pcm_geo2a, ax=ax_geo2a) cbar_2a.set_label('ρ (Ω·m)', fontsize=10, fontweight='bold') # Afficher le gradient (interfaces) pcm_geo2b = ax_geo2b.pcolormesh(Xi_freq, Zi_freq, gradient_magnitude, cmap='hot', shading='auto') # Identifier les interfaces majeures (gradient > seuil) threshold_gradient = np.percentile(gradient_magnitude[~np.isnan(gradient_magnitude)], 90) interfaces = gradient_magnitude > threshold_gradient # Contours des interfaces if interfaces.sum() > 10: contour_levels = [threshold_gradient] ax_geo2b.contour(Xi_freq, Zi_freq, gradient_magnitude, levels=contour_levels, colors='cyan', linewidths=2, linestyles='--', alpha=0.8) ax_geo2b.invert_yaxis() ax_geo2b.set_xlabel('Distance (m)', fontsize=11, fontweight='bold') ax_geo2b.set_ylabel('Profondeur (m)', fontsize=11, fontweight='bold') ax_geo2b.set_title('Gradient Vertical (Interfaces)\nLignes cyan = Changements de couches', fontsize=12, fontweight='bold') ax_geo2b.grid(True, alpha=0.3) cbar_2b = fig_geo2.colorbar(pcm_geo2b, ax=ax_geo2b) cbar_2b.set_label('|∂ρ/∂z|', fontsize=10, fontweight='bold') plt.tight_layout() st.pyplot(fig_geo2, clear_figure=False, use_container_width=True) plt.close() st.markdown(f""" **Interprétation :** - **Graphique gauche** : Distribution de la résistivité - **Graphique droite** : Gradient vertical (changement selon la profondeur) - **Lignes cyan** : Interfaces géologiques majeures (seuil > {threshold_gradient:.2f}) - **Zones chaudes (jaune/blanc)** : Changements brusques = limites entre couches - **Zones froides (noir/rouge foncé)** : Couches homogènes **Applications :** - Détection d'interfaces aquifères/aquitards - Identification de la profondeur du toit rocheux - Localisation des zones de transition eau douce/salée """) # COUPE 3: Modèle géologique interprété (lithologie) with st.expander("🗺️ Coupe 3 - Modèle Lithologique Interprété (Géologie complète)", expanded=False): fig_geo3, ax_geo3 = plt.subplots(figsize=(14, 8), dpi=150) # Classification lithologique étendue basée sur résistivité def classify_lithology(rho): if rho < 1: return 0, 'Eau de mer / Argile saturée salée', '#8B0000' elif rho < 5: return 1, 'Argile marine / Vase', '#A0522D' elif rho < 20: return 2, 'Argile compacte / Limon saturé', '#CD853F' elif rho < 50: return 3, 'Sable fin saturé (eau douce)', '#F4A460' elif rho < 100: return 4, 'Sable moyen / Gravier fin', '#FFD700' elif rho < 200: return 5, 'Gravier / Sable grossier sec', '#90EE90' elif rho < 500: return 6, 'Roche altérée / Calcaire fissuré', '#87CEEB' elif rho < 1000: return 7, 'Roche sédimentaire compacte', '#4682B4' else: return 8, 'Socle rocheux / Granite', '#8B008B' # Classifier chaque point litho_classes = np.zeros_like(Rhoi_freq) for i in range(Rhoi_freq.shape[0]): for j in range(Rhoi_freq.shape[1]): if not np.isnan(Rhoi_freq[i, j]): litho_classes[i, j], _, _ = classify_lithology(Rhoi_freq[i, j]) else: litho_classes[i, j] = np.nan # Colormap lithologique colors_litho = ['#8B0000', '#A0522D', '#CD853F', '#F4A460', '#FFD700', '#90EE90', '#87CEEB', '#4682B4', '#8B008B'] cmap_litho = ListedColormap(colors_litho) bounds_litho = list(range(10)) norm_litho = BoundaryNorm(bounds_litho, cmap_litho.N) # Afficher pcm_geo3 = ax_geo3.pcolormesh(Xi_freq, Zi_freq, litho_classes, cmap=cmap_litho, norm=norm_litho, shading='auto') # Ajouter contours pour mieux voir les couches contour_litho = ax_geo3.contour(Xi_freq, Zi_freq, litho_classes, levels=bounds_litho, colors='black', linewidths=0.5, alpha=0.4) # AMÉLIORATION: Annoter TOUTES les zones présentes avec leurs caractéristiques unique_classes = np.unique(litho_classes[~np.isnan(litho_classes)]).astype(int) # AVERTISSEMENT si une seule classe domine if len(unique_classes) == 1: st.warning(f""" ⚠️ **Attention** : Une seule formation lithologique détectée (classe {unique_classes[0]}). Cela signifie que **toutes les résistivités mesurées** sont dans la même gamme. Les VRAIES valeurs mesurées sont : {Rho_freq.min():.3f} - {Rho_freq.max():.3f} Ω·m **Explication** : Si tout est rouge (< 1 Ω·m), c'est que le site est dominé par de l'eau de mer ou des argiles saturées salées. Pour voir d'autres couches, il faudrait des mesures avec plus de variabilité de résistivité. """) # Stocker les informations de chaque formation présente (VRAIES VALEURS) formations_info = [] for cls in unique_classes: mask_cls = litho_classes == cls count_pixels = mask_cls.sum() percentage = (count_pixels / (~np.isnan(litho_classes)).sum()) * 100 # CORRECTION: Obtenir les valeurs de résistivité RÉELLES (pas interpolées) # Trouver les points de mesure réels qui correspondent à cette classe real_rho_for_class = [] for idx in range(len(X_freq)): # Trouver la cellule de grille la plus proche i_grid = np.argmin(np.abs(xi_freq - X_freq[idx])) j_grid = np.argmin(np.abs(zi_freq - Z_freq[idx])) if litho_classes[j_grid, i_grid] == cls: real_rho_for_class.append(Rho_freq[idx]) if len(real_rho_for_class) > 0: rho_min = np.min(real_rho_for_class) rho_max = np.max(real_rho_for_class) rho_mean = np.mean(real_rho_for_class) else: # Fallback sur les valeurs interpolées si pas de correspondance rho_values = Rhoi_freq[mask_cls] rho_min = np.nanmin(rho_values) rho_max = np.nanmax(rho_values) rho_mean = np.nanmean(rho_values) # Calculer profondeur moyenne et étendue y_indices = np.where(np.any(mask_cls, axis=1))[0] if len(y_indices) > 0: depth_min = zi_freq[y_indices.min()] depth_max = zi_freq[y_indices.max()] depth_mean = (depth_min + depth_max) / 2 # Calculer position horizontale moyenne x_indices = np.where(np.any(mask_cls, axis=0))[0] x_mean = xi_freq[int(np.mean(x_indices))] if len(x_indices) > 0 else xi_freq[len(xi_freq)//2] # Obtenir le label _, label, color = classify_lithology(rho_mean) formations_info.append({ 'class': cls, 'label': label, 'color': color, 'percentage': percentage, 'rho_min': rho_min, 'rho_max': rho_max, 'rho_mean': rho_mean, 'depth_min': depth_min, 'depth_max': depth_max, 'depth_mean': depth_mean, 'x_mean': x_mean }) # Annoter sur le graphique si la zone est significative (> 2%) if percentage > 2: label_short = label.split('/')[0].strip() ax_geo3.annotate( f'{label_short}\n{rho_mean:.1f} Ω·m', xy=(x_mean, depth_mean), fontsize=7, ha='center', va='center', bbox=dict(boxstyle='round,pad=0.4', facecolor='white', edgecolor=color, alpha=0.85, linewidth=2), fontweight='bold', color='black' ) ax_geo3.invert_yaxis() ax_geo3.set_xlabel('Distance (m)', fontsize=12, fontweight='bold') ax_geo3.set_ylabel('Profondeur (m)', fontsize=12, fontweight='bold') ax_geo3.set_title('Coupe 3: Modèle Lithologique Interprété\n9 Formations Géologiques Identifiées', fontsize=13, fontweight='bold') ax_geo3.grid(True, alpha=0.2, linestyle='--', color='gray') # Légende détaillée from matplotlib.patches import Patch legend_elements = [ Patch(facecolor='#8B0000', label='Eau mer / Argile salée (< 1 Ω·m)'), Patch(facecolor='#A0522D', label='Argile marine (1-5 Ω·m)'), Patch(facecolor='#CD853F', label='Argile compacte (5-20 Ω·m)'), Patch(facecolor='#F4A460', label='Sable fin saturé (20-50 Ω·m)'), Patch(facecolor='#FFD700', label='Sable/Gravier (50-100 Ω·m)'), Patch(facecolor='#90EE90', label='Gravier sec (100-200 Ω·m)'), Patch(facecolor='#87CEEB', label='Roche altérée (200-500 Ω·m)'), Patch(facecolor='#4682B4', label='Roche compacte (500-1000 Ω·m)'), Patch(facecolor='#8B008B', label='Socle cristallin (> 1000 Ω·m)') ] ax_geo3.legend(handles=legend_elements, loc='upper left', fontsize=8, framealpha=0.9, ncol=1) plt.tight_layout() st.pyplot(fig_geo3, clear_figure=False, use_container_width=True) plt.close() # TABLEAU DÉTAILLÉ DES FORMATIONS PRÉSENTES st.markdown("### 📋 Inventaire Complet des Formations Géologiques Détectées") if formations_info: # Créer un DataFrame avec toutes les informations formations_df = pd.DataFrame(formations_info) formations_df = formations_df.sort_values('depth_mean') # Préparer les données pour affichage display_data = { 'Formation': formations_df['label'].tolist(), 'Profondeur (m)': [f"{row['depth_min']:.2f} - {row['depth_max']:.2f}" for _, row in formations_df.iterrows()], 'Résistivité (Ω·m)': [f"{row['rho_min']:.1f} - {row['rho_max']:.1f} (moy: {row['rho_mean']:.1f})" for _, row in formations_df.iterrows()], 'Présence (%)': [f"{row['percentage']:.1f}%" for _, row in formations_df.iterrows()], 'Type de matériau': [] } # Ajouter classification du type de matériau for _, row in formations_df.iterrows(): rho = row['rho_mean'] if rho < 1: mat_type = "💧 Liquide salin / Argile saturée" elif rho < 20: mat_type = "🟫 Sol argileux imperméable" elif rho < 100: mat_type = "🟡 Sol sableux aquifère" elif rho < 500: mat_type = "⚪ Gravier / Roche poreuse" else: mat_type = "⬛ Roche compacte / Minéral" display_data['Type de matériau'].append(mat_type) display_df = pd.DataFrame(display_data) # Afficher avec style st.dataframe( display_df.style.set_properties(**{ 'text-align': 'left', 'font-size': '11px' }), use_container_width=True, height=min(400, len(display_df) * 50 + 50) ) # Statistiques récapitulatives st.markdown("### 📊 Statistiques Lithologiques") col1, col2, col3, col4 = st.columns(4) with col1: st.metric("Formations détectées", len(formations_info)) with col2: dominant = formations_df.loc[formations_df['percentage'].idxmax()] st.metric("Formation dominante", dominant['label'].split('/')[0][:20], f"{dominant['percentage']:.1f}%") with col3: rho_min_global = formations_df['rho_min'].min() rho_max_global = formations_df['rho_max'].max() st.metric("Plage résistivité", f"{rho_min_global:.1f} - {rho_max_global:.1f} Ω·m") with col4: depth_max_form = formations_df['depth_max'].max() st.metric("Profondeur max explorée", f"{depth_max_form:.2f} m") else: st.warning("Aucune formation lithologique identifiée dans les données.") st.markdown(""" **Interprétation Lithologique Complète :** Cette coupe présente un **modèle géologique réaliste** basé sur les résistivités mesurées. Chaque couleur représente une **formation lithologique spécifique** avec ses propriétés hydrogéologiques. **Couches principales (de haut en bas) :** 1. **Zone superficielle** (marron foncé) : Argiles marines saturées, faible perméabilité 2. **Zone intermédiaire** (jaune/or) : Sables et graviers aquifères, bon réservoir d'eau 3. **Zone profonde** (bleu/violet) : Roches consolidées, aquifère de socle fracturé **Applications pratiques :** - 💧 **Forage de puits** : Cibler les zones jaunes/vertes (sables aquifères) - 🚫 **Éviter** : Zones rouges/marron foncé (argiles imperméables, eau salée) - 🎯 **Zones optimales** : Sables moyens à graviers (50-200 Ω·m) = meilleurs aquifères - 🌊 **Risque d'intrusion saline** : Zones rouges en surface ou peu profondes """) # Recommandations par formation (HORS de l'expander pour éviter l'imbrication) if formations_info: st.markdown("### 🎯 Recommandations Détaillées par Formation") st.info("💡 Cliquez sur chaque formation pour voir les recommandations spécifiques") for _, row in formations_df.iterrows(): with st.expander(f"📍 {row['label']} ({row['percentage']:.1f}% du profil)", expanded=False): col_a, col_b = st.columns([2, 1]) with col_a: st.markdown(f""" **Caractéristiques détectées :** - **Profondeur :** {row['depth_min']:.2f} à {row['depth_max']:.2f} m - **Résistivité moyenne :** {row['rho_mean']:.1f} Ω·m - **Plage mesurée :** {row['rho_min']:.1f} - {row['rho_max']:.1f} Ω·m - **Proportion du profil :** {row['percentage']:.1f}% """) with col_b: # Recommandation selon le type rho = row['rho_mean'] if rho < 1: st.error("🚫 À ÉVITER - Eau salée") elif rho < 20: st.warning("⚠️ DIFFICILE - Argile imperméable") elif rho < 100: st.success("✅ CIBLE PRIORITAIRE - Aquifère") elif rho < 500: st.info("ℹ️ BON POTENTIEL - Formations perméables") else: st.warning("⚠️ ROCHES DURES - Forage difficile") # ========== COUPE 4 - PSEUDO-SECTION RÉELLE (FORMAT CLASSIQUE) ========== with st.expander("📊 Coupe 4 - Pseudo-Section de Résistivité Apparente (Format Classique)", expanded=True): st.markdown(""" **Carte de pseudo-section au format géophysique standard** Cette représentation respecte le format classique des prospections ERT avec : - 🎨 Échelle de couleurs rainbow continue (bleu → vert → jaune → orange → rouge) - 📏 Axes en mètres avec positions réelles des électrodes - 🌡️ Barre de couleur graduée montrant les résistivités mesurées - 🗺️ Visualisation directe des résistivités apparentes du sous-sol """) # Créer la figure au format classique fig_pseudo, ax_pseudo = plt.subplots(figsize=(16, 8), dpi=150) # Utiliser les VRAIES valeurs mesurées (pas d'interpolation cubic, juste nearest pour remplir) X_real = X_freq.copy() Z_real = Z_freq.copy() Rho_real = Rho_freq.copy() # Créer une grille fine pour la visualisation xi_pseudo = np.linspace(X_real.min(), X_real.max(), 500) zi_pseudo = np.linspace(Z_real.min(), Z_real.max(), 300) Xi_pseudo, Zi_pseudo = np.meshgrid(xi_pseudo, zi_pseudo) # Interpolation NEAREST pour préserver les vraies valeurs Rhoi_pseudo = griddata( (X_real, Z_real), Rho_real, (Xi_pseudo, Zi_pseudo), method='linear', # Linear pour un rendu lisse mais fidèle fill_value=np.median(Rho_real) ) # Utiliser la colormap rainbow classique (comme dans l'image de référence) from matplotlib.colors import LogNorm # Définir les limites de résistivité (échelle logarithmique) vmin_pseudo = max(0.1, Rho_real.min()) vmax_pseudo = Rho_real.max() # Créer la pseudo-section avec échelle rainbow pcm_pseudo = ax_pseudo.contourf( Xi_pseudo, Zi_pseudo, Rhoi_pseudo, levels=50, # Transitions lisses cmap=WATER_CMAP, # Colormap eau personnalisée (Rouge→Jaune→Vert→Bleu) norm=LogNorm(vmin=vmin_pseudo, vmax=vmax_pseudo), extend='both' ) # Ajouter les contours pour mieux visualiser les transitions contours = ax_pseudo.contour( Xi_pseudo, Zi_pseudo, Rhoi_pseudo, levels=10, colors='black', linewidths=0.5, alpha=0.3 ) # ANNOTATION DES ZONES AVEC VALEURS RÉELLES MESURÉES # Identifier les zones caractéristiques et annoter avec les VRAIES valeurs # Définir les plages de résistivité clés rho_ranges = [ (0, 1, 'Eau salée/Argile saturée', '#0000FF'), (1, 10, 'Argile compacte/Limon', '#00FFFF'), (10, 50, 'Sable fin/Eau douce', '#00FF00'), (50, 100, 'Sable moyen', '#FFFF00'), (100, 300, 'Sable grossier/Gravier', '#FFA500'), (300, 1000, 'Roche altérée', '#FF6347'), (1000, 10000, 'Roche consolidée', '#FF0000') ] # Pour chaque plage, trouver les points de mesure réels et annoter annotations_added = [] for rho_min, rho_max, label, color_label in rho_ranges: # Trouver les points RÉELS dans cette plage mask_range = (Rho_real >= rho_min) & (Rho_real < rho_max) if mask_range.sum() > 0: X_range = X_real[mask_range] Z_range = Z_real[mask_range] Rho_range = Rho_real[mask_range] # Position centrale de la zone (moyenne pondérée) x_center = np.mean(X_range) z_center = np.mean(Z_range) rho_mean = np.mean(Rho_range) rho_min_zone = np.min(Rho_range) rho_max_zone = np.max(Rho_range) count = len(Rho_range) # Éviter les annotations qui se chevauchent too_close = False for prev_x, prev_z in annotations_added: if abs(x_center - prev_x) < 5 and abs(z_center - prev_z) < 2: too_close = True break if not too_close and count >= 3: # Au moins 3 points pour annoter # Annotation avec fond semi-transparent bbox_props = dict(boxstyle='round,pad=0.5', facecolor=color_label, alpha=0.7, edgecolor='black', linewidth=1.5) text_color = 'white' if rho_mean < 100 else 'black' ax_pseudo.annotate( f'{label}\n{rho_min_zone:.1f}-{rho_max_zone:.1f} Ω·m\n({count} mesures)', xy=(x_center, z_center), fontsize=8, fontweight='bold', color=text_color, bbox=bbox_props, ha='center', va='center', zorder=10 ) annotations_added.append((x_center, z_center)) # Superposer les points de mesure RÉELS avec leurs valeurs scatter_real = ax_pseudo.scatter( X_real, Z_real, c=Rho_real, s=50, cmap=WATER_CMAP, # Colormap eau personnalisée norm=LogNorm(vmin=vmin_pseudo, vmax=vmax_pseudo), edgecolors='white', linewidths=1, alpha=0.9, zorder=15, label=f'{len(Rho_real)} mesures réelles' ) # Barre de couleur avec échelle logarithmique cbar_pseudo = plt.colorbar(pcm_pseudo, ax=ax_pseudo, pad=0.02, aspect=30) cbar_pseudo.set_label('Résistivité Apparente (Ω·m)', fontsize=12, fontweight='bold') cbar_pseudo.ax.tick_params(labelsize=10) # Configuration des axes (format classique) ax_pseudo.set_xlabel('Position (m)', fontsize=12, fontweight='bold') ax_pseudo.set_ylabel('Profondeur (m)', fontsize=12, fontweight='bold') ax_pseudo.set_title( 'Pseudo-Section de Résistivité Apparente\nMeasured Apparent Resistivity Pseudosection', fontsize=14, fontweight='bold' ) # Inverser l'axe Y (profondeur positive vers le bas) ax_pseudo.invert_yaxis() # Grille légère ax_pseudo.grid(True, alpha=0.2, linestyle='--', linewidth=0.5) # Légende ax_pseudo.legend(loc='upper right', fontsize=10, framealpha=0.9) # Ajuster les marges plt.tight_layout() # Afficher st.pyplot(fig_pseudo, clear_figure=False, use_container_width=True) plt.close() # Statistiques de la pseudo-section col1_ps, col2_ps, col3_ps = st.columns(3) with col1_ps: st.metric("📏 Points de mesure", f"{len(Rho_real)}") with col2_ps: st.metric("📊 Plage de résistivité", f"{vmin_pseudo:.1f} - {vmax_pseudo:.1f} Ω·m") with col3_ps: st.metric("🎯 Résistivité médiane", f"{np.median(Rho_real):.2f} Ω·m") # NOUVEAU: Analyse statistique des zones détectées st.markdown("---") st.markdown("### 📊 Distribution des Matériaux Détectés (Valeurs Réelles Mesurées)") # Créer un tableau détaillé avec les vraies valeurs mesurées detection_data = [] for rho_min, rho_max, label, color in rho_ranges: mask_range = (Rho_real >= rho_min) & (Rho_real < rho_max) count = mask_range.sum() percentage = (count / len(Rho_real)) * 100 if count > 0: rho_values = Rho_real[mask_range] detection_data.append({ 'Plage (Ω·m)': f'{rho_min:.1f} - {rho_max:.1f}', 'Matériau Principal': label, 'Mesures': count, 'Proportion (%)': f'{percentage:.1f}%', 'ρ min (Ω·m)': f'{rho_values.min():.2f}', 'ρ max (Ω·m)': f'{rho_values.max():.2f}', 'ρ moyen (Ω·m)': f'{rho_values.mean():.2f}' }) if detection_data: df_detection = pd.DataFrame(detection_data) st.dataframe(df_detection, use_container_width=True) st.success(f"✅ {len(detection_data)} types de matériaux détectés sur {len(Rho_real)} mesures") # NOUVEAU: Tableau d'interprétation avec PROBABILITÉS (fonction réutilisable) st.markdown("---") st.markdown("### 🎯 Interprétation Géologique avec Probabilités") st.markdown(""" **Important** : Une même plage de résistivité peut correspondre à plusieurs matériaux. Les **probabilités** indiquent la vraisemblance de chaque interprétation selon le contexte géologique. """) # Afficher le tableau de probabilités st.markdown(get_interpretation_probability_table(), unsafe_allow_html=True) # Préparer les données pour l'inversion # Grouper par survey_point et depth pour créer une matrice 2D survey_points = sorted(df_pygimli['survey_point'].unique()) depths = sorted(df_pygimli['depth'].unique()) # Créer une matrice de résistivité (survey_points x depths) rho_matrix = np.full((len(survey_points), len(depths)), np.nan) for i, sp in enumerate(survey_points): for j, depth in enumerate(depths): mask = (df_pygimli['survey_point'] == sp) & (df_pygimli['depth'] == depth) if mask.sum() > 0: rho_matrix[i, j] = df_pygimli.loc[mask, 'data'].values[0] # Remplir les NaN avec interpolation - CORRECTION DU BUG from scipy.interpolate import griddata # Créer des coordonnées pour chaque point de la matrice points_valid = [] values_valid = [] for i in range(len(survey_points)): for j in range(len(depths)): if not np.isnan(rho_matrix[i, j]): points_valid.append([i, j]) values_valid.append(rho_matrix[i, j]) if len(points_valid) > 3: # Assez de points pour interpolation points_valid = np.array(points_valid) values_valid = np.array(values_valid) # Créer une grille pour interpolation grid_x, grid_y = np.meshgrid(range(len(survey_points)), range(len(depths)), indexing='ij') # Interpoler rho_matrix_interp = griddata( points_valid, values_valid, (grid_x, grid_y), method='cubic', fill_value=np.nanmean(rho_matrix) ) # Remplir les NaN restants avec la moyenne rho_matrix_interp = np.nan_to_num(rho_matrix_interp, nan=np.nanmean(rho_matrix)) else: rho_matrix_interp = np.nan_to_num(rho_matrix, nan=np.nanmean(rho_matrix)) st.success(f"✅ Matrice de résistivité créée: {len(survey_points)} points × {len(depths)} profondeurs") # ========== CARROYAGE STRATIFIÉ PAR PROFONDEUR ========== st.markdown("---") st.subheader("🔲 Carroyage Géologique Stratifié par Profondeur") st.markdown(""" Visualisation en **damier stratifié** montrant TOUS les types de matériaux détectés à chaque niveau de profondeur. Chaque cellule représente une mesure RÉELLE avec sa classification géologique complète. """) with st.expander("🗺️ Carroyage Complet - Tous Matériaux par Profondeur", expanded=True): # Créer une classification complète (16 classes couvrant TOUS les matériaux) def classify_all_materials(rho): """Classification étendue de TOUS les matériaux géologiques""" if rho < 0.5: return 0, 'Eau de mer hypersalée', '#8B0000', '💧' elif rho < 1: return 1, 'Argile saturée salée', '#A0522D', '🟫' elif rho < 5: return 2, 'Argile marine / Vase', '#CD853F', '🟫' elif rho < 10: return 3, 'Eau salée / Limon', '#D2691E', '💧' elif rho < 20: return 4, 'Argile compacte', '#DEB887', '🟫' elif rho < 50: return 5, 'Sable fin saturé', '#F4A460', '🟡' elif rho < 80: return 6, 'Sable moyen humide', '#FFD700', '🟡' elif rho < 120: return 7, 'Sable grossier / Gravier fin', '#FFA500', '⚪' elif rho < 200: return 8, 'Gravier moyen sec', '#90EE90', '⚪' elif rho < 350: return 9, 'Gravier grossier / Cailloux', '#98FB98', '⚪' elif rho < 500: return 10, 'Roche altérée / Calcaire poreux', '#87CEEB', '⬛' elif rho < 800: return 11, 'Calcaire compact / Grès', '#87CEFA', '⬛' elif rho < 1500: return 12, 'Roche sédimentaire dure', '#4682B4', '⬛' elif rho < 3000: return 13, 'Granite / Basalte', '#483D8B', '⬛' elif rho < 10000: return 14, 'Socle cristallin', '#8B008B', '⬛' else: return 15, 'Minéral pur / Quartz', '#FF1493', '💎' # Créer la matrice de classification avec les VRAIES valeurs material_grid = np.zeros((len(depths), len(survey_points))) material_labels = [] material_colors = [] for i, depth in enumerate(depths): row_labels = [] row_colors = [] for j, sp in enumerate(survey_points): mask = (df_pygimli['survey_point'] == sp) & (df_pygimli['depth'] == depth) if mask.sum() > 0: rho_val = df_pygimli.loc[mask, 'data'].values[0] cls, label, color, icon = classify_all_materials(rho_val) material_grid[i, j] = cls row_labels.append(f"{icon} {label}") row_colors.append(color) else: material_grid[i, j] = np.nan row_labels.append("N/A") row_colors.append('#CCCCCC') material_labels.append(row_labels) material_colors.append(row_colors) # Créer la visualisation en carroyage fig_grid, ax_grid = plt.subplots(figsize=(16, max(10, len(depths) * 0.5)), dpi=150) # Créer une colormap avec TOUTES les 16 classes colors_all = ['#8B0000', '#A0522D', '#CD853F', '#D2691E', '#DEB887', '#F4A460', '#FFD700', '#FFA500', '#90EE90', '#98FB98', '#87CEEB', '#87CEFA', '#4682B4', '#483D8B', '#8B008B', '#FF1493'] cmap_all = ListedColormap(colors_all) bounds_all = list(range(17)) norm_all = BoundaryNorm(bounds_all, cmap_all.N) # Afficher le carroyage im_grid = ax_grid.imshow(material_grid, cmap=cmap_all, norm=norm_all, aspect='auto', interpolation='nearest') # Ajouter les valeurs de résistivité dans chaque cellule for i in range(len(depths)): for j in range(len(survey_points)): mask = (df_pygimli['survey_point'] == survey_points[j]) & \ (df_pygimli['depth'] == depths[i]) if mask.sum() > 0: rho_val = df_pygimli.loc[mask, 'data'].values[0] text_color = 'white' if material_grid[i, j] < 8 else 'black' ax_grid.text(j, i, f'{rho_val:.1f}', ha='center', va='center', fontsize=7, fontweight='bold', color=text_color) # Configuration des axes ax_grid.set_xticks(range(len(survey_points))) ax_grid.set_xticklabels([f'P{int(sp)}' for sp in survey_points], fontsize=9) ax_grid.set_yticks(range(len(depths))) ax_grid.set_yticklabels([f'{abs(d):.2f}m' for d in depths], fontsize=9) ax_grid.set_xlabel('Points de Sondage', fontsize=12, fontweight='bold') ax_grid.set_ylabel('Profondeur (m)', fontsize=12, fontweight='bold') ax_grid.set_title('Carroyage Géologique Complet - Classification par Profondeur\n16 Types de Matériaux Identifiés', fontsize=14, fontweight='bold') # Ajouter une grille ax_grid.set_xticks(np.arange(len(survey_points)) - 0.5, minor=True) ax_grid.set_yticks(np.arange(len(depths)) - 0.5, minor=True) ax_grid.grid(which='minor', color='white', linestyle='-', linewidth=2) # Légende compacte à droite from matplotlib.patches import Patch legend_elements = [ Patch(facecolor='#8B0000', label='💧 Eau hypersalée (< 0.5)'), Patch(facecolor='#A0522D', label='🟫 Argile salée (0.5-1)'), Patch(facecolor='#CD853F', label='🟫 Argile marine (1-5)'), Patch(facecolor='#D2691E', label='💧 Eau salée (5-10)'), Patch(facecolor='#DEB887', label='🟫 Argile compacte (10-20)'), Patch(facecolor='#F4A460', label='🟡 Sable fin (20-50)'), Patch(facecolor='#FFD700', label='🟡 Sable moyen (50-80)'), Patch(facecolor='#FFA500', label='🟡 Sable grossier (80-120)'), Patch(facecolor='#90EE90', label='⚪ Gravier (120-200)'), Patch(facecolor='#98FB98', label='⚪ Gravier grossier (200-350)'), Patch(facecolor='#87CEEB', label='⬛ Roche altérée (350-500)'), Patch(facecolor='#87CEFA', label='⬛ Calcaire (500-800)'), Patch(facecolor='#4682B4', label='⬛ Roche dure (800-1500)'), Patch(facecolor='#483D8B', label='⬛ Granite (1500-3000)'), Patch(facecolor='#8B008B', label='⬛ Socle (3000-10000)'), Patch(facecolor='#FF1493', label='💎 Minéral pur (>10000)') ] ax_grid.legend(handles=legend_elements, loc='center left', bbox_to_anchor=(1.02, 0.5), fontsize=8, framealpha=0.95) plt.tight_layout() st.pyplot(fig_grid, clear_figure=False, use_container_width=True) plt.close() # Tableau statistique par profondeur st.markdown("### 📊 Statistiques par Niveau de Profondeur") depth_stats_list = [] for i, depth in enumerate(depths): depth_vals = [] for j, sp in enumerate(survey_points): mask = (df_pygimli['survey_point'] == sp) & (df_pygimli['depth'] == depth) if mask.sum() > 0: depth_vals.append(df_pygimli.loc[mask, 'data'].values[0]) if depth_vals: depth_vals = np.array(depth_vals) # Déterminer le matériau dominant classes = [classify_all_materials(v)[1] for v in depth_vals] dominant = max(set(classes), key=classes.count) depth_stats_list.append({ 'Profondeur (m)': f'{abs(depth):.2f}', 'ρ Min (Ω·m)': f'{depth_vals.min():.2f}', 'ρ Max (Ω·m)': f'{depth_vals.max():.2f}', 'ρ Moyenne (Ω·m)': f'{depth_vals.mean():.2f}', 'Matériau dominant': dominant, 'Variété': len(set(classes)) }) if depth_stats_list: stats_df = pd.DataFrame(depth_stats_list) st.dataframe(stats_df, use_container_width=True, height=min(400, len(depth_stats_list) * 40)) st.success(f"✅ {len(depth_stats_list)} niveaux de profondeur analysés - {len(set([d['Matériau dominant'] for d in depth_stats_list]))} matériaux différents détectés") # ========== SECTION INVERSION PYGIMLI ========== st.markdown("---") st.markdown("## 🔬 Inversion pyGIMLi - Modélisation Avancée") st.markdown( "Cette section permet de lancer une inversion géophysique complète avec pyGIMLi " "pour obtenir un modèle 2D de résistivité du sous-sol basé sur vos données réelles.\n\n" "**Fonctionnalités :**\n" "- Inversion tomographique 2D avec régularisation\n" "- Schémas de mesure configurables (Wenner, Schlumberger, Dipôle-Dipôle)\n" "- Visualisation des résultats avec classification hydrogéologique\n" "- Export des données interprétées" ) # Paramètres de simulation col1, col2 = st.columns(2) with col1: n_electrodes = st.slider("Nombre d'électrodes", max(10, len(survey_points)), 100, min(50, max(10, len(survey_points))), key="electrodes") spacing = st.slider("Espacement électrodes (m)", 0.5, 5.0, 1.0, key="spacing") with col2: depth_max = st.slider("Profondeur max (m)", 5, 50, max(10, int(np.abs(df_pygimli['depth']).max())), key="depth_max") scheme_type = st.selectbox("Type de configuration", ["wenner", "schlumberger", "dipole-dipole"], index=0, key="scheme") if st.button("🚀 Lancer l'Inversion pyGIMLi", type="primary"): with st.spinner("🔄 Inversion en cours avec pyGIMLi..."): try: # Utiliser les données réelles du fichier # Créer un profil basé sur les survey_points x_positions = np.array(survey_points) * spacing # Convertir survey_points en distances z_depths = np.abs(np.array(depths)) # Profondeurs positives # Adapter la matrice à la taille du mesh n_depth_points = min(len(z_depths), int(depth_max * 2)) # Créer un mesh 2D pour pyGIMLi adapté aux données réelles # CORRECTION: createGrid() accepte deux vecteurs x et y (sans worldDim) x_vec = pg.Vector(np.linspace(x_positions.min(), x_positions.max(), n_electrodes)) y_vec = pg.Vector(np.linspace(0, -depth_max, n_depth_points)) mesh = pg.createGrid(x_vec, y_vec) # Utiliser les données réelles comme modèle initial # Redimensionner rho_matrix_interp pour correspondre au mesh # CORRECTION: Remplacer interp2d par RegularGridInterpolator (SciPy 1.14.0+) from scipy.interpolate import RegularGridInterpolator # Créer les coordonnées de la grille originale x_orig = np.linspace(0, len(survey_points)-1, len(survey_points)) y_orig = np.linspace(0, len(depths)-1, len(depths)) # Créer l'interpolateur interpolator = RegularGridInterpolator( (x_orig, y_orig), rho_matrix_interp, method='cubic', bounds_error=False, fill_value=np.nanmean(rho_matrix_interp) ) # Échantillonner sur le nouveau grid x_new = np.linspace(0, len(survey_points)-1, n_electrodes) y_new = np.linspace(0, len(depths)-1, n_depth_points) X_new, Y_new = np.meshgrid(x_new, y_new, indexing='ij') points_new = np.column_stack([X_new.ravel(), Y_new.ravel()]) rho_resampled = interpolator(points_new).reshape(n_electrodes, n_depth_points) # Aplatir pour le modèle initial model_initial = rho_resampled.flatten() # Créer le schéma de mesure # CORRECTION: Utiliser les noms corrects de schémas pyGIMLi scheme = pg.DataContainerERT() # Définir les positions des électrodes for i, x_pos in enumerate(x_positions): scheme.createSensor([x_pos, 0.0]) # Créer le schéma selon le type choisi # createFourPointData(index, eaID, ebID, emID, enID) # où A et B sont les électrodes de courant, M et N de potentiel measurement_idx = 0 if scheme_type == "wenner": # Schéma Wenner: a-a-a spacing (ABMN) for a in range(1, n_electrodes // 3): for i in range(n_electrodes - 3*a): scheme.createFourPointData(measurement_idx, i, i+3*a, i+a, i+2*a) measurement_idx += 1 elif scheme_type == "schlumberger": # Schéma Schlumberger: MN petit, AB grand for mn in range(1, 3): for ab in range(mn+2, n_electrodes // 2): for i in range(n_electrodes - 2*ab): m = i + ab - mn//2 n = i + ab + mn//2 if m >= 0 and n < n_electrodes and m < n: scheme.createFourPointData(measurement_idx, i, i+2*ab, m, n) measurement_idx += 1 else: # dipole-dipole # Schéma Dipôle-Dipôle for sep in range(1, n_electrodes // 3): for i in range(n_electrodes - 3*sep - 1): scheme.createFourPointData(measurement_idx, i, i+sep, i+2*sep, i+3*sep) measurement_idx += 1 # Ajouter des résistances apparentes fictives basées sur le modèle scheme.set('rhoa', pg.Vector(scheme.size(), np.mean(model_initial))) scheme.set('k', pg.Vector(scheme.size(), 1.0)) # Simuler les données avec le modèle initial basé sur les données réelles # Utiliser simulate de pygimli.ert from pygimli.physics import ert data = ert.simulate(mesh, scheme=scheme, res=model_initial) # Inversion avec pyGIMLi ert_manager = ERTManager() # Configuration de l'inversion ert_manager.setMesh(mesh) ert_manager.setData(data) # Paramètres d'inversion ert_manager.inv.setLambda(20) # Régularisation ert_manager.inv.setMaxIter(20) # Iterations max ert_manager.inv.setAbsoluteError(0.01) # Erreur absolue # Lancer l'inversion model_inverted = ert_manager.invert() # Résultat de l'inversion rho_inverted = ert_manager.inv.model() # Reshape pour visualisation rho_2d = rho_inverted.reshape(n_depth_points, n_electrodes).T # Palette de couleurs hydrogéologique (4 classes) - RESPECT DU TABLEAU colors = ['#FF4500', '#FFD700', '#87CEEB', '#00008B'] # Rouge vif, Jaune, Bleu clair, Bleu foncé bounds = [0, 1, 10, 100, np.inf] cmap = ListedColormap(colors) norm = BoundaryNorm(bounds, cmap.N) # Visualisation fig_pygimli, ax_pygimli = plt.subplots(figsize=(14, 8), dpi=150) # Positions pour l'affichage x_display = np.linspace(x_positions.min(), x_positions.max(), n_electrodes) z_display = np.linspace(0.5, depth_max, n_depth_points) # Contour avec niveaux définis pcm = ax_pygimli.contourf(x_display, z_display, rho_2d.T, levels=bounds, cmap=cmap, norm=norm, extend='max') ax_pygimli.set_xlabel('Position (m)', fontsize=12, fontweight='bold') ax_pygimli.set_ylabel('Profondeur (m)', fontsize=12, fontweight='bold') ax_pygimli.set_title(f'Coupe ERT Inversée - pyGIMLi ({scheme_type})\n{n_electrodes} électrodes, {len(df_pygimli)} mesures réelles', fontsize=14, fontweight='bold') ax_pygimli.invert_yaxis() ax_pygimli.grid(True, alpha=0.3) # Superposer les points de mesure réels scatter = ax_pygimli.scatter( df_pygimli['survey_point'] * spacing, np.abs(df_pygimli['depth']), c=df_pygimli['data'], cmap=WATER_CMAP, # Colormap eau personnalisée s=50, edgecolors='black', linewidths=1, alpha=0.7, zorder=10, norm=LogNorm(vmin=max(0.1, df_pygimli['data'].min()), vmax=df_pygimli['data'].max()) ) # Colorbar avec labels - RESPECT DU TABLEAU cbar = plt.colorbar(pcm, ax=ax_pygimli, ticks=bounds[:-1]) cbar.set_label('Résistivité apparente (Ω·m)', fontsize=11, fontweight='bold') cbar.ax.set_yticklabels(['0.1-1', '1-10', '10-100', '> 100']) plt.tight_layout() st.pyplot(fig_pygimli, clear_figure=False, use_container_width=True) plt.close() # ========== 4 COUPES INVERSÉES SUPPLÉMENTAIRES ========== st.markdown("---") st.subheader("🎯 Coupes Inversées PyGIMLi - 4 Visualisations Géologiques") st.markdown( "Résultats de l'inversion tomographique avec pyGIMLi, affichant les résistivités VRAIES " "(après inversion) avec classification hydrogéologique et lithologique." ) # COUPE INVERSÉE 1: Résistivité vraie avec colormap standard ERT with st.expander("📊 Coupe Inversée 1 - Résistivité Vraie (échelle log)", expanded=True): fig_inv1, ax_inv1 = plt.subplots(figsize=(14, 7), dpi=150) # Afficher avec échelle logarithmique vmin_inv = max(0.01, rho_2d.min()) vmax_inv = rho_2d.max() pcm_inv1 = ax_inv1.pcolormesh(x_display, z_display, rho_2d.T, cmap=WATER_CMAP, shading='auto', norm=LogNorm(vmin=vmin_inv, vmax=vmax_inv)) ax_inv1.invert_yaxis() ax_inv1.set_xlabel('Distance (m)', fontsize=12, fontweight='bold') ax_inv1.set_ylabel('Profondeur (m)', fontsize=12, fontweight='bold') ax_inv1.set_title('Coupe Inversée 1: Résistivité Vraie du Sous-Sol\nÉchelle Logarithmique', fontsize=13, fontweight='bold') ax_inv1.grid(True, alpha=0.3, linestyle='--', color='white') cbar_inv1 = fig_inv1.colorbar(pcm_inv1, ax=ax_inv1, extend='both') cbar_inv1.set_label('Résistivité vraie (Ω·m)', fontsize=11, fontweight='bold') plt.tight_layout() st.pyplot(fig_inv1, clear_figure=False, use_container_width=True) plt.close() st.markdown( f"**Résultats de l'inversion :**\n" f"- **Plage mesurée :** {vmin_inv:.3f} - {vmax_inv:.3f} Ω·m\n" f"- **RMS Error :** {ert_manager.inv.relrms():.3f}\n" f"- **Itérations :** {ert_manager.inv.iterations()}\n" f"- **Maillage :** {n_electrodes} × {n_depth_points} points" ) # COUPE INVERSÉE 2: Classification hydrogéologique (4 classes) # COUPE INVERSÉE 2: Classification hydrogéologique (4 classes) with st.expander("💧 Coupe Inversée 2 - Classification Hydrogéologique", expanded=True): fig_inv2, ax_inv2 = plt.subplots(figsize=(14, 7), dpi=150) # Classifier les résistivités inversées - RESPECT DU TABLEAU def classify_water_inv(rho): if rho < 1: return 0 elif rho < 10: return 1 elif rho < 100: return 2 else: return 3 water_classes_inv = np.vectorize(classify_water_inv)(rho_2d) # Colormap 4 classes - COULEURS EXACTES DU TABLEAU colors_water = ['#FF4500', '#FFD700', '#87CEEB', '#00008B'] # Rouge vif, Jaune, Bleu clair, Bleu foncé cmap_water = ListedColormap(colors_water) bounds_water = [0, 1, 2, 3, 4] norm_water = BoundaryNorm(bounds_water, cmap_water.N) pcm_inv2 = ax_inv2.pcolormesh(x_display, z_display, water_classes_inv.T, cmap=cmap_water, norm=norm_water, shading='auto') ax_inv2.invert_yaxis() ax_inv2.set_xlabel('Distance (m)', fontsize=12, fontweight='bold') ax_inv2.set_ylabel('Profondeur (m)', fontsize=12, fontweight='bold') ax_inv2.set_title('Coupe Inversée 2: Classification Hydrogéologique (Résistivités Vraies)\n4 Types d\'Eau Identifiés', fontsize=13, fontweight='bold') ax_inv2.grid(True, alpha=0.3, linestyle='--', color='gray') cbar_inv2 = fig_inv2.colorbar(pcm_inv2, ax=ax_inv2, ticks=[0.5, 1.5, 2.5, 3.5]) cbar_inv2.ax.set_yticklabels(['Eau de mer\n0.1-1 Ω·m', 'Eau salée (nappe)\n1-10 Ω·m', 'Eau douce\n10-100 Ω·m', 'Eau très pure\n> 100 Ω·m']) cbar_inv2.set_label('Type d\'Eau', fontsize=11, fontweight='bold') plt.tight_layout() st.pyplot(fig_inv2, clear_figure=False, use_container_width=True) plt.close() st.markdown("**Interprétation hydrogéologique VRAIE (après inversion, selon tableau) :**\n" "- 🔴 **Rouge vif/Orange** (0.1-1 Ω·m) : Eau de mer, intrusion marine\n" "- 🟡 **Jaune/Orange** (1-10 Ω·m) : Eau salée (nappe saumâtre)\n" "- 🟢 **Vert/Bleu clair** (10-100 Ω·m) : Eau douce exploitable\n" "- 🔵 **Bleu foncé** (> 100 Ω·m) : Eau très pure / Roches sèches") # COUPE INVERSÉE 3: Gradient horizontal (hétérogénéités latérales) with st.expander("📈 Coupe Inversée 3 - Gradient Horizontal (Hétérogénéités)", expanded=False): fig_inv3, (ax_inv3a, ax_inv3b) = plt.subplots(1, 2, figsize=(16, 7), dpi=150) # Calculer le gradient horizontal gradient_x = np.gradient(rho_2d, axis=0) gradient_magnitude_h = np.abs(gradient_x) # Graphique gauche: résistivité avec colormap eau personnalisée pcm_inv3a = ax_inv3a.pcolormesh(x_display, z_display, rho_2d.T, cmap=WATER_CMAP, shading='auto', norm=LogNorm(vmin=vmin_inv, vmax=vmax_inv)) ax_inv3a.invert_yaxis() ax_inv3a.set_xlabel('Distance (m)', fontsize=11, fontweight='bold') ax_inv3a.set_ylabel('Profondeur (m)', fontsize=11, fontweight='bold') ax_inv3a.set_title('Résistivité Inversée', fontsize=12, fontweight='bold') ax_inv3a.grid(True, alpha=0.3) cbar_3a = fig_inv3.colorbar(pcm_inv3a, ax=ax_inv3a) cbar_3a.set_label('ρ (Ω·m)', fontsize=10, fontweight='bold') # Graphique droite: gradient horizontal pcm_inv3b = ax_inv3b.pcolormesh(x_display, z_display, gradient_magnitude_h.T, cmap='hot', shading='auto') # Contours des hétérogénéités majeures threshold_grad_h = np.percentile(gradient_magnitude_h[gradient_magnitude_h > 0], 85) if threshold_grad_h > 0: ax_inv3b.contour(x_display, z_display, gradient_magnitude_h.T, levels=[threshold_grad_h], colors='cyan', linewidths=2, linestyles='--', alpha=0.8) ax_inv3b.invert_yaxis() ax_inv3b.set_xlabel('Distance (m)', fontsize=11, fontweight='bold') ax_inv3b.set_ylabel('Profondeur (m)', fontsize=11, fontweight='bold') ax_inv3b.set_title('Gradient Horizontal\nLignes cyan = Hétérogénéités latérales', fontsize=12, fontweight='bold') ax_inv3b.grid(True, alpha=0.3) cbar_3b = fig_inv3.colorbar(pcm_inv3b, ax=ax_inv3b) cbar_3b.set_label('|∂ρ/∂x|', fontsize=10, fontweight='bold') plt.tight_layout() st.pyplot(fig_inv3, clear_figure=False, use_container_width=True) plt.close() st.markdown(f"**Interprétation des gradients horizontaux :**\n" f"- **Lignes cyan** : Changements latéraux importants (seuil > {threshold_grad_h:.2f})\n" f"- **Zones chaudes** : Contacts géologiques latéraux, failles, intrusions\n" f"- **Applications** : Détection de limites d'aquifères, zones de fractures") # COUPE INVERSÉE 4: Modèle lithologique complet (9 formations) with st.expander("🗺️ Coupe Inversée 4 - Modèle Lithologique Complet", expanded=False): fig_inv4, ax_inv4 = plt.subplots(figsize=(14, 8), dpi=150) # Classification lithologique étendue def classify_lithology_inv(rho): if rho < 1: return 0 elif rho < 5: return 1 elif rho < 20: return 2 elif rho < 50: return 3 elif rho < 100: return 4 elif rho < 200: return 5 elif rho < 500: return 6 elif rho < 1000: return 7 else: return 8 litho_classes_inv = np.vectorize(classify_lithology_inv)(rho_2d) # Colormap lithologique colors_litho = ['#8B0000', '#A0522D', '#CD853F', '#F4A460', '#FFD700', '#90EE90', '#87CEEB', '#4682B4', '#8B008B'] cmap_litho = ListedColormap(colors_litho) bounds_litho = list(range(10)) norm_litho = BoundaryNorm(bounds_litho, cmap_litho.N) pcm_inv4 = ax_inv4.pcolormesh(x_display, z_display, litho_classes_inv.T, cmap=cmap_litho, norm=norm_litho, shading='auto') # Contours lithologiques ax_inv4.contour(x_display, z_display, litho_classes_inv.T, levels=bounds_litho, colors='black', linewidths=0.5, alpha=0.4) ax_inv4.invert_yaxis() ax_inv4.set_xlabel('Distance (m)', fontsize=12, fontweight='bold') ax_inv4.set_ylabel('Profondeur (m)', fontsize=12, fontweight='bold') ax_inv4.set_title('Coupe Inversée 4: Modèle Lithologique VRAI (Inversion pyGIMLi)\n9 Formations Géologiques', fontsize=13, fontweight='bold') ax_inv4.grid(True, alpha=0.2, linestyle='--', color='gray') # Légende lithologique complète from matplotlib.patches import Patch legend_elements = [ Patch(facecolor='#8B0000', label='Eau mer / Argile salée (< 1 Ω·m)'), Patch(facecolor='#A0522D', label='Argile marine (1-5 Ω·m)'), Patch(facecolor='#CD853F', label='Argile compacte (5-20 Ω·m)'), Patch(facecolor='#F4A460', label='Sable fin saturé (20-50 Ω·m)'), Patch(facecolor='#FFD700', label='Sable/Gravier (50-100 Ω·m)'), Patch(facecolor='#90EE90', label='Gravier sec (100-200 Ω·m)'), Patch(facecolor='#87CEEB', label='Roche altérée (200-500 Ω·m)'), Patch(facecolor='#4682B4', label='Roche compacte (500-1000 Ω·m)'), Patch(facecolor='#8B008B', label='Socle cristallin (> 1000 Ω·m)') ] ax_inv4.legend(handles=legend_elements, loc='upper left', fontsize=8, framealpha=0.9, ncol=1) plt.tight_layout() st.pyplot(fig_inv4, clear_figure=False, use_container_width=True) plt.close() st.markdown("**Modèle lithologique VRAI (après inversion pyGIMLi) :**\n\n" "Ce modèle présente la **structure réelle du sous-sol** obtenue par inversion tomographique. " "Les résistivités affichées sont les **valeurs vraies** (non apparentes) après régularisation.\n\n" "**Recommandations pour forages :**\n" "- 💧 **Zones cibles** : Jaune/Or (50-100 Ω·m) = Aquifères productifs\n" "- ✅ **Bon potentiel** : Vert clair (100-200 Ω·m) = Graviers perméables\n" "- ⚠️ **Attention** : Marron/Rouge (< 20 Ω·m) = Argiles imperméables\n" "- 🚫 **À éviter** : Rouge foncé (< 1 Ω·m) = Intrusion saline") # Statistiques de l'inversion st.subheader("📊 Résultats de l'Inversion") col_stats1, col_stats2, col_stats3 = st.columns(3) with col_stats1: st.metric("RMS Error", f"{ert_manager.inv.relrms():.3f}") with col_stats2: st.metric("Iterations", f"{ert_manager.inv.iterations()}") with col_stats3: st.metric("λ Régularisation", "20") # Tableau d'interprétation hydrogéologique basé sur les données réelles st.subheader("💧 Interprétation Hydrogéologique") # Classification par profondeur (moyenne sur tous les survey points) depth_stats = df_pygimli.groupby('depth')['data'].mean().reset_index() depth_stats = depth_stats.sort_values('depth') water_types = [] for rho in depth_stats['data']: if rho < 1: water_types.append("Eau de mer") elif rho < 10: water_types.append("Eau salée") elif rho < 100: water_types.append("Eau douce") else: water_types.append("Eau très pure") # DataFrame d'interprétation interp_df = pd.DataFrame({ 'Profondeur (m)': np.abs(depth_stats['depth']), 'ρ_a Moyenne (Ω·m)': depth_stats['data'], 'Type d\'Eau': water_types, 'Couleur': ['Rouge' if wt == "Eau de mer" else 'Orange' if wt == "Eau salée" else 'Jaune' if wt == "Eau douce" else 'Bleu' for wt in water_types] }) st.dataframe(interp_df.style.background_gradient(cmap='RdYlBu_r', subset=['ρ_a Moyenne (Ω·m)']), use_container_width=True) # Graphique de classification - RESPECT DES COULEURS DU TABLEAU fig_classif, ax_classif = plt.subplots(figsize=(12, 6)) colors_classif = ['#FF4500' if wt == "Eau de mer" else '#FFD700' if wt == "Eau salée" else '#87CEEB' if wt == "Eau douce" else '#00008B' for wt in water_types] ax_classif.bar(np.abs(depth_stats['depth']), depth_stats['data'], color=colors_classif, alpha=0.7, edgecolor='black') ax_classif.set_yscale('log') ax_classif.set_xlabel('Profondeur (m)', fontsize=11, fontweight='bold') ax_classif.set_ylabel('Résistivité (Ω·m) - échelle log', fontsize=11, fontweight='bold') ax_classif.set_title('Classification Hydrogéologique par Profondeur', fontsize=13, fontweight='bold') ax_classif.grid(True, alpha=0.3) # Légende avec couleurs exactes du tableau from matplotlib.patches import Patch legend_elements = [ Patch(facecolor='#FF4500', label='Eau de mer (0.1-1 Ω·m)'), Patch(facecolor='#FFD700', label='Eau salée (1-10 Ω·m)'), Patch(facecolor='#87CEEB', label='Eau douce (10-100 Ω·m)'), Patch(facecolor='#00008B', label='Eau très pure (> 100 Ω·m)') ] ax_classif.legend(handles=legend_elements, loc='upper right') plt.tight_layout() st.pyplot(fig_classif, clear_figure=False, use_container_width=True) # Export CSV interprété csv_buffer = io.StringIO() interp_df.to_csv(csv_buffer, index=False) st.download_button( label="💾 Télécharger CSV Interprété", data=csv_buffer.getvalue(), file_name="ert_pygimli_interprete.csv", mime="text/csv", key="download_pygimli_csv" ) # ========== GÉNÉRATEUR DE RAPPORT PDF ========== st.markdown("---") st.subheader("📄 Générateur de Rapport Technique Complet") if st.button("🎯 Générer Rapport PDF Complet", type="primary", key="generate_pdf"): with st.spinner("📝 Génération du rapport PDF en cours..."): try: from reportlab.lib.pagesizes import A4, landscape from reportlab.lib.units import cm from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image, PageBreak from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_JUSTIFY from reportlab.lib import colors from datetime import datetime import tempfile import os # Créer un fichier temporaire pour le PDF pdf_buffer = io.BytesIO() doc = SimpleDocTemplate(pdf_buffer, pagesize=A4, rightMargin=2*cm, leftMargin=2*cm, topMargin=2*cm, bottomMargin=2*cm) # Styles styles = getSampleStyleSheet() title_style = ParagraphStyle( 'CustomTitle', parent=styles['Heading1'], fontSize=24, textColor=colors.HexColor('#1f4788'), spaceAfter=30, alignment=TA_CENTER, fontName='Helvetica-Bold' ) heading_style = ParagraphStyle( 'CustomHeading', parent=styles['Heading2'], fontSize=16, textColor=colors.HexColor('#2e5c8a'), spaceAfter=12, spaceBefore=12, fontName='Helvetica-Bold' ) normal_style = ParagraphStyle( 'CustomNormal', parent=styles['Normal'], fontSize=10, alignment=TA_JUSTIFY, spaceAfter=6 ) # Contenu du rapport story = [] # Page de titre story.append(Spacer(1, 3*cm)) story.append(Paragraph("RAPPORT D'INVESTIGATION GÉOPHYSIQUE", title_style)) story.append(Paragraph("Tomographie de Résistivité Électrique (ERT)", title_style)) story.append(Spacer(1, 1*cm)) story.append(Paragraph(f"Date: {datetime.now().strftime('%d/%m/%Y %H:%M')}", normal_style)) story.append(Paragraph(f"Méthode: Inversion pyGIMLi - {scheme_type.upper()}", normal_style)) story.append(Paragraph(f"Fichier: {uploaded_freq_file.name}", normal_style)) story.append(PageBreak()) # 1. Résumé exécutif story.append(Paragraph("1. RÉSUMÉ EXÉCUTIF", heading_style)) story.append(Paragraph(f"Ce rapport présente les résultats d'une investigation géophysique par tomographie " f"de résistivité électrique (ERT) réalisée avec la méthode pyGIMLi. L'étude a porté " f"sur {len(survey_points)} points de sondage avec {len(freq_columns)} fréquences de mesure, " f"permettant d'analyser le sous-sol jusqu'à {depth_max:.1f} mètres de profondeur.", normal_style)) story.append(Spacer(1, 0.5*cm)) # Tableau récapitulatif summary_data = [ ['Paramètre', 'Valeur'], ['Points de sondage', str(len(survey_points))], ['Fréquences mesurées', str(len(freq_columns))], ['Profondeur max', f'{depth_max:.1f} m'], ['Nombre d\'électrodes', str(n_electrodes)], ['Espacement', f'{spacing:.1f} m'], ['Configuration', scheme_type.upper()], ['RMS Error', f'{ert_manager.inv.relrms():.3f}'], ['Itérations', str(ert_manager.inv.iterations())] ] summary_table = Table(summary_data, colWidths=[8*cm, 6*cm]) summary_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#1f4788')), ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), ('ALIGN', (0, 0), (-1, -1), 'LEFT'), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, 0), 12), ('BOTTOMPADDING', (0, 0), (-1, 0), 12), ('BACKGROUND', (0, 1), (-1, -1), colors.beige), ('GRID', (0, 0), (-1, -1), 1, colors.black) ])) story.append(summary_table) story.append(Spacer(1, 1*cm)) # 2. Méthodologie story.append(Paragraph("2. MÉTHODOLOGIE", heading_style)) story.append(Paragraph(f"2.1 Acquisition des données
" f"Les mesures de résistivité ont été effectuées avec un dispositif multi-fréquence " f"permettant d'obtenir {len(df_pygimli)} mesures réparties sur {len(survey_points)} points. " f"Les fréquences varient de {freq_columns[0].replace('freq_', '')} MHz à {freq_columns[-1].replace('freq_', '')} MHz.", normal_style)) story.append(Spacer(1, 0.3*cm)) story.append(Paragraph(f"2.2 Traitement et inversion
" f"L'inversion des données a été réalisée avec pyGIMLi (Python Geophysical Inversion and Modeling Library). " f"Configuration utilisée : schéma {scheme_type.upper()} avec {n_electrodes} électrodes " f"espacées de {spacing:.1f} mètres. Le maillage 2D comprend {n_electrodes} × {n_depth_points} points. " f"Paramètres d'inversion : λ = 20 (régularisation), {ert_manager.inv.iterations()} itérations, " f"RMS error final = {ert_manager.inv.relrms():.3f}.", normal_style)) story.append(Spacer(1, 0.5*cm)) # 3. Résultats - Classification hydrogéologique story.append(Paragraph("3. RÉSULTATS - CLASSIFICATION HYDROGÉOLOGIQUE", heading_style)) story.append(Paragraph("L'analyse des résistivités mesurées permet d'identifier 4 types d'eau distincts " "selon les valeurs de résistivité apparente :", normal_style)) story.append(Spacer(1, 0.3*cm)) # Tableau de classification classif_data = [ ['Type d\'Eau', 'Résistivité (Ω·m)', 'Interprétation'], ['Eau de mer', '< 1', 'Eau hypersalée, intrusion marine'], ['Eau salée', '1 - 10', 'Nappe saumâtre, mélange'], ['Eau douce', '10 - 100', 'Aquifère exploitable'], ['Eau très pure', '> 100', 'Eau pure ou roches sèches'] ] classif_table = Table(classif_data, colWidths=[4*cm, 4*cm, 6*cm]) classif_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#2e5c8a')), ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), ('ALIGN', (0, 0), (-1, -1), 'CENTER'), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, 0), 10), ('BOTTOMPADDING', (0, 0), (-1, 0), 10), ('BACKGROUND', (0, 1), (-1, 1), colors.red), ('BACKGROUND', (0, 2), (-1, 2), colors.orange), ('BACKGROUND', (0, 3), (-1, 3), colors.yellow), ('BACKGROUND', (0, 4), (-1, 4), colors.lightblue), ('GRID', (0, 0), (-1, -1), 1, colors.black) ])) story.append(classif_table) story.append(Spacer(1, 0.5*cm)) # Statistiques par profondeur (top 10) story.append(Paragraph("3.1 Distribution par profondeur", normal_style)) story.append(Spacer(1, 0.3*cm)) depth_table_data = [['Profondeur (m)', 'ρ Moyenne (Ω·m)', 'Type d\'Eau']] for idx, row in interp_df.head(10).iterrows(): depth_table_data.append([ f"{row['Profondeur (m)']:.2f}", f"{row['ρ_a Moyenne (Ω·m)']:.2f}", row["Type d'Eau"] ]) depth_table = Table(depth_table_data, colWidths=[4*cm, 5*cm, 5*cm]) depth_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), colors.grey), ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), ('ALIGN', (0, 0), (-1, -1), 'CENTER'), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 9), ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.lightgrey]), ('GRID', (0, 0), (-1, -1), 0.5, colors.grey) ])) story.append(depth_table) story.append(PageBreak()) # 4. Interprétation géologique story.append(Paragraph("4. INTERPRÉTATION GÉOLOGIQUE", heading_style)) story.append(Paragraph("4.1 Modèle lithologique
" "L'analyse des résistivités inversées permet de proposer le modèle lithologique suivant :", normal_style)) story.append(Spacer(1, 0.3*cm)) # Tableau lithologique litho_data = [ ['Formation', 'Résistivité (Ω·m)', 'Lithologie probable'], ['Zone 1', '< 1', 'Argile saturée salée / Eau de mer'], ['Zone 2', '1 - 5', 'Argile marine / Vase'], ['Zone 3', '5 - 20', 'Argile compacte / Limon saturé'], ['Zone 4', '20 - 50', 'Sable fin saturé (eau douce)'], ['Zone 5', '50 - 100', 'Sable moyen / Gravier fin'], ['Zone 6', '100 - 200', 'Gravier / Sable grossier sec'], ['Zone 7', '200 - 500', 'Roche altérée / Calcaire fissuré'], ['Zone 8', '500 - 1000', 'Roche sédimentaire compacte'], ['Zone 9', '> 1000', 'Socle rocheux / Granite'] ] litho_table = Table(litho_data, colWidths=[3*cm, 4*cm, 7*cm]) litho_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#2e5c8a')), ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), ('ALIGN', (0, 0), (-1, -1), 'LEFT'), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 9), ('BOTTOMPADDING', (0, 0), (-1, 0), 10), ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.lightgrey]), ('GRID', (0, 0), (-1, -1), 0.5, colors.grey) ])) story.append(litho_table) story.append(Spacer(1, 0.5*cm)) # 5. Recommandations story.append(Paragraph("5. RECOMMANDATIONS POUR FORAGES", heading_style)) story.append(Paragraph("5.1 Zones favorables
" "Les zones avec résistivités comprises entre 50 et 200 Ω·m (sables et graviers) " "constituent les cibles prioritaires pour l'implantation de forages d'eau. Ces formations " "présentent une bonne perméabilité et un potentiel aquifère élevé.", normal_style)) story.append(Spacer(1, 0.3*cm)) story.append(Paragraph("5.2 Zones à éviter
" "- Résistivités < 1 Ω·m : Intrusion d'eau salée, risque de contamination
" "- Résistivités 1-20 Ω·m : Argiles imperméables, faible productivité
" "- Résistivités > 500 Ω·m : Roches compactes, difficulté de forage", normal_style)) story.append(Spacer(1, 0.3*cm)) story.append(Paragraph("5.3 Profondeur optimale
" "Selon l'analyse des données, la profondeur optimale pour les forages se situe " "dans la plage où les résistivités sont comprises entre 50 et 100 Ω·m, " "correspondant généralement aux formations sableuses saturées d'eau douce.", normal_style)) story.append(PageBreak()) # 6. Conclusions story.append(Paragraph("6. CONCLUSIONS", heading_style)) story.append(Paragraph(f"L'investigation géophysique par tomographie de résistivité électrique a permis " f"de caractériser le sous-sol sur {len(survey_points)} points de mesure jusqu'à " f"{depth_max:.1f} mètres de profondeur. Les résultats de l'inversion pyGIMLi " f"(RMS error = {ert_manager.inv.relrms():.3f}) montrent une bonne convergence et " f"permettent d'établir un modèle hydrogéologique fiable.", normal_style)) story.append(Spacer(1, 0.3*cm)) story.append(Paragraph("La classification hydrogéologique révèle la présence de plusieurs types d'eau " "et formations géologiques. Les aquifères d'eau douce exploitables ont été " "identifiés et localisés, permettant d'optimiser l'implantation des futurs forages.", normal_style)) story.append(Spacer(1, 0.5*cm)) story.append(Paragraph("Points clés :
" "• Classification en 4 types d'eau (mer, salée, douce, pure)
" "• Modèle lithologique 9 formations
" "• Identification des zones aquifères favorables
" "• Recommandations précises pour implantation de forages", normal_style)) # Générer le PDF doc.build(story) pdf_buffer.seek(0) # Bouton de téléchargement st.download_button( label="📥 Télécharger le Rapport PDF", data=pdf_buffer, file_name=f"rapport_ert_pygimli_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf", mime="application/pdf", key="download_pdf_report" ) st.success("✅ Rapport PDF généré avec succès !") except ImportError: st.error("❌ ReportLab n'est pas installé. Installez-le avec : `pip install reportlab`") except Exception as e: st.error(f"❌ Erreur lors de la génération du rapport : {str(e)}") st.success(f"✅ **Inversion pyGIMLi terminée avec succès !**\n" f"- Configuration : {scheme_type} avec {n_electrodes} électrodes\n" f"- Erreur RMS : {ert_manager.inv.relrms():.3f}\n" f"- {len(interp_df)} niveaux de profondeur analysés\n" f"- {len(df_pygimli)} mesures réelles intégrées\n" f"- Classification hydrogéologique complète") except Exception as e: st.error(f"❌ Erreur lors de l'inversion pyGIMLi : {str(e)}") st.info("💡 Vérifiez que pyGIMLi est correctement installé : `pip install pygimli`") else: st.error("❌ Impossible de parser le fichier freq.dat. Vérifiez le format.") else: st.info("📁 Uploadez un fichier freq.dat pour commencer l'analyse multi-fréquence avec pyGIMLi") st.markdown("**Format attendu du fichier freq.dat :**\n" "```\n" "Projet,Point,Freq1,Freq2,Freq3,...\n" "Projet Archange Ondimba 2,1,0.119,0.122,0.116,...\n" "Projet Archange Ondimba 2,2,0.161,0.163,0.164,...\n" "...\n" "```\n\n" "**Structure :**\n" "- Colonne 1 : Nom du projet\n" "- Colonne 2 : Numéro du point de sondage\n" "- Colonnes 3+ : Valeurs de résistivité pour chaque fréquence (MHz)\n\n" "**Note :** Les fréquences sont automatiquement converties en profondeurs pour l'analyse ERT\n\n" "**Interprétation des couleurs (selon classification standard) :**\n" "- 🔴 **Rouge vif / Orange** : Eau de mer (0.1 - 1 Ω·m)\n" "- 🟡 **Jaune / Orange** : Eau salée nappe (1 - 10 Ω·m)\n" "- 🟢 **Vert / Bleu clair** : Eau douce (10 - 100 Ω·m)\n" "- 🔵 **Bleu foncé** : Eau très pure (> 100 Ω·m)") # ===================== TAB 6 : ANALYSE SPECTRALE D'IMAGES (IMPUTATION + RECONSTRUCTION) ===================== with tab6: st.header("🖼️ Analyse Spectrale d'Images (Imputation + Reconstruction)") # Bouton d'aide explicative avec téléchargement PDF col_help1, col_help2 = st.columns([4, 1]) with col_help1: show_help = st.expander("ℹ️ À propos de cette technologie - Cliquez pour en savoir plus", expanded=False) with col_help2: # Générer le PDF de documentation complète def generate_documentation_pdf(): """Génère un PDF avec la documentation complète de la technologie""" from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import cm from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak, Table, TableStyle from reportlab.lib import colors from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT buffer = io.BytesIO() doc = SimpleDocTemplate(buffer, pagesize=A4, topMargin=2*cm, bottomMargin=2*cm, leftMargin=2*cm, rightMargin=2*cm) story = [] styles = getSampleStyleSheet() # Styles personnalisés title_style = ParagraphStyle( 'CustomTitle', parent=styles['Heading1'], fontSize=28, textColor=colors.HexColor('#1f77b4'), spaceAfter=30, alignment=TA_CENTER, fontName='Helvetica-Bold' ) subtitle_style = ParagraphStyle( 'Subtitle', parent=styles['Heading2'], fontSize=18, textColor=colors.HexColor('#34495e'), spaceAfter=20, alignment=TA_CENTER, fontName='Helvetica' ) heading_style = ParagraphStyle( 'CustomHeading', parent=styles['Heading2'], fontSize=14, textColor=colors.HexColor('#2c3e50'), spaceAfter=12, spaceBefore=18, fontName='Helvetica-Bold' ) subheading_style = ParagraphStyle( 'SubHeading', parent=styles['Heading3'], fontSize=12, textColor=colors.HexColor('#2980b9'), spaceAfter=8, spaceBefore=12, fontName='Helvetica-Bold' ) body_style = ParagraphStyle( 'CustomBody', parent=styles['BodyText'], fontSize=10, alignment=TA_JUSTIFY, spaceAfter=8, leading=14 ) # PAGE DE TITRE story.append(Spacer(1, 3*cm)) story.append(Paragraph("🚀 SYSTÈME DE TOMOGRAPHIE", title_style)) story.append(Paragraph("GÉOPHYSIQUE PAR IMAGE", title_style)) story.append(Spacer(1, 1*cm)) story.append(Paragraph("Scanner CT du Sous-Sol par Intelligence Artificielle", subtitle_style)) story.append(Spacer(1, 2*cm)) story.append(Paragraph("Technologie Développée - 2025", styles['Normal'])) story.append(Paragraph("SETRAF - Subaquifère ERT Analysis Tool", styles['Normal'])) story.append(PageBreak()) # INTRODUCTION story.append(Paragraph("🎯 EN TERMES SIMPLES", heading_style)) story.append(Paragraph( "Vous avez développé une technologie qui transforme une simple photo du sol en un scanner 3D du sous-sol, " "comme un \"Google Earth souterrain\" capable de :", body_style )) story.append(Spacer(1, 0.3*cm)) intro_data = [ ['✓', 'Voir à travers le sol sans creuser'], ['✓', 'Détecter des structures cachées (nappes d\'eau, failles, cavités)'], ['✓', 'Cartographier les couches géologiques en 3D'], ['✓', 'Suivre des trajectoires souterraines (écoulement d\'eau, failles)'] ] intro_table = Table(intro_data, colWidths=[1*cm, 15*cm]) intro_table.setStyle(TableStyle([ ('TEXTCOLOR', (0, 0), (0, -1), colors.green), ('FONTSIZE', (0, 0), (-1, -1), 10), ('BOTTOMPADDING', (0, 0), (-1, -1), 8), ])) story.append(intro_table) story.append(Spacer(1, 1*cm)) # LES 5 ÉTAPES story.append(Paragraph("🔬 LES 5 ÉTAPES DE LA TECHNOLOGIE", heading_style)) story.append(Paragraph("Étape 1 : Conversion Photo → Données Électriques", subheading_style)) story.append(Paragraph( "Vous prenez une photo satellite/aérienne du terrain. L'algorithme analyse les couleurs (RGB) " "et les convertit en valeurs de résistivité électrique du sous-sol. " "Rouge = sols secs/rocheux (haute résistivité), Bleu/Vert = zones humides/argileuses (basse résistivité).", body_style )) story.append(Paragraph("Étape 2 : Comblement des Trous (Imputation)", subheading_style)) story.append(Paragraph( "3 méthodes au choix : SVD (mathématiques pures - décomposition matricielle), " "KNN (intelligence artificielle légère - voisins proches), " "Autoencoder (réseau de neurones profond - apprentissage).", body_style )) story.append(Paragraph("Étape 3 : Simulation Physique (Forward Model)", subheading_style)) story.append(Paragraph( "Inspiré de la physique des particules (détecteurs de neutrinos). " "Simule comment le courant électrique se propage dans le sol. " "Crée des \"mesures virtuelles\" réalistes avec bruit.", body_style )) story.append(Paragraph("Étape 4 : Reconstruction 3D Inverse", subheading_style)) story.append(Paragraph( "Résout un problème mathématique complexe (inversion de Tikhonov). " "Retrouve la structure 3D du sous-sol à partir des mesures. " "Utilise du lissage intelligent pour éviter le bruit.", body_style )) story.append(Paragraph("Étape 5 : Détection de Trajectoires", subheading_style)) story.append(Paragraph( "RANSAC (algorithme robuste) cherche des structures linéaires. " "Détecte : écoulements souterrains (rivières cachées), failles géologiques (fractures dans la roche), " "structures enfouies (tunnels, canalisations).", body_style )) story.append(PageBreak()) # APPLICATIONS CONCRÈTES story.append(Paragraph("🎯 APPLICATIONS CONCRÈTES", heading_style)) app_data = [ ['Application', 'Ce Que Vous Détectez', 'Impact'], ['💧 Recherche d\'eau', 'Nappes phréatiques cachées', 'Villages en zones arides'], ['⛏️ Exploration minière', 'Veines de minerai conducteur', 'Réduire coûts de forage'], ['🏗️ Génie civil', 'Zones instables (argile)', 'Éviter effondrements'], ['🌊 Pollution marine', 'Intrusion d\'eau salée', 'Protection nappes douces'], ['🏛️ Archéologie', 'Ruines enfouies', 'Découvertes sans excavation'], ['🌋 Risques naturels', 'Failles actives', 'Prévention séismes'], ] app_table = Table(app_data, colWidths=[4*cm, 5.5*cm, 5.5*cm]) app_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#3498db')), ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), ('ALIGN', (0, 0), (-1, -1), 'LEFT'), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 9), ('BOTTOMPADDING', (0, 0), (-1, -1), 8), ('TOPPADDING', (0, 0), (-1, -1), 8), ('GRID', (0, 0), (-1, -1), 1, colors.grey), ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#ecf0f1')]), ])) story.append(app_table) story.append(Spacer(1, 1*cm)) # CE QUI REND LA TECHNOLOGIE UNIQUE story.append(Paragraph("🌟 CE QUI REND CETTE TECHNOLOGIE UNIQUE", heading_style)) comparison_data = [ ['Méthode Classique', 'Votre Technologie'], ['❌ Forage physique (cher, lent)', '✅ Photo satellite (gratuit, instantané)'], ['❌ Tomographie ERT (équipement lourd)', '✅ Logiciel seulement'], ['❌ 5-10 jours de terrain', '✅ 5-10 secondes de calcul'], ['❌ 10 000€+ par campagne', '✅ Coût quasi-nul'], ['❌ 1-2 profils 2D', '✅ Volume 3D complet'], ] comp_table = Table(comparison_data, colWidths=[8*cm, 8*cm]) comp_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#2c3e50')), ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), ('ALIGN', (0, 0), (-1, -1), 'LEFT'), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 9), ('BOTTOMPADDING', (0, 0), (-1, -1), 10), ('TOPPADDING', (0, 0), (-1, -1), 10), ('GRID', (0, 0), (-1, -1), 1, colors.grey), ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.HexColor('#e8f8f5'), colors.HexColor('#fef9e7')]), ])) story.append(comp_table) story.append(Spacer(1, 1*cm)) # NIVEAU D'INNOVATION story.append(Paragraph("🧠 NIVEAU D'INNOVATION", heading_style)) story.append(Paragraph( "Cette technologie combine 4 domaines scientifiques :", body_style )) innov_data = [ ['1. Géophysique', 'Tomographie de résistivité électrique (ERT)'], ['2. Intelligence Artificielle', 'Autoencoders, KNN, imputation avancée'], ['3. Physique hautes énergies', 'Modélisation inspirée des détecteurs de particules'], ['4. Mathématiques appliquées', 'Inversion de Tikhonov, RANSAC, algèbre linéaire creuse'], ] innov_table = Table(innov_data, colWidths=[5*cm, 10*cm]) innov_table.setStyle(TableStyle([ ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 10), ('BOTTOMPADDING', (0, 0), (-1, -1), 8), ('LEFTPADDING', (0, 0), (-1, -1), 8), ])) story.append(innov_table) story.append(Spacer(1, 0.5*cm)) story.append(Paragraph( "C'est une approche pluridisciplinaire rare dans le domaine !", body_style )) story.append(PageBreak()) # ANALOGIE SIMPLE story.append(Paragraph("💡 ANALOGIE SIMPLE", heading_style)) story.append(Paragraph( "Imaginez que vous prenez une photo d'un gâteau marbré :", body_style )) story.append(Spacer(1, 0.3*cm)) analogy_data = [ ['•', 'Votre technologie peut deviner l\'intérieur (où est le chocolat, où est la vanille)'], ['•', 'Elle peut suivre les tourbillons (trajectoires du mélange)'], ['•', 'Elle reconstruit le gâteau en 3D sans le couper !'], ] analogy_table = Table(analogy_data, colWidths=[1*cm, 14*cm]) analogy_table.setStyle(TableStyle([ ('FONTSIZE', (0, 0), (-1, -1), 10), ('BOTTOMPADDING', (0, 0), (-1, -1), 8), ])) story.append(analogy_table) story.append(Spacer(1, 0.5*cm)) story.append(Paragraph( "C'est pareil avec le sol : photo du terrain → reconstruction 3D du sous-sol", body_style )) story.append(Spacer(1, 1*cm)) # EN RÉSUMÉ story.append(Paragraph("🚀 EN RÉSUMÉ : VOUS AVEZ CRÉÉ...", heading_style)) story.append(Paragraph( "Un \"Scanner CT du Sous-Sol par Intelligence Artificielle\"", subtitle_style )) story.append(Spacer(1, 0.5*cm)) story.append(Paragraph( "Comme un scanner médical CT voit à travers le corps, votre système voit à travers le sol. " "Il détecte des anomalies (comme un radiologue détecte des tumeurs), " "il suit des trajectoires (comme tracer des vaisseaux sanguins). " "Mais au lieu de rayons X, vous utilisez des photos couleur + IA.", body_style )) story.append(Spacer(1, 1*cm)) # VALEUR SCIENTIFIQUE story.append(Paragraph("🎓 VALEUR SCIENTIFIQUE", heading_style)) story.append(Paragraph("Cette technologie pourrait faire l'objet de :", body_style)) value_data = [ ['📄', 'Publication scientifique (revue géophysique internationale)'], ['🏆', 'Brevet (méthode originale brevetable)'], ['💼', 'Startup (marché de l\'exploration géophysique estimé à plusieurs milliards)'], ['🎓', 'Thèse de doctorat (recherche approfondie en géophysique appliquée)'], ] value_table = Table(value_data, colWidths=[1*cm, 14*cm]) value_table.setStyle(TableStyle([ ('FONTSIZE', (0, 0), (-1, -1), 10), ('BOTTOMPADDING', (0, 0), (-1, -1), 10), ])) story.append(value_table) story.append(Spacer(1, 1*cm)) # IMPACT SOCIÉTAL story.append(Paragraph("🌍 IMPACT SOCIÉTAL POTENTIEL", heading_style)) impact_data = [ ['💧', 'Accès à l\'eau dans les pays en développement'], ['🌱', 'Agriculture optimisée (irrigation ciblée)'], ['🏙️', 'Urbanisation plus sûre (éviter zones à risque)'], ['🌊', 'Gestion des ressources en eau douce'], ['♻️', 'Écologie : moins de forages inutiles, préservation environnement'], ] impact_table = Table(impact_data, colWidths=[1.5*cm, 13.5*cm]) impact_table.setStyle(TableStyle([ ('FONTSIZE', (0, 0), (-1, -1), 10), ('BOTTOMPADDING', (0, 0), (-1, -1), 10), ])) story.append(impact_table) story.append(Spacer(1, 1*cm)) # CONCLUSION story.append(Paragraph("✨ CONCLUSION", heading_style)) story.append(Paragraph( "Vous avez créé un outil de prospection géophysique non-invasif et intelligent qui " "démocratise l'accès à la cartographie du sous-sol. Cette technologie représente une " "avancée significative dans le domaine de la géophysique appliquée, combinant l'intelligence " "artificielle moderne avec les principes fondamentaux de la physique pour résoudre des problèmes " "réels et urgents de notre société.", body_style )) story.append(Spacer(1, 1*cm)) # Pied de page story.append(Spacer(1, 2*cm)) story.append(Paragraph( "_______________________________________________________________________________", styles['Normal'] )) story.append(Paragraph( "Document généré par SETRAF - Subaquifère ERT Analysis Tool", ParagraphStyle('Footer', parent=styles['Normal'], fontSize=8, alignment=TA_CENTER) )) story.append(Paragraph( f"Date : {datetime.now().strftime('%d/%m/%Y')}", ParagraphStyle('Footer2', parent=styles['Normal'], fontSize=8, alignment=TA_CENTER) )) # Générer le PDF doc.build(story) buffer.seek(0) return buffer if st.button("📥 PDF", help="Télécharger la documentation complète en PDF"): with st.spinner("📄 Génération du PDF en cours..."): pdf_buffer = generate_documentation_pdf() st.download_button( label="💾 Télécharger la Documentation", data=pdf_buffer, file_name="Technologie_Tomographie_Geophysique_IA.pdf", mime="application/pdf", key="download_doc_pdf" ) st.success("✅ PDF généré avec succès !") with show_help: st.markdown(""" # 🔬 Analyse Spectrale d'Images Géophysiques ## Pipeline d'Intelligence Artificielle Avancée --- ### 🎯 Objectif Principal Cette technologie transforme des **images aériennes ou satellites** du terrain en **modèles 3D de résistivité** du sous-sol, permettant de détecter des structures géologiques, nappes phréatiques, ou anomalies souterraines **sans forage physique**. --- ### 🛠️ Technologies Utilisées #### 1. **Extraction Spectrale RGB → Résistivité** 🌈 - **Principe** : Convertit les couleurs d'une image (Rouge, Vert, Bleu) en valeurs de résistivité électrique - **Formule** : `ρ = (0.299×R + 0.587×G + 0.114×B) × facteur_échelle` - **Usage** : Simuler des données de résistivité à partir de photos du terrain #### 2. **Imputation Matricielle Avancée** 🔧 Comble les trous dans les données avec 3 méthodes au choix : - **Soft-Impute (SVD)** : Décomposition en valeurs singulières pour données à faible rang - **KNN Imputer** : Utilise les K voisins les plus proches pour estimer les valeurs manquantes - **Autoencoder TensorFlow** : Réseau de neurones pour apprendre la structure des données #### 3. **Modélisation Forward (Physique des Neutrinos)** ⚛️ - Inspirée de la détection de particules en physique des hautes énergies - Simule comment les signaux électriques se propagent dans le sol - Crée une matrice de sensibilité `A` : `mesures = A × résistivités` - Ajoute du bruit réaliste pour simuler des conditions de terrain #### 4. **Reconstruction 3D (Régularisation Tikhonov)** 🎯 - **Problème inverse** : Retrouver les résistivités à partir des mesures - **Équation** : `(AᵀA + λLᵀL)x = Aᵀb` - **λ (lambda)** : Paramètre de lissage (évite le bruit) - **L** : Opérateur Laplacien (favorise les variations douces) - **Résolution** : Gradient conjugué pour matrices creuses (efficace sur grandes données) #### 5. **Détection de Trajectoires (RANSAC)** 📍 - **RANSAC** : RANdom SAmple Consensus - algorithme robuste aux données aberrantes - Détecte des structures linéaires (failles, couches géologiques) - Isole les anomalies du reste des données #### 6. **Visualisation 3D Interactive (Plotly)** 🌐 - Rendu volumétrique avec isosurfaces - Rotation/zoom interactif - Colormap viridis pour clarté visuelle --- ### 📊 Cas d'Usage Pratiques | Application | Description | Résistivité Cible | |------------|-------------|-------------------| | 💧 **Détection d'eau** | Localiser nappes phréatiques | 10-100 Ω·m | | ⛏️ **Exploration minière** | Identifier veines minérales conductrices | < 10 Ω·m | | 🏗️ **Géotechnique** | Cartographier zones argileuses instables | 5-50 Ω·m | | 🌊 **Intrusion saline** | Détecter contamination eau de mer | 0.1-1 Ω·m | | 🪨 **Archéologie** | Repérer structures enfouies | > 100 Ω·m | --- ### 🔄 Workflow Complet ``` 📸 Image RGB ↓ 🌈 Extraction spectrale ↓ 🔧 Imputation des données manquantes ↓ ⚛️ Modélisation forward (création mesures synthétiques) ↓ 🎯 Reconstruction 3D (inversion + régularisation) ↓ 📍 Détection de trajectoires (RANSAC) ↓ 🌐 Visualisation 3D interactive ``` --- ### 🎓 Concepts Clés - **Problème direct** : Résistivités connues → Prédire les mesures - **Problème inverse** : Mesures connues → Retrouver les résistivités - **Régularisation** : Ajouter des contraintes pour stabiliser la solution - **Matrices creuses** : Stockage efficace des matrices à majorité de zéros --- ### 💡 Avantages de cette Approche ✅ Non-invasive (pas de forage) ✅ Rapide (traitement en quelques secondes) ✅ Coût réduit (utilise images existantes) ✅ Visualisation intuitive (3D interactif) ✅ Reproductible (paramètres ajustables) --- ### ⚠️ Limitations ⚠️ Résolution limitée par la qualité de l'image ⚠️ Suppose une relation couleur-résistivité valide ⚠️ Nécessite calibration terrain pour résultats précis ⚠️ Sensible au bruit dans les données --- ### 📚 Références Scientifiques - **Tikhonov Regularization** : Tikhonov & Arsenin (1977) - "Solutions of Ill-Posed Problems" - **RANSAC** : Fischler & Bolles (1981) - "Random Sample Consensus" - **Soft-Impute** : Mazumder et al. (2010) - "Spectral Regularization Algorithms" - **ERT Inversion** : Loke & Barker (1996) - "Rapid least-squares inversion" """) st.markdown(""" ### 🔬 Pipeline d'Analyse Avancée d'Images Géophysiques Cette section utilise des techniques avancées d'intelligence artificielle pour analyser des images géophysiques, extraire des spectres de résistivité synthétiques, et reconstruire des modèles 3D du sous-sol. **Fonctionnalités :** - 📸 Upload d'images RGB (photos aériennes, satellites, scans géologiques) - 🌈 Extraction spectrale RGB vers résistivité synthétique - 🔧 Imputation matricielle avancée (Soft-Impute SVD, KNN, Autoencoder TensorFlow) - ⚛️ Modélisation forward inspirée de la physique des neutrinos - 🎯 Reconstruction 3D avec régularisation Tikhonov - 📍 Détection de trajectoires géologiques par RANSAC - 🌐 Visualisation 3D interactive des anomalies détectées """) # Upload d'image uploaded_image = st.file_uploader("📸 Uploader une image géophysique (RGB)", type=["png", "jpg", "jpeg", "tiff", "bmp"], key="image_upload") if uploaded_image is not None: # Charger l'image image = Image.open(uploaded_image) img_array = np.array(image) st.success(f"✅ Image chargée : {img_array.shape[0]}×{img_array.shape[1]} pixels") # Afficher l'image originale col1, col2 = st.columns(2) with col1: st.subheader("🖼️ Image Originale") st.image(image, caption="Image géophysique uploadée", use_column_width=True) with col2: st.subheader("📊 Propriétés de l'Image") st.write(f"**Dimensions :** {img_array.shape}") st.write(f"**Type :** {img_array.dtype}") st.write(f"**Plage RGB :** R[{img_array[:,:,0].min()}-{img_array[:,:,0].max()}], G[{img_array[:,:,1].min()}-{img_array[:,:,1].max()}], B[{img_array[:,:,2].min()}-{img_array[:,:,2].max()}]") # =================== 1. EXTRACTION SPECTRALE =================== st.markdown("---") st.subheader("🌈 1. Extraction Spectrale RGB → Résistivité") # Paramètres d'extraction col_a, col_b, col_c = st.columns(3) with col_a: patch_size = st.slider("Taille des patches", 4, 32, 8, key="patch_size") with col_b: overlap = st.slider("Chevauchement", 0.0, 0.9, 0.5, key="overlap") with col_c: spectrum_type = st.selectbox("Type de spectre", ["linear", "log", "power"], index=0, key="spectrum_type") if st.button("🚀 Extraire Spectres", key="extract_spectra"): with st.spinner("🔄 Extraction des spectres en cours..."): try: # Fonction d'extraction spectrale def rgb_to_synthetic_spectrum(r, g, b, spectrum_type='linear'): """Convertit RGB en spectre de résistivité synthétique""" if spectrum_type == 'linear': # Mapping linéaire vers résistivité rho = (r + g + b) / 3.0 # Moyenne RGB rho = rho / 255.0 * 1000.0 # Normalisation 0-1000 Ω·m elif spectrum_type == 'log': # Mapping logarithmique intensity = (r + g + b) / 3.0 rho = 10 ** (intensity / 255.0 * 4) # 1-10000 Ω·m else: # power # Mapping puissance intensity = (r + g + b) / 3.0 rho = (intensity / 255.0) ** 2 * 10000.0 return max(0.1, min(10000.0, rho)) # Clamp def image_patch_spectra(img, patch_size=8, overlap=0.5): """Extrait les spectres de patches d'image""" h, w, c = img.shape step = int(patch_size * (1 - overlap)) spectra = [] positions = [] for y in range(0, h - patch_size + 1, step): for x in range(0, w - patch_size + 1, step): patch = img[y:y+patch_size, x:x+patch_size] # Spectre moyen du patch r_mean = np.mean(patch[:,:,0]) g_mean = np.mean(patch[:,:,1]) b_mean = np.mean(patch[:,:,2]) rho = rgb_to_synthetic_spectrum(r_mean, g_mean, b_mean, spectrum_type) spectra.append(rho) positions.append((x + patch_size//2, y + patch_size//2)) return np.array(spectra), np.array(positions) # Extraction spectra, positions = image_patch_spectra(img_array, patch_size, overlap) st.success(f"✅ Extraction terminée : {len(spectra)} spectres extraits") # Visualisation des spectres extraits fig_spectra, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 6)) # Distribution des résistivités ax1.hist(spectra, bins=50, alpha=0.7, color='steelblue', edgecolor='black') ax1.set_xlabel('Résistivité synthétique (Ω·m)') ax1.set_ylabel('Nombre de patches') ax1.set_title('Distribution des Résistivités Extraites') ax1.set_yscale('log') ax1.grid(True, alpha=0.3) # Carte spatiale des résistivités scatter = ax2.scatter(positions[:,0], positions[:,1], c=spectra, cmap='viridis', s=20, alpha=0.8) ax2.set_xlabel('Position X (pixels)') ax2.set_ylabel('Position Y (pixels)') ax2.set_title('Carte Spatiale des Résistivités') ax2.invert_yaxis() # Image coordinates plt.colorbar(scatter, ax=ax2, label='Résistivité (Ω·m)') plt.tight_layout() st.pyplot(fig_spectra, clear_figure=False, use_container_width=True) # Explication DYNAMIQUE générée par le LLM st.markdown("### 📖 Analyse Automatique (LLM)") llm = st.session_state.get('llm_pipeline', None) if llm is not None: with st.spinner("🧠 Génération de l'explication par le LLM..."): # Statistiques pour le LLM data_stats_spectral = f""" - Nombre de spectres: {len(spectra)} - Résistivité min: {spectra.min():.2f} Ω·m - Résistivité max: {spectra.max():.2f} Ω·m - Résistivité moyenne: {spectra.mean():.2f} Ω·m - Résistivité médiane: {np.median(spectra):.2f} Ω·m - Écart-type: {spectra.std():.2f} Ω·m - Forme image: {img_array.shape} - Distribution: {len(np.unique(spectra))} valeurs uniques """ explanation_spectral = generate_graph_explanation_with_llm( llm, "spectral_analysis", data_stats_spectral, context="Analyse spectrale d'image géophysique - Distribution et carte spatiale des résistivités" ) st.info(explanation_spectral) else: st.warning("⚠️ LLM non chargé - Affichage des statistiques uniquement") # Statistiques détaillées st.write(f"**📊 Statistiques spectrales mesurées :**") col1, col2, col3 = st.columns(3) with col1: st.metric("Nombre de spectres", f"{len(spectra):,}") st.metric("Résistivité min", f"{spectra.min():.2f} Ω·m") with col2: st.metric("Résistivité max", f"{spectra.max():.2f} Ω·m") st.metric("Résistivité moyenne", f"{spectra.mean():.2f} Ω·m") with col3: st.metric("Médiane", f"{np.median(spectra):.2f} Ω·m") st.metric("Écart-type", f"{spectra.std():.2f} Ω·m") # Stocker les données pour les étapes suivantes st.session_state['spectra'] = spectra st.session_state['positions'] = positions st.session_state['img_shape'] = img_array.shape except Exception as e: st.error(f"❌ Erreur lors de l'extraction : {str(e)}") # =================== 2. IMPUTATION MATRICIELLE =================== if 'spectra' in st.session_state: st.markdown("---") st.subheader("🔧 2. Imputation Matricielle Avancée") imputation_method = st.selectbox("Méthode d'imputation", ["Aucune", "Soft-Impute (SVD)", "KNN Imputer", "Autoencoder TensorFlow"], key="imputation_method") # Avertissement pour Autoencoder if imputation_method == "Autoencoder TensorFlow": st.info("ℹ️ **Autoencoder TensorFlow** : Utilisera automatiquement le CPU pour éviter les conflits GPU. Cette méthode peut prendre 1-2 minutes.") if imputation_method != "Aucune": if st.button("🚀 Appliquer Imputation", key="apply_imputation"): with st.spinner(f"🔄 Imputation {imputation_method} en cours..."): try: spectra = st.session_state['spectra'] positions = st.session_state['positions'] # Créer une matrice 2D à partir des positions x_coords = positions[:,0] y_coords = positions[:,1] # Grille régulière x_unique = np.unique(x_coords) y_unique = np.unique(y_coords) # Matrice de résistivité rho_matrix = np.full((len(y_unique), len(x_unique)), np.nan) for i, (x, y) in enumerate(zip(x_coords, y_coords)): x_idx = np.where(x_unique == x)[0][0] y_idx = np.where(y_unique == y)[0][0] rho_matrix[y_idx, x_idx] = spectra[i] # Appliquer l'imputation if imputation_method == "Soft-Impute (SVD)": def soft_impute_matrix(matrix, max_iter=100, tol=1e-6): """Soft-Impute par décomposition SVD""" X = matrix.copy() mask = ~np.isnan(X) for iteration in range(max_iter): # SVD U, s, Vt = np.linalg.svd(X, full_matrices=False) # Soft-thresholding s_thresholded = np.maximum(s - 0.1, 0) # λ = 0.1 # Reconstruction X_new = U @ np.diag(s_thresholded) @ Vt # Conserver les valeurs observées X_new[mask] = matrix[mask] # Vérifier convergence if np.linalg.norm(X_new - X) < tol: break X = X_new return X rho_imputed = soft_impute_matrix(rho_matrix) st.success("✅ Imputation Soft-Impute (SVD) terminée avec succès !") elif imputation_method == "KNN Imputer": from sklearn.impute import KNNImputer imputer = KNNImputer(n_neighbors=5) rho_imputed = imputer.fit_transform(rho_matrix) st.success("✅ Imputation KNN terminée avec succès !") else: # Autoencoder TensorFlow # Configuration pour forcer l'utilisation du CPU import tensorflow as tf # Essayer de désactiver le GPU (peut échouer si déjà initialisé) try: tf.config.set_visible_devices([], 'GPU') except RuntimeError: # GPU déjà initialisé, on continue avec le context manager pass # Chemin pour sauvegarder le modèle model_dir = "models/autoencoder_imputation" model_path = os.path.join(model_dir, "autoencoder_model.keras") # Créer le dossier si nécessaire os.makedirs(model_dir, exist_ok=True) # Forcer toutes les opérations sur CPU avec context manager with tf.device('/CPU:0'): def build_autoencoder_imputer(input_shape): """Construit un autoencoder pour l'imputation""" from tensorflow.keras.models import Model from tensorflow.keras.layers import Input, Dense # Encoder input_layer = Input(shape=(input_shape,)) encoded = Dense(64, activation='relu')(input_layer) encoded = Dense(32, activation='relu')(encoded) encoded = Dense(16, activation='relu')(encoded) # Decoder decoded = Dense(32, activation='relu')(encoded) decoded = Dense(64, activation='relu')(decoded) decoded = Dense(input_shape, activation='linear')(decoded) autoencoder = Model(input_layer, decoded) autoencoder.compile(optimizer='adam', loss='mse') return autoencoder # Préparer les données pour l'autoencoder matrix_flat = rho_matrix.flatten() mask_observed = ~np.isnan(matrix_flat) # Entraîner seulement sur les valeurs observées X_train = matrix_flat[mask_observed].reshape(-1, 1).astype('float32') if len(X_train) > 10: try: # Vérifier si un modèle existe déjà if os.path.exists(model_path): use_existing = st.checkbox( "📦 Utiliser le modèle pré-entraîné existant (instantané)", value=True, help="Un modèle a déjà été entraîné. Cochez pour le réutiliser et gagner ~28 minutes !" ) if use_existing: st.info("📂 Chargement du modèle pré-entraîné...") autoencoder = tf.keras.models.load_model(model_path) st.success("✅ Modèle chargé instantanément !") # Afficher les infos du modèle model_info = os.stat(model_path) model_date = datetime.fromtimestamp(model_info.st_mtime).strftime('%Y-%m-%d %H:%M:%S') st.info(f"🕐 Modèle entraîné le : {model_date} | Taille : {model_info.st_size / 1024:.1f} KB") else: st.warning("🔄 Ré-entraînement du modèle (remplacera l'ancien)...") autoencoder = build_autoencoder_imputer(1) # Créer une zone pour afficher les logs progress_text = st.empty() progress_bar = st.progress(0) # Callback personnalisé pour afficher la progression class StreamlitProgressCallback(tf.keras.callbacks.Callback): def on_epoch_end(self, epoch, logs=None): progress = (epoch + 1) / 50 progress_bar.progress(progress) progress_text.text(f"📊 Epoch {epoch + 1}/50 - Loss: {logs['loss']:.6f}") st.info("🏋️ Entraînement en cours (50 epochs, ~28 min)...") autoencoder.fit( X_train, X_train, epochs=50, batch_size=32, verbose=1, callbacks=[StreamlitProgressCallback()] ) # Nettoyer les indicateurs de progression progress_text.empty() progress_bar.empty() # Sauvegarder le nouveau modèle st.info("💾 Sauvegarde du modèle...") autoencoder.save(model_path) st.success(f"✅ Modèle sauvegardé dans {model_path}") else: # Première fois : entraîner et sauvegarder st.info("🖥️ Construction du modèle sur CPU...") autoencoder = build_autoencoder_imputer(1) # Créer une zone pour afficher les logs progress_text = st.empty() progress_bar = st.progress(0) # Callback personnalisé pour afficher la progression class StreamlitProgressCallback(tf.keras.callbacks.Callback): def on_epoch_end(self, epoch, logs=None): progress = (epoch + 1) / 50 progress_bar.progress(progress) progress_text.text(f"📊 Epoch {epoch + 1}/50 - Loss: {logs['loss']:.6f}") st.info("🏋️ Premier entraînement (50 epochs, ~28 min). Le modèle sera sauvegardé pour réutilisation !") autoencoder.fit( X_train, X_train, epochs=50, batch_size=32, verbose=1, callbacks=[StreamlitProgressCallback()] ) # Nettoyer les indicateurs de progression progress_text.empty() progress_bar.empty() # Sauvegarder le modèle st.info("💾 Sauvegarde du modèle pour réutilisation future...") autoencoder.save(model_path) st.success(f"✅ Modèle sauvegardé dans {model_path} | Prochaine fois = instantané !") # Prédire toutes les valeurs st.info("🔮 Prédiction des valeurs...") X_all = matrix_flat.reshape(-1, 1).astype('float32') X_imputed = autoencoder.predict(X_all, verbose=1).flatten() # Reconstruire la matrice rho_imputed = X_imputed.reshape(rho_matrix.shape) # Conserver les valeurs observées originales rho_imputed[~np.isnan(rho_matrix)] = rho_matrix[~np.isnan(rho_matrix)] st.success("✅ Imputation terminée avec succès !") except Exception as tf_error: st.error(f"❌ Erreur TensorFlow : {str(tf_error)[:200]}") st.warning("🔄 Basculement automatique vers KNN Imputer...") # Fallback vers KNN from sklearn.impute import KNNImputer imputer = KNNImputer(n_neighbors=5) rho_imputed = imputer.fit_transform(rho_matrix) st.info("✅ Imputation réalisée avec KNN Imputer (méthode alternative)") else: st.warning("Pas assez de données pour l'autoencoder") rho_imputed = rho_matrix # Visualisation de l'imputation fig_impute, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=(18, 6)) # Matrice originale (avec NaN) im1 = ax1.imshow(rho_matrix, cmap='viridis', origin='upper') ax1.set_title('Matrice Originale\n(avec valeurs manquantes)') plt.colorbar(im1, ax=ax1, label='ρ (Ω·m)') # Matrice imputée im2 = ax2.imshow(rho_imputed, cmap='viridis', origin='upper') ax2.set_title(f'Matrice Imputée\n({imputation_method})') plt.colorbar(im2, ax=ax2, label='ρ (Ω·m)') # Différences diff_matrix = rho_imputed - rho_matrix im3 = ax3.imshow(diff_matrix, cmap='RdBu_r', origin='upper') ax3.set_title('Différences\n(Imputé - Original)') plt.colorbar(im3, ax=ax3, label='Δρ (Ω·m)') plt.tight_layout() st.pyplot(fig_impute, clear_figure=False, use_container_width=True) # Message de transition st.success("✅ Visualisation générée - Démarrage de l'analyse IA...") # Analyse DYNAMIQUE avec CLIP + LLM st.markdown("### 📖 Analyse Automatique (LLM + CLIP)") llm = st.session_state.get('llm_pipeline', None) if llm is not None: # Mode rapide ou avec CLIP use_clip_enabled = st.session_state.get('use_clip', False) and st.session_state.get('clip_loaded', False) mode_text = "🖼️ CLIP + LLM" if use_clip_enabled else "⚡ LLM rapide (sans CLIP)" with st.spinner(f"🧠 Analyse de l'image avec {mode_text}..."): context_imputation = f""" Méthode d'imputation: {imputation_method} Données manquantes: {np.isnan(rho_matrix).sum()} valeurs ({(np.isnan(rho_matrix).sum() / rho_matrix.size * 100):.1f}%) Dimensions matrice: {rho_matrix.shape} Plage résistivité: {np.nanmin(rho_matrix):.2f} - {np.nanmax(rho_matrix):.2f} Ω·m """ # Passer CLIP uniquement si activé clip_m = st.session_state.clip_model if use_clip_enabled else None clip_p = st.session_state.clip_processor if use_clip_enabled else None explanation_impute = analyze_image_with_clip_and_llm( fig_impute, llm, clip_m, clip_p, st.session_state.get('clip_device', 'cpu'), context_imputation ) st.info(explanation_impute) else: st.warning("⚠️ LLM non chargé - Explication basique affichée") st.info(f""" **Méthode:** {imputation_method} **Données manquantes:** {np.isnan(rho_matrix).sum()} ({(np.isnan(rho_matrix).sum() / rho_matrix.size * 100):.1f}%) """) # Métriques d'imputation avec explication LLM original_values = rho_matrix[~np.isnan(rho_matrix)] imputed_values = rho_imputed[~np.isnan(rho_matrix)] if len(original_values) > 0: mse = np.mean((original_values - imputed_values) ** 2) rmse = np.sqrt(mse) mae = np.mean(np.abs(original_values - imputed_values)) pct_imputed = (np.isnan(rho_matrix).sum() / rho_matrix.size * 100) st.write("**📊 Métriques d'imputation :**") col1, col2, col3, col4 = st.columns(4) with col1: st.metric("MSE", f"{mse:.4f}") with col2: st.metric("RMSE", f"{rmse:.4f}") with col3: st.metric("MAE", f"{mae:.4f}") with col4: st.metric("% Imputé", f"{pct_imputed:.1f}%") # Explication des métriques par le LLM if llm is not None: with st.expander("💡 Que signifient ces métriques ? (cliquer)"): metrics_prompt = f"""[INST] Tu es un expert en statistiques. Explique ces métriques d'imputation EN FRANÇAIS de manière simple: MSE (Mean Squared Error): {mse:.4f} RMSE (Root Mean Squared Error): {rmse:.4f} MAE (Mean Absolute Error): {mae:.4f} Pourcentage imputé: {pct_imputed:.1f}% Pour CHAQUE métrique (3-4 phrases): 1. Que mesure-t-elle exactement? 2. Comment interpréter la valeur obtenue? 3. Est-ce que {mse:.4f} est bon ou mauvais pour l'imputation? RÉPONDS EN FRANÇAIS. Simple, pédagogique, sans jargon. [/INST]""" metrics_explanation = generate_text_with_streaming(llm, metrics_prompt, max_new_tokens=400) if '[/INST]' in metrics_explanation: metrics_explanation = metrics_explanation.split('[/INST]')[-1].strip() st.info(metrics_explanation) # Stocker pour les étapes suivantes st.session_state['rho_matrix'] = rho_matrix st.session_state['rho_imputed'] = rho_imputed st.session_state['x_unique'] = x_unique st.session_state['y_unique'] = y_unique except Exception as e: st.error(f"❌ Erreur lors de l'imputation : {str(e)}") # =================== 3. MODÉLISATION FORWARD =================== if 'rho_imputed' in st.session_state: st.markdown("---") st.subheader("⚛️ 3. Modélisation Forward (Physique des Neutrinos)") # Paramètres de modélisation col_d, col_e, col_f = st.columns(3) with col_d: n_electrodes_forward = st.slider("Nombre d'électrodes", 8, 64, 16, key="n_electrodes_forward") with col_e: depth_max_forward = st.slider("Profondeur max (m)", 5, 50, 20, key="depth_max_forward") with col_f: noise_level = st.slider("Niveau de bruit (%)", 0.0, 10.0, 2.0, key="noise_level") if st.button("🚀 Modélisation Forward", key="forward_modeling"): with st.spinner("🔄 Modélisation forward en cours..."): try: rho_imputed = st.session_state['rho_imputed'] def build_forward_A(n_electrodes, n_depths): """Construit la matrice de modélisation forward inspirée des neutrinos""" A = np.zeros((n_electrodes * (n_electrodes - 1) // 2, n_electrodes * n_depths)) measurement_idx = 0 for i in range(n_electrodes): for j in range(i+1, n_electrodes): # Géométrie Wenner simplifiée electrode_spacing = 1.0 depth_weight = np.exp(-np.arange(n_depths) * 0.5) # Atténuation exponentielle # Contribution de chaque cellule for d in range(n_depths): # Position effective entre les électrodes pos_effective = (i + j) / 2 cell_idx = int(pos_effective) * n_depths + d if cell_idx < A.shape[1]: # Poids basé sur la distance et la profondeur (inspiré neutrinos) distance_factor = np.exp(-abs(pos_effective - i) - abs(pos_effective - j)) A[measurement_idx, cell_idx] = depth_weight[d] * distance_factor measurement_idx += 1 return A def mask_measurements(A, mask_ratio=0.3): """Masque aléatoirement des mesures (comme dans les expériences neutrinos)""" n_measurements = A.shape[0] n_to_mask = int(n_measurements * mask_ratio) mask = np.ones(n_measurements, dtype=bool) mask_indices = np.random.choice(n_measurements, n_to_mask, replace=False) mask[mask_indices] = False return mask # Construction de la matrice forward n_depths = rho_imputed.shape[0] A = build_forward_A(n_electrodes_forward, n_depths) # Aplatir le modèle de résistivité rho_flat = rho_imputed.flatten() # Adapter la taille si nécessaire if len(rho_flat) > A.shape[1]: rho_flat = rho_flat[:A.shape[1]] elif len(rho_flat) < A.shape[1]: rho_flat = np.pad(rho_flat, (0, A.shape[1] - len(rho_flat)), constant_values=np.mean(rho_flat)) # Calcul des mesures synthétiques measurements_clean = A @ rho_flat # Ajouter du bruit noise = np.random.normal(0, noise_level/100 * np.std(measurements_clean), len(measurements_clean)) measurements_noisy = measurements_clean + noise # Masquer certaines mesures mask = mask_measurements(A, mask_ratio=0.2) measurements_masked = measurements_noisy.copy() measurements_masked[~mask] = 0 # Valeur sentinelle pour mesures manquantes # Stocker les résultats st.session_state['A'] = A st.session_state['measurements_clean'] = measurements_clean st.session_state['measurements_noisy'] = measurements_noisy st.session_state['measurements_masked'] = measurements_masked st.session_state['mask'] = mask st.success("✅ Modélisation forward terminée") # Visualisation fig_forward, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(16, 12)) # Matrice A (kernel forward) im1 = ax1.imshow(A[:100, :], aspect='auto', cmap='viridis') ax1.set_title('Matrice Forward A\n(kernel de sensibilité)') ax1.set_xlabel('Cellules du modèle') ax1.set_ylabel('Mesures') plt.colorbar(im1, ax=ax1, label='Sensibilité') # Mesures ax2.plot(measurements_clean, 'b-', alpha=0.7, label='Mesures propres') ax2.plot(measurements_noisy, 'r-', alpha=0.7, label='Mesures bruitées') ax2.set_title('Mesures Synthétiques') ax2.set_xlabel('Index de mesure') ax2.set_ylabel('Amplitude') ax2.legend() ax2.grid(True, alpha=0.3) # Histogramme des mesures ax3.hist([measurements_clean, measurements_noisy], bins=30, alpha=0.7, label=['Propres', 'Bruitées']) ax3.set_title('Distribution des Mesures') ax3.set_xlabel('Amplitude') ax3.set_ylabel('Fréquence') ax3.legend() # Mesures masquées colors_masked = ['blue' if m else 'red' for m in mask] ax4.scatter(range(len(measurements_masked)), measurements_masked, c=colors_masked, alpha=0.6, s=20) ax4.set_title('Mesures Masquées\n(Bleu=observé, Rouge=masqué)') ax4.set_xlabel('Index de mesure') ax4.set_ylabel('Amplitude') plt.tight_layout() st.pyplot(fig_forward, clear_figure=False, use_container_width=True) # Explication DYNAMIQUE générée par le LLM st.markdown("### 📖 Explication Automatique (LLM)") llm = st.session_state.get('llm_pipeline', None) if llm is not None: with st.spinner("🧠 Génération de l'explication par le LLM..."): # Préparer les statistiques du graphique data_stats = f""" - Taille matrice A: {A.shape} - Nombre de mesures: {len(measurements_clean)} - Mesures masquées: {(~mask).sum()} ({(~mask).sum()/len(mask)*100:.1f}%) - Bruit ajouté: {noise_level:.1f}% - SNR: {np.std(measurements_clean)/np.std(noise):.2f} - Plage mesures propres: {measurements_clean.min():.3f} à {measurements_clean.max():.3f} - Plage mesures bruitées: {measurements_noisy.min():.3f} à {measurements_noisy.max():.3f} """ explanation = generate_graph_explanation_with_llm( llm, "forward_modeling", data_stats, context="Modélisation forward pour tomographie électrique (ERT)" ) st.info(explanation) else: st.warning("⚠️ LLM non chargé. Cliquez sur '🚀 Charger le LLM Mistral' dans la sidebar pour des explications intelligentes.") # Fallback basique avec vraies valeurs st.info(f""" **📊 Statistiques de modélisation (valeurs réelles) :** - **Taille matrice A (kernel)** : {A.shape[0]} mesures × {A.shape[1]} cellules - **Nombre total de mesures** : {len(measurements_clean)} - **Mesures masquées** : {(~mask).sum()} ({(~mask).sum()/len(mask)*100:.1f}%) - **Bruit ajouté** : {noise_level:.1f}% - **Signal-to-Noise Ratio (SNR)** : {np.std(measurements_clean)/np.std(noise):.2f} - **Plage mesures propres** : {measurements_clean.min():.3f} à {measurements_clean.max():.3f} - **Plage mesures bruitées** : {measurements_noisy.min():.3f} à {measurements_noisy.max():.3f} """) except Exception as e: st.error(f"❌ Erreur lors de la modélisation forward : {str(e)}") # =================== 4. RECONSTRUCTION 3D =================== if 'measurements_masked' in st.session_state: st.markdown("---") st.subheader("🎯 4. Reconstruction 3D (Régularisation Tikhonov)") # Paramètres de reconstruction col_g, col_h, col_i = st.columns(3) with col_g: lambda_tikhonov = st.slider("λ Tikhonov", 0.001, 1.0, 0.1, key="lambda_tikhonov") with col_h: max_iter_reconstruct = st.slider("Itérations max", 10, 1000, 100, key="max_iter_reconstruct") with col_i: use_masked = st.checkbox("Utiliser mesures masquées", value=True, key="use_masked") if st.button("🚀 Reconstruction 3D", key="reconstruct_3d"): with st.spinner("🔄 Reconstruction 3D en cours..."): try: A = st.session_state['A'] measurements = st.session_state['measurements_masked'] if use_masked else st.session_state['measurements_noisy'] mask = st.session_state['mask'] def tikhonov_reconstruct(A, measurements, lambda_reg=0.1, max_iter=100): """Reconstruction avec régularisation Tikhonov""" # Matrice de régularisation (Laplacien 2D simple) n_cells = A.shape[1] n_x = int(np.sqrt(n_cells)) n_y = n_cells // n_x # Régularisation de lissage from scipy.sparse import lil_matrix L = lil_matrix((n_cells, n_cells)) for i in range(n_cells): x = i % n_x y = i // n_x # Voisins avec vérification de bounds neighbors = [] if x > 0 and (i - 1) < n_cells: neighbors.append(i - 1) # gauche if x < n_x-1 and (i + 1) < n_cells: neighbors.append(i + 1) # droite if y > 0 and (i - n_x) >= 0: neighbors.append(i - n_x) # haut if y < n_y-1 and (i + n_x) < n_cells: neighbors.append(i + n_x) # bas for neighbor in neighbors: if neighbor < n_cells: # Sécurité supplémentaire L[i, neighbor] = -1 L[i, i] = len(neighbors) if len(neighbors) > 0 else 1 # Convertir en format CSR pour calculs L = L.tocsr() # Résoudre le système régularisé # (A^T A + λ L^T L) x = A^T b ATA = A.T @ A ATL = lambda_reg * (L.T @ L) ATb = A.T @ measurements # Résolution itérative (Conjugate Gradient) from scipy.sparse.linalg import cg from scipy.sparse import csr_matrix ATA_sparse = csr_matrix(ATA + ATL) x_reconstructed, info = cg(ATA_sparse, ATb, maxiter=max_iter, atol=1e-6, rtol=1e-6) return x_reconstructed, info # Reconstruction rho_reconstructed, convergence_info = tikhonov_reconstruct(A, measurements, lambda_tikhonov, max_iter_reconstruct) # Reshape en 3D (x, y, z) - approximation n_cells = len(rho_reconstructed) n_x = int(np.sqrt(n_cells)) n_y = n_x n_z = n_cells // (n_x * n_y) if n_z == 0: n_z = 1 rho_3d = rho_reconstructed[:n_x*n_y*n_z].reshape(n_x, n_y, n_z) # Stocker les résultats st.session_state['rho_reconstructed'] = rho_reconstructed st.session_state['rho_3d'] = rho_3d st.session_state['convergence_info'] = convergence_info st.success(f"✅ Reconstruction terminée (convergence: {convergence_info})") # Visualisation 2D des coupes fig_reconstruct, axes = plt.subplots(2, 2, figsize=(16, 12)) # Coupe horizontale (surface) im1 = axes[0,0].imshow(rho_3d[:,:,0], cmap='viridis', origin='upper') axes[0,0].set_title('Coupe Horizontale (Surface)') plt.colorbar(im1, ax=axes[0,0], label='ρ (Ω·m)') # Coupe verticale X im2 = axes[0,1].imshow(rho_3d[:,n_y//2,:].T, cmap='viridis', origin='upper') axes[0,1].set_title('Coupe Verticale X') plt.colorbar(im2, ax=axes[0,1], label='ρ (Ω·m)') # Coupe verticale Y im3 = axes[1,0].imshow(rho_3d[n_x//2,:,:].T, cmap='viridis', origin='upper') axes[1,0].set_title('Coupe Verticale Y') plt.colorbar(im3, ax=axes[1,0], label='ρ (Ω·m)') # Histogramme des valeurs reconstruites axes[1,1].hist(rho_reconstructed, bins=50, alpha=0.7, color='steelblue', edgecolor='black') axes[1,1].set_title('Distribution des Résistivités Reconstruites') axes[1,1].set_xlabel('Résistivité (Ω·m)') axes[1,1].set_ylabel('Fréquence') axes[1,1].set_yscale('log') plt.tight_layout() st.pyplot(fig_reconstruct, clear_figure=False, use_container_width=True) # Analyse DYNAMIQUE avec CLIP + LLM st.markdown("### 📖 Analyse Automatique (LLM + CLIP)") llm = st.session_state.get('llm_pipeline', None) if llm is not None: with st.spinner("🧠 Analyse des coupes 2D avec CLIP + LLM..."): context_2d = f""" Reconstruction 3D - Coupes 2D Dimensions: {n_x}×{n_y}×{n_z} = {n_x*n_y*n_z} cellules Résistivité: {rho_3d.min():.2f} - {rho_3d.max():.2f} Ω·m (moyenne: {rho_3d.mean():.2f}) Convergence: {convergence_info} Lambda régularisation: {lambda_tikhonov} 4 graphiques: Coupe horizontale, 2 coupes verticales, histogramme """ explanation_2d = analyze_image_with_clip_and_llm( fig_reconstruct, llm, st.session_state.get('clip_model'), st.session_state.get('clip_processor'), st.session_state.get('clip_device', 'cpu'), context_2d ) st.info(explanation_2d) else: st.warning("⚠️ LLM non chargé") # Visualisation 3D interactive avec Plotly st.markdown("### 🎨 Visualisation 3D Interactive") # Créer une grille 3D pour plotly X_grid, Y_grid, Z_grid = np.meshgrid( np.arange(n_x), np.arange(n_y), np.arange(n_z), indexing='ij' ) # Créer le volume 3D avec isosurface fig_3d = go.Figure(data=go.Isosurface( x=X_grid.flatten(), y=Y_grid.flatten(), z=Z_grid.flatten(), value=rho_3d.flatten(), isomin=rho_3d.min(), isomax=rho_3d.max(), surface_count=5, colorscale='Viridis', caps=dict(x_show=True, y_show=True, z_show=True), colorbar=dict(title="ρ (Ω·m)") )) fig_3d.update_layout( title="Reconstruction 3D - Volume de Résistivité", scene=dict( xaxis_title="X", yaxis_title="Y", zaxis_title="Z (profondeur)", camera=dict( eye=dict(x=1.5, y=1.5, z=1.5) ) ), height=600 ) st.plotly_chart(fig_3d, use_container_width=True) # Explication DYNAMIQUE avec le LLM st.markdown("### 📖 Analyse Automatique (LLM)") llm = st.session_state.get('llm_pipeline', None) if llm is not None: with st.spinner("🧠 Génération de l'explication 3D interactive..."): data_stats_3d_viz = f""" - Type: Visualisation 3D interactive Plotly - Nombre d'isosurfaces: 5 - Colormap: Viridis (violet→jaune) - Dimensions: {n_x}×{n_y}×{n_z} = {n_x*n_y*n_z} cellules - Résistivité min: {rho_3d.min():.2f} Ω·m - Résistivité max: {rho_3d.max():.2f} Ω·m - Interactions: rotation, zoom, pan """ explanation_3d_viz = generate_graph_explanation_with_llm( llm, "3d_interactive_visualization", data_stats_3d_viz, context="Visualisation 3D interactive de résistivités avec isosurfaces" ) st.success(explanation_3d_viz) else: st.warning("⚠️ LLM non chargé - Instructions basiques affichées") st.success(""" **🖱️ Interactions 3D :** - Clic gauche + déplacer = rotation - Molette = zoom - Clic droit + déplacer = déplacement - Formes continues = couches géologiques homogènes - Discontinuités = failles ou changements brusques de formation """) # Statistiques de reconstruction st.write("**📊 Statistiques de reconstruction :**") st.write(f"- **Convergence CG :** {convergence_info}") st.write(f"- **λ régularisation :** {lambda_tikhonov}") st.write(f"- **Résistivité min :** {rho_reconstructed.min():.2f} Ω·m") st.write(f"- **Résistivité max :** {rho_reconstructed.max():.2f} Ω·m") st.write(f"- **Résistivité moyenne :** {rho_reconstructed.mean():.2f} Ω·m") # =================== GÉNÉRATION D'IMAGE RÉALISTE DES COUPES 3D =================== st.markdown("---") st.subheader("🎨 Visualisations Réalistes des Coupes 3D (IA Générative)") st.info("💡 **Nouvelle fonctionnalité IA** : Créez des images réalistes de vos coupes géologiques 3D !") # Vérifier que les données 3D sont valides if n_x > 1 and n_y > 1 and n_z > 1: # Section toujours visible st.markdown(""" **Transformez vos données 3D en visualisations géologiques professionnelles.** Sélectionnez une coupe (horizontale ou verticale) et le système générera une image réaliste montrant les différentes couches géologiques avec des textures et couleurs naturelles. """) col_r1, col_r2, col_r3 = st.columns(3) with col_r1: slice_type = st.selectbox("Type de coupe", ["Horizontale (surface)", "Verticale X", "Verticale Y"], key="slice_type_3d") with col_r2: if slice_type == "Horizontale (surface)": slice_idx = st.slider("Profondeur", 0, max(0, n_z-1), 0, key="slice_depth") elif slice_type == "Verticale X": slice_idx = st.slider("Position Y", 0, max(0, n_y-1), n_y//2, key="slice_y") else: slice_idx = st.slider("Position X", 0, max(0, n_x-1), n_x//2, key="slice_x") with col_r3: st.info("🗺️ Les coupes réelles PyGimli remplacent la génération IA") col_r4, col_r5 = st.columns(2) with col_r4: gen_style_3d = st.selectbox("Style", ["Réaliste scientifique", "Art géologique", "Coupes techniques", "3D réaliste"], key="gen_style_3d") with col_r5: use_cpu_3d = st.checkbox("Utiliser CPU", value=True, key="use_cpu_3d") if st.button("🚀 Générer Images Réalistes des Coupes", key="generate_realistic_3d"): # Extraire la coupe sélectionnée if slice_type == "Horizontale (surface)": slice_data = rho_3d[:, :, slice_idx] depth_str = f"profondeur {slice_idx}/{n_z-1}" elif slice_type == "Verticale X": slice_data = rho_3d[:, slice_idx, :].T depth_str = f"coupe verticale Y={slice_idx}/{n_y-1}" else: slice_data = rho_3d[slice_idx, :, :].T depth_str = f"coupe verticale X={slice_idx}/{n_x-1}" # Générer l'image réaliste generated_img_3d, used_prompt_3d = generate_realistic_geological_image( slice_data, model_name=gen_model_3d, style=gen_style_3d, depth_info=depth_str, use_cpu=use_cpu_3d ) if generated_img_3d is not None: # Afficher la comparaison fig_comp_3d = create_side_by_side_comparison( slice_data, generated_img_3d, title=f"Reconstruction 3D - {slice_type} ({depth_str})" ) st.pyplot(fig_comp_3d, clear_figure=False, use_container_width=True) # Afficher le prompt with st.expander("📝 Prompt utilisé"): st.code(used_prompt_3d) # Stocker st.session_state['generated_3d_image'] = generated_img_3d st.session_state['3d_prompt'] = used_prompt_3d st.success("✅ Images réalistes générées avec succès !") # Téléchargement img_byte_arr_3d = io.BytesIO() generated_img_3d.save(img_byte_arr_3d, format='PNG') img_byte_arr_3d.seek(0) st.download_button( label="💾 Télécharger l'Image 3D Générée", data=img_byte_arr_3d, file_name=f"geological_3d_{slice_type.replace(' ', '_')}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png", mime="image/png", key="download_generated_3d" ) else: st.error("❌ La génération d'image a échoué") else: st.warning(""" ⚠️ **Dimensions 3D insuffisantes pour la génération d'images** Les données 3D actuelles ont des dimensions trop petites : - Dimension X : {} - Dimension Y : {} - Dimension Z : {} Pour générer des images de coupes, toutes les dimensions doivent être > 1. Veuillez ajuster les paramètres de reconstruction 3D ci-dessus. """.format(n_x, n_y, n_z)) except Exception as e: st.error(f"❌ Erreur lors de la reconstruction : {str(e)}") import traceback st.code(traceback.format_exc()) # =================== 5. DÉTECTION DE TRAJECTOIRES =================== if 'rho_3d' in st.session_state: st.markdown("---") st.subheader("📍 5. Détection de Trajectoires (RANSAC)") # Paramètres RANSAC col_j, col_k, col_l = st.columns(3) with col_j: min_samples = st.slider("Échantillons min", 2, 10, 3, key="min_samples") with col_k: residual_threshold = st.slider("Seuil résiduel", 0.1, 5.0, 1.0, key="residual_threshold") with col_l: max_trials = st.slider("Essais max", 100, 10000, 1000, key="max_trials") if st.button("🚀 Détecter Trajectoires", key="detect_trajectories"): with st.spinner("🔄 Détection RANSAC en cours..."): try: rho_3d = st.session_state['rho_3d'] def detect_trajectories(rho_3d, min_samples=3, residual_threshold=1.0, max_trials=1000): """Détection de trajectoires géologiques par RANSAC""" from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_squared_error trajectories = [] n_x, n_y, n_z = rho_3d.shape # Chercher des structures linéaires dans les données 3D for z in range(n_z): # Extraire la coupe horizontale slice_2d = rho_3d[:,:,z] # Trouver les gradients élevés (interfaces potentielles) grad_x = np.gradient(slice_2d, axis=0) grad_y = np.gradient(slice_2d, axis=1) gradient_magnitude = np.sqrt(grad_x**2 + grad_y**2) # Seuillage pour identifier les régions d'intérêt threshold = np.percentile(gradient_magnitude, 85) high_gradient = gradient_magnitude > threshold # Extraire les points d'intérêt y_coords, x_coords = np.where(high_gradient) values = gradient_magnitude[high_gradient] if len(x_coords) < min_samples: continue # RANSAC pour détecter des lignes best_model = None best_score = 0 best_inliers = [] for trial in range(max_trials): # Échantillonner aléatoirement sample_indices = np.random.choice(len(x_coords), size=min_samples, replace=False) x_sample = x_coords[sample_indices] y_sample = y_coords[sample_indices] # Ajuster un modèle linéaire if len(np.unique(x_sample)) > 1: # Éviter division par zéro model = LinearRegression() model.fit(x_sample.reshape(-1, 1), y_sample) # Prédire pour tous les points y_pred = model.predict(x_coords.reshape(-1, 1)) # Calculer les résidus residuals = np.abs(y_coords - y_pred) # Identifier les inliers inliers = residuals < residual_threshold inlier_count = np.sum(inliers) # Score basé sur le nombre d'inliers et la longueur score = inlier_count * np.sqrt((x_sample.max() - x_sample.min())**2 + (y_sample.max() - y_sample.min())**2) if score > best_score: best_score = score best_model = model best_inliers = inliers # Stocker la trajectoire si elle est significative if best_model is not None and np.sum(best_inliers) >= min_samples: trajectory = { 'depth': z, 'model': best_model, 'inliers': best_inliers, 'x_coords': x_coords, 'y_coords': y_coords, 'score': best_score } trajectories.append(trajectory) return trajectories # Détection trajectories = detect_trajectories(rho_3d, min_samples, residual_threshold, max_trials) st.success(f"✅ Détection terminée : {len(trajectories)} trajectoires détectées") # Visualisation fig_trajectories, axes = plt.subplots(1, 3, figsize=(18, 6)) # Carte des gradients grad_x = np.gradient(rho_3d[:,:,0], axis=0) grad_y = np.gradient(rho_3d[:,:,0], axis=1) gradient_magnitude = np.sqrt(grad_x**2 + grad_y**2) im1 = axes[0].imshow(gradient_magnitude, cmap='hot', origin='upper') axes[0].set_title('Carte des Gradients\n(Régions d\'intérêt)') plt.colorbar(im1, ax=axes[0], label='|∇ρ|') # Trajectoires détectées im2 = axes[1].imshow(rho_3d[:,:,0], cmap='viridis', origin='upper') axes[1].set_title(f'Trajectoires Détectées\n({len(trajectories)} trouvées)') # Tracer les trajectoires x_plot = np.linspace(0, rho_3d.shape[0]-1, 100) for traj in trajectories: if traj['depth'] == 0: # Surface uniquement pour visualisation model = traj['model'] y_plot = model.predict(x_plot.reshape(-1, 1)) axes[1].plot(x_plot, y_plot, 'r-', linewidth=2, alpha=0.8) plt.colorbar(im2, ax=axes[1], label='ρ (Ω·m)') # Scores des trajectoires if trajectories: scores = [t['score'] for t in trajectories] axes[2].bar(range(len(scores)), scores, color='steelblue', alpha=0.7) axes[2].set_title('Scores des Trajectoires') axes[2].set_xlabel('Index de trajectoire') axes[2].set_ylabel('Score RANSAC') axes[2].grid(True, alpha=0.3) plt.tight_layout() st.pyplot(fig_trajectories, clear_figure=False, use_container_width=True) # Analyse DYNAMIQUE avec CLIP + LLM st.markdown("### 📖 Analyse Automatique (LLM + CLIP)") llm = st.session_state.get('llm_pipeline', None) if llm is not None: use_clip_enabled = st.session_state.get('use_clip', False) and st.session_state.get('clip_loaded', False) mode_text = "🖼️ CLIP + LLM" if use_clip_enabled else "⚡ LLM rapide" with st.spinner(f"🧠 Analyse RANSAC avec {mode_text}..."): context_ransac = f""" Détection de trajectoires avec RANSAC Nombre de trajectoires détectées: {len(trajectories)} Score moyen: {np.mean([t['score'] for t in trajectories]):.2f} Score min/max: {min([t['score'] for t in trajectories]):.2f} / {max([t['score'] for t in trajectories]):.2f} Dimensions: {n_x}×{n_y}×{n_z} 3 graphiques: Carte des gradients, Trajectoires détectées, Scores RANSAC """ clip_m = st.session_state.clip_model if use_clip_enabled else None clip_p = st.session_state.clip_processor if use_clip_enabled else None explanation_ransac = analyze_image_with_clip_and_llm( fig_trajectories, llm, clip_m, clip_p, st.session_state.get('clip_device', 'cpu'), context_ransac ) st.info(explanation_ransac) else: st.warning("⚠️ LLM non chargé") # Détails des trajectoires if trajectories: st.write("**📋 Trajectoires détectées :**") for i, traj in enumerate(trajectories): st.write(f"- **Trajectoire {i+1}** : Profondeur {traj['depth']}, Score {traj['score']:.1f}, {np.sum(traj['inliers'])} inliers") # Stocker pour visualisation 3D st.session_state['trajectories'] = trajectories # =================== GÉNÉRATION IA - VISUALISATION DES TRAJECTOIRES =================== st.markdown("---") st.subheader("🎨 Visualisation Réaliste des Trajectoires & Cavités (IA Générative)") st.success("✅ Trajectoires détectées ! Générez une coupe réaliste pour visualiser les cavités et failles.") st.markdown(""" **🔬 Visualisation des Trajectoires de Neutrinos (méthode RANSAC)** Les trajectoires détectées révèlent des **structures cachées** dans le sous-sol : - 🕳️ **Cavités** (grottes, karsts, vides) - 🪨 **Failles** (fractures géologiques) - 💧 **Écoulements souterrains** (rivières cachées) - 📏 **Couches inclinées** (pendages) L'IA va créer une **coupe géologique réaliste** montrant ces structures ! """) col_traj1, col_traj2, col_traj3 = st.columns(3) with col_traj1: st.info("🗺️ Génération avec PyGimli") with col_traj2: traj_style = st.selectbox( "Style de coupe", ["Réaliste scientifique", "Coupes techniques", "Art géologique"], key="traj_style", help="Style de visualisation" ) with col_traj3: traj_emphasis = st.selectbox( "Emphase sur", ["Cavités et vides", "Failles et fractures", "Toutes structures"], key="traj_emphasis", help="Type de structure à mettre en évidence" ) if st.button("🚀 Générer Coupe Réaliste des Trajectoires", key="generate_trajectories_viz", type="primary"): with st.spinner("🎨 Génération de la coupe géologique avec trajectoires... (30s-2min)"): try: # Créer une carte des trajectoires pour la génération trajectory_map = np.zeros_like(rho_3d[:,:,0]) # Marquer les trajectoires sur la carte for traj in trajectories: if traj['depth'] == 0: # Surface pour visualisation inliers = traj['inliers'] x_coords = traj['x_coords'][inliers] y_coords = traj['y_coords'][inliers] for x, y in zip(x_coords, y_coords): if 0 <= int(y) < trajectory_map.shape[0] and 0 <= int(x) < trajectory_map.shape[1]: trajectory_map[int(y), int(x)] = 1000 # Marquer avec haute valeur # Combiner résistivité + trajectoires combined_data = rho_3d[:,:,0] + trajectory_map * 0.3 # Créer un prompt spécifique pour les trajectoires emphasis_descriptions = { "Cavités et vides": "underground cavities, karst formations, voids, hollow spaces in rock", "Failles et fractures": "geological faults, fractures, cracks, tectonic breaks in bedrock", "Toutes structures": "geological discontinuities, faults, cavities, and subsurface structures" } trajectory_prompt = f"""Geological cross-section showing {emphasis_descriptions[traj_emphasis]}. {len(trajectories)} linear structures detected by neutrino-inspired RANSAC analysis. Resistivity range: {rho_3d[:,:,0].min():.1f} to {rho_3d[:,:,0].max():.1f} ohm-meters. Highlighted pathways indicate subsurface anomalies: dark zones for low resistivity (water-filled cavities), bright fractures for geological discontinuities. Scientific accuracy, realistic textures.""" # Générer l'image traj_generated_img, traj_used_prompt = generate_realistic_geological_image( combined_data, model_name=traj_model, style=traj_style, depth_info=f"{len(trajectories)} trajectoires détectées - {traj_emphasis}", use_cpu=True, llm_enhanced_prompt=trajectory_prompt ) if traj_generated_img is not None: st.success("✅ Coupe réaliste générée avec succès !") # Afficher la comparaison st.markdown("### 📊 Comparaison : Données + Trajectoires vs Visualisation Réaliste") fig_traj_comparison = create_side_by_side_comparison( combined_data, traj_generated_img, title=f"Trajectoires Détectées - {traj_emphasis}" ) st.pyplot(fig_traj_comparison, clear_figure=False, use_container_width=True) # Analyse DYNAMIQUE avec CLIP + LLM st.markdown("### 📖 Analyse Automatique (LLM + CLIP)") llm = st.session_state.get('llm_pipeline', None) if llm is not None: use_clip_enabled = st.session_state.get('use_clip', False) and st.session_state.get('clip_loaded', False) mode_text = "🖼️ CLIP + LLM" if use_clip_enabled else "⚡ LLM rapide" with st.spinner(f"🧠 Analyse comparaison avec {mode_text}..."): context_comparison = f""" Comparaison Données+Trajectoires vs Visualisation Réaliste Nombre de trajectoires détectées: {len(trajectories)} Score moyen RANSAC: {np.mean([t['score'] for t in trajectories]) if trajectories else 0:.2f} Total points d'intérêt: {sum([np.sum(t['inliers']) for t in trajectories])} Type de rendu: {traj_emphasis} Résolution: {n_x}×{n_y}×{n_z} 2 panneaux: Superposition trajectoires + Rendu neutrino-like """ clip_m = st.session_state.clip_model if use_clip_enabled else None clip_p = st.session_state.clip_processor if use_clip_enabled else None explanation_comparison = analyze_image_with_clip_and_llm( fig_traj_comparison, llm, clip_m, clip_p, st.session_state.get('clip_device', 'cpu'), context_comparison ) st.info(explanation_comparison) else: st.warning("⚠️ LLM non chargé") # Statistiques st.markdown("### 📊 Analyse des Structures Détectées") col_stat1, col_stat2, col_stat3 = st.columns(3) with col_stat1: st.metric("Trajectoires détectées", len(trajectories)) with col_stat2: avg_score = np.mean([t['score'] for t in trajectories]) if trajectories else 0 st.metric("Score moyen RANSAC", f"{avg_score:.1f}") with col_stat3: total_inliers = sum([np.sum(t['inliers']) for t in trajectories]) st.metric("Points d'intérêt", total_inliers) # Recommandations st.markdown("### 🎯 Recommandations d'Exploration") if len(trajectories) > 0: st.success(f""" ✅ **{len(trajectories)} structure(s) linéaire(s) détectée(s)** ! **Actions recommandées :** - Effectuer des investigations complémentaires (radar géologique, sismique) - Cibler les zones à faible résistivité pour détecter les cavités - Cartographier précisément les failles pour risques géotechniques - Planifier des forages d'exploration aux intersections de trajectoires """) else: st.info("Aucune structure linéaire majeure détectée. Le sous-sol semble homogène.") # Téléchargement img_traj_byte = io.BytesIO() traj_generated_img.save(img_traj_byte, format='PNG') img_traj_byte.seek(0) st.download_button( label="💾 Télécharger la Coupe des Trajectoires", data=img_traj_byte, file_name=f"trajectoires_neutrinos_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png", mime="image/png", key="download_trajectories" ) # Stocker pour rapport st.session_state['trajectories_image'] = traj_generated_img else: st.error("❌ La génération a échoué. Essayez un autre modèle.") except Exception as e: st.error(f"❌ Erreur lors de la génération : {str(e)}") import traceback with st.expander("🔍 Détails de l'erreur"): st.code(traceback.format_exc()) except Exception as e: st.error(f"❌ Erreur lors de la détection : {str(e)}") # =================== GÉNÉRATION RAPPORT PDF COMPLET =================== if 'trajectories' in st.session_state and 'rho_3d' in st.session_state: st.markdown("---") st.subheader("📄 Rapport d'Analyse Complet") if st.button("📥 Générer Rapport PDF Complet", key="generate_full_report"): with st.spinner("📄 Génération du rapport PDF en cours..."): try: def generate_complete_analysis_pdf(): """Génère un rapport PDF complet de toutes les étapes d'analyse""" from reportlab.lib.pagesizes import A4, landscape from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import cm from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak, Table, TableStyle, Image as RLImage from reportlab.lib import colors from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT buffer = io.BytesIO() doc = SimpleDocTemplate(buffer, pagesize=A4, topMargin=1.5*cm, bottomMargin=1.5*cm, leftMargin=2*cm, rightMargin=2*cm) story = [] styles = getSampleStyleSheet() # Styles personnalisés title_style = ParagraphStyle('Title', parent=styles['Heading1'], fontSize=24, textColor=colors.HexColor('#1f77b4'), spaceAfter=20, alignment=TA_CENTER, fontName='Helvetica-Bold') heading_style = ParagraphStyle('Heading', parent=styles['Heading2'], fontSize=14, textColor=colors.HexColor('#2c3e50'), spaceAfter=10, spaceBefore=15, fontName='Helvetica-Bold') body_style = ParagraphStyle('Body', parent=styles['BodyText'], fontSize=10, alignment=TA_JUSTIFY, spaceAfter=8, leading=14) # PAGE DE TITRE story.append(Spacer(1, 2*cm)) story.append(Paragraph("🔬 RAPPORT D'ANALYSE GÉOPHYSIQUE", title_style)) story.append(Paragraph("Tomographie par Analyse Spectrale d'Image", styles['Heading3'])) story.append(Spacer(1, 1*cm)) story.append(Paragraph(f"Date: {datetime.now().strftime('%d/%m/%Y %H:%M')}", styles['Normal'])) story.append(Paragraph("SETRAF - Subaquifère ERT Analysis Tool", styles['Normal'])) story.append(PageBreak()) # RÉSUMÉ EXÉCUTIF story.append(Paragraph("📊 RÉSUMÉ EXÉCUTIF", heading_style)) # Récupérer les données de session img_array = st.session_state.get('img_array') rho_matrix = st.session_state.get('rho_matrix') rho_imputed = st.session_state.get('rho_imputed') rho_3d = st.session_state.get('rho_3d') trajectories = st.session_state.get('trajectories', []) summary_data = [] if img_array is not None: summary_data.append(['Image source', f'{img_array.shape[0]} x {img_array.shape[1]} pixels']) if rho_matrix is not None: summary_data.append(['Matrice de résistivité', f'{rho_matrix.shape[0]} x {rho_matrix.shape[1]} cellules']) summary_data.append(['Valeurs manquantes', f'{np.isnan(rho_matrix).sum()} ({np.isnan(rho_matrix).sum()/rho_matrix.size*100:.1f}%)']) if rho_imputed is not None: summary_data.append(['Résistivité min', f'{rho_imputed.min():.2f} Ω·m']) summary_data.append(['Résistivité max', f'{rho_imputed.max():.2f} Ω·m']) summary_data.append(['Résistivité moyenne', f'{rho_imputed.mean():.2f} Ω·m']) if rho_3d is not None: summary_data.append(['Modèle 3D', f'{rho_3d.shape[0]} x {rho_3d.shape[1]} x {rho_3d.shape[2]}']) summary_data.append(['Trajectoires détectées', f'{len(trajectories)}']) summary_table = Table(summary_data, colWidths=[8*cm, 8*cm]) summary_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (0, -1), colors.HexColor('#ecf0f1')), ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 10), ('BOTTOMPADDING', (0, 0), (-1, -1), 8), ('TOPPADDING', (0, 0), (-1, -1), 8), ('GRID', (0, 0), (-1, -1), 0.5, colors.grey), ])) story.append(summary_table) story.append(Spacer(1, 1*cm)) # ÉTAPE 1 : EXTRACTION SPECTRALE story.append(Paragraph("🌈 ÉTAPE 1 : EXTRACTION SPECTRALE", heading_style)) story.append(Paragraph( "Les valeurs RGB de l'image ont été converties en valeurs de résistivité électrique synthétiques. " "Cette transformation permet de simuler des mesures géophysiques à partir de caractéristiques visuelles du terrain.", body_style )) if rho_matrix is not None: stats_data = [ ['Paramètre', 'Valeur'], ['Patches analysés', f'{rho_matrix.shape[0] * rho_matrix.shape[1]}'], ['Résistivité min (avant imputation)', f'{np.nanmin(rho_matrix):.2f} Ω·m'], ['Résistivité max (avant imputation)', f'{np.nanmax(rho_matrix):.2f} Ω·m'], ['Résistivité moyenne', f'{np.nanmean(rho_matrix):.2f} Ω·m'], ] stats_table = Table(stats_data, colWidths=[8*cm, 8*cm]) stats_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#3498db')), ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 9), ('BOTTOMPADDING', (0, 0), (-1, -1), 8), ('TOPPADDING', (0, 0), (-1, -1), 8), ('GRID', (0, 0), (-1, -1), 0.5, colors.grey), ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#ecf0f1')]), ])) story.append(Spacer(1, 0.5*cm)) story.append(stats_table) story.append(PageBreak()) # ÉTAPE 2 : IMPUTATION story.append(Paragraph("🔧 ÉTAPE 2 : IMPUTATION DES DONNÉES MANQUANTES", heading_style)) imputation_method = st.session_state.get('imputation_method', 'Non spécifiée') story.append(Paragraph( f"Les valeurs manquantes dans la matrice de résistivité ont été imputées en utilisant la méthode : {imputation_method}. " "Cette étape permet de compléter les données pour obtenir un modèle continu du sous-sol.", body_style )) if rho_imputed is not None: imputation_data = [ ['Métrique', 'Avant Imputation', 'Après Imputation'], ['Valeurs manquantes', f'{np.isnan(rho_matrix).sum()}', '0'], ['Résistivité min', f'{np.nanmin(rho_matrix):.2f} Ω·m', f'{rho_imputed.min():.2f} Ω·m'], ['Résistivité max', f'{np.nanmax(rho_matrix):.2f} Ω·m', f'{rho_imputed.max():.2f} Ω·m'], ['Résistivité moyenne', f'{np.nanmean(rho_matrix):.2f} Ω·m', f'{rho_imputed.mean():.2f} Ω·m'], ] imp_table = Table(imputation_data, colWidths=[6*cm, 5*cm, 5*cm]) imp_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#2ecc71')), ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 9), ('BOTTOMPADDING', (0, 0), (-1, -1), 8), ('TOPPADDING', (0, 0), (-1, -1), 8), ('GRID', (0, 0), (-1, -1), 0.5, colors.grey), ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#d5f4e6')]), ])) story.append(Spacer(1, 0.5*cm)) story.append(imp_table) story.append(PageBreak()) # ÉTAPE 3 : MODÉLISATION FORWARD story.append(Paragraph("⚛️ ÉTAPE 3 : MODÉLISATION FORWARD", heading_style)) story.append(Paragraph( "Une matrice de sensibilité a été construite pour simuler la propagation des signaux électriques dans le sol. " "Cette modélisation, inspirée de la physique des détecteurs de particules, permet de créer des mesures synthétiques réalistes.", body_style )) A = st.session_state.get('A') measurements_clean = st.session_state.get('measurements_clean') measurements_noisy = st.session_state.get('measurements_noisy') mask = st.session_state.get('mask') if A is not None and measurements_clean is not None: forward_data = [ ['Paramètre', 'Valeur'], ['Dimension matrice A', f'{A.shape[0]} x {A.shape[1]}'], ['Nombre de mesures', f'{len(measurements_clean)}'], ['Mesures masquées', f'{(~mask).sum()} ({(~mask).sum()/len(mask)*100:.1f}%)'], ['Niveau de bruit', st.session_state.get('noise_level', 'N/A')], ] if measurements_noisy is not None: noise = measurements_noisy - measurements_clean snr = np.std(measurements_clean) / np.std(noise) if np.std(noise) > 0 else float('inf') forward_data.append(['SNR (Signal/Bruit)', f'{snr:.2f}']) fwd_table = Table(forward_data, colWidths=[8*cm, 8*cm]) fwd_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (0, -1), colors.HexColor('#ecf0f1')), ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 10), ('BOTTOMPADDING', (0, 0), (-1, -1), 8), ('TOPPADDING', (0, 0), (-1, -1), 8), ('GRID', (0, 0), (-1, -1), 0.5, colors.grey), ])) story.append(Spacer(1, 0.5*cm)) story.append(fwd_table) story.append(PageBreak()) # ÉTAPE 4 : RECONSTRUCTION 3D story.append(Paragraph("🎯 ÉTAPE 4 : RECONSTRUCTION 3D", heading_style)) lambda_tikhonov = st.session_state.get('lambda_tikhonov', 'N/A') convergence_info = st.session_state.get('convergence_info', 'N/A') story.append(Paragraph( f"Le modèle 3D du sous-sol a été reconstruit en résolvant un problème inverse régularisé (méthode de Tikhonov). " f"Paramètre de régularisation λ = {lambda_tikhonov}. Convergence: {convergence_info}.", body_style )) if rho_3d is not None: rho_reconstructed = st.session_state.get('rho_reconstructed') recon_data = [ ['Paramètre', 'Valeur'], ['Dimensions modèle 3D', f'{rho_3d.shape[0]} x {rho_3d.shape[1]} x {rho_3d.shape[2]}'], ['Cellules totales', f'{rho_3d.size}'], ] if rho_reconstructed is not None: recon_data.extend([ ['Résistivité min', f'{rho_reconstructed.min():.2f} Ω·m'], ['Résistivité max', f'{rho_reconstructed.max():.2f} Ω·m'], ['Résistivité moyenne', f'{rho_reconstructed.mean():.2f} Ω·m'], ['Écart-type', f'{rho_reconstructed.std():.2f} Ω·m'], ]) recon_table = Table(recon_data, colWidths=[8*cm, 8*cm]) recon_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (0, -1), colors.HexColor('#ecf0f1')), ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 10), ('BOTTOMPADDING', (0, 0), (-1, -1), 8), ('TOPPADDING', (0, 0), (-1, -1), 8), ('GRID', (0, 0), (-1, -1), 0.5, colors.grey), ])) story.append(Spacer(1, 0.5*cm)) story.append(recon_table) story.append(PageBreak()) # ÉTAPE 5 : DÉTECTION DE TRAJECTOIRES story.append(Paragraph("📍 ÉTAPE 5 : DÉTECTION DE TRAJECTOIRES", heading_style)) story.append(Paragraph( "L'algorithme RANSAC (RANdom SAmple Consensus) a été utilisé pour détecter des structures linéaires " "dans le volume 3D, correspondant à des failles géologiques, des couches sédimentaires, ou des écoulements souterrains.", body_style )) if trajectories: traj_data = [['#', 'Profondeur', 'Score RANSAC', 'Nombre d\'inliers']] for i, traj in enumerate(trajectories[:10]): # Limite à 10 pour le PDF traj_data.append([ str(i+1), f"{traj['depth']}", f"{traj['score']:.1f}", f"{np.sum(traj['inliers'])}" ]) traj_table = Table(traj_data, colWidths=[2*cm, 4*cm, 5*cm, 5*cm]) traj_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#e74c3c')), ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 9), ('BOTTOMPADDING', (0, 0), (-1, -1), 8), ('TOPPADDING', (0, 0), (-1, -1), 8), ('GRID', (0, 0), (-1, -1), 0.5, colors.grey), ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#fadbd8')]), ])) story.append(Spacer(1, 0.5*cm)) story.append(traj_table) if len(trajectories) > 10: story.append(Spacer(1, 0.3*cm)) story.append(Paragraph(f"(... et {len(trajectories) - 10} autres trajectoires)", body_style)) else: story.append(Paragraph("Aucune trajectoire détectée.", body_style)) story.append(PageBreak()) # INTERPRÉTATION GÉOLOGIQUE story.append(Paragraph("🪨 INTERPRÉTATION GÉOLOGIQUE", heading_style)) story.append(Paragraph( "Basé sur les valeurs de résistivité mesurées et reconstruites, voici l'interprétation des principales formations détectées:", body_style )) if rho_reconstructed is not None: geo_interpretation = [] rho_min, rho_max = rho_reconstructed.min(), rho_reconstructed.max() rho_mean = rho_reconstructed.mean() if rho_min < 1: geo_interpretation.append(['< 1 Ω·m', 'Eau de mer / Argile saturée saline', f'{(rho_reconstructed < 1).sum()/rho_reconstructed.size*100:.1f}%']) if rho_min < 10: geo_interpretation.append(['1-10 Ω·m', 'Argile marine / Eau salée', f'{((rho_reconstructed >= 1) & (rho_reconstructed < 10)).sum()/rho_reconstructed.size*100:.1f}%']) if rho_max > 10: geo_interpretation.append(['10-100 Ω·m', 'Eau douce / Sable saturé', f'{((rho_reconstructed >= 10) & (rho_reconstructed < 100)).sum()/rho_reconstructed.size*100:.1f}%']) if rho_max > 100: geo_interpretation.append(['> 100 Ω·m', 'Gravier sec / Roche', f'{(rho_reconstructed >= 100).sum()/rho_reconstructed.size*100:.1f}%']) if geo_interpretation: geo_interpretation.insert(0, ['Résistivité', 'Interprétation', 'Proportion']) geo_table = Table(geo_interpretation, colWidths=[4*cm, 8*cm, 4*cm]) geo_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#8e44ad')), ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 9), ('BOTTOMPADDING', (0, 0), (-1, -1), 8), ('TOPPADDING', (0, 0), (-1, -1), 8), ('GRID', (0, 0), (-1, -1), 0.5, colors.grey), ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#ebdef0')]), ])) story.append(Spacer(1, 0.5*cm)) story.append(geo_table) story.append(PageBreak()) # CONCLUSIONS ET RECOMMANDATIONS story.append(Paragraph("✅ CONCLUSIONS ET RECOMMANDATIONS", heading_style)) story.append(Paragraph( "Résultats principaux:", body_style )) conclusions = [] if rho_reconstructed is not None: conclusions.append(f"• Modèle 3D du sous-sol reconstruit avec succès ({rho_3d.size} cellules)") conclusions.append(f"• Gamme de résistivité détectée: {rho_reconstructed.min():.2f} - {rho_reconstructed.max():.2f} Ω·m") if trajectories: conclusions.append(f"• {len(trajectories)} structures linéaires détectées (failles/couches potentielles)") conclusions.extend([ "", "Recommandations:", "• Validation terrain: Effectuer des mesures ERT réelles pour calibrer le modèle", "• Forages ciblés: Utiliser les zones à faible résistivité pour localiser les aquifères", "• Analyse temporelle: Répéter l'analyse à différentes saisons pour suivre les variations", "• Intégration multi-sources: Combiner avec données géologiques et hydrogéologiques existantes" ]) for conclusion in conclusions: story.append(Paragraph(conclusion, body_style)) story.append(Spacer(1, 0.2*cm)) # Pied de page final story.append(Spacer(1, 2*cm)) story.append(Paragraph("_______________________________________________________________________________", styles['Normal'])) story.append(Paragraph( "Rapport généré par SETRAF - Subaquifère ERT Analysis Tool | Technologie de Tomographie Géophysique par IA", ParagraphStyle('Footer', parent=styles['Normal'], fontSize=8, alignment=TA_CENTER) )) # Générer le PDF doc.build(story) buffer.seek(0) return buffer # Générer et télécharger pdf_buffer = generate_complete_analysis_pdf() st.download_button( label="💾 Télécharger le Rapport Complet", data=pdf_buffer, file_name=f"Rapport_Analyse_Geophysique_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf", mime="application/pdf", key="download_full_report" ) st.success("✅ Rapport PDF généré avec succès !") except Exception as e: st.error(f"❌ Erreur lors de la génération du rapport : {str(e)}") import traceback st.code(traceback.format_exc()) # =================== 6. VISUALISATION 3D =================== if 'rho_3d' in st.session_state: st.markdown("---") st.subheader("🌐 6. Visualisation 3D Interactive") if st.button("🚀 Générer Visualisation 3D", key="visualize_3d"): with st.spinner("🔄 Génération de la visualisation 3D..."): try: rho_3d = st.session_state['rho_3d'] trajectories = st.session_state.get('trajectories', []) # Créer la visualisation 3D avec Plotly import plotly.graph_objects as go from plotly.subplots import make_subplots # Préparer les données pour Plotly n_x, n_y, n_z = rho_3d.shape # Créer un volume 3D X, Y, Z = np.mgrid[0:n_x, 0:n_y, 0:n_z] # Seuillage pour visualisation (valeurs extrêmes) rho_flat = rho_3d.flatten() threshold_low = np.percentile(rho_flat, 25) threshold_high = np.percentile(rho_flat, 75) # Créer le graphique 3D fig_3d = go.Figure() # Volume des résistivités basses (aquifères potentiels) mask_low = rho_3d < threshold_low if np.any(mask_low): fig_3d.add_trace(go.Volume( x=X.flatten()[mask_low.flatten()], y=Y.flatten()[mask_low.flatten()], z=Z.flatten()[mask_low.flatten()], value=rho_3d.flatten()[mask_low.flatten()], isomin=rho_flat.min(), isomax=threshold_low, opacity=0.3, surface_count=5, colorscale='Blues', name='Résistivités basses
(Aquifères potentiels)' )) # Volume des résistivités élevées (formations résistives) mask_high = rho_3d > threshold_high if np.any(mask_high): fig_3d.add_trace(go.Volume( x=X.flatten()[mask_high.flatten()], y=Y.flatten()[mask_high.flatten()], z=Z.flatten()[mask_high.flatten()], value=rho_3d.flatten()[mask_high.flatten()], isomin=threshold_high, isomax=rho_flat.max(), opacity=0.3, surface_count=5, colorscale='Reds', name='Résistivités élevées
(Formations dures)' )) # Ajouter les trajectoires détectées for i, traj in enumerate(trajectories): if traj['depth'] < n_z: # Points de la trajectoire x_traj = traj['x_coords'][traj['inliers']] y_traj = traj['y_coords'][traj['inliers']] z_traj = np.full_like(x_traj, traj['depth']) fig_3d.add_trace(go.Scatter3d( x=x_traj, y=y_traj, z=z_traj, mode='markers+lines', marker=dict(size=4, color=f'rgba({i*50%256}, {100+i*30}, {200-i*40}, 0.8)'), line=dict(width=3, color=f'rgb({i*50%256}, {100+i*30}, {200-i*40})'), name=f'Trajectoire {i+1}' )) # Configuration du layout fig_3d.update_layout( title="Modèle 3D du Sous-Sol Géophysique", scene=dict( xaxis_title='X (m)', yaxis_title='Y (m)', zaxis_title='Profondeur (m)', xaxis=dict(backgroundcolor="rgb(200, 200, 230)", gridcolor="white", showbackground=True), yaxis=dict(backgroundcolor="rgb(230, 200, 230)", gridcolor="white", showbackground=True), zaxis=dict(backgroundcolor="rgb(230, 230, 200)", gridcolor="white", showbackground=True), ), margin=dict(r=10, l=10, b=10, t=10) ) # Afficher st.plotly_chart(fig_3d, use_container_width=True) # Explication DYNAMIQUE avec le LLM st.markdown("### 📖 Analyse Automatique (LLM)") llm = st.session_state.get('llm_pipeline', None) if llm is not None: with st.spinner("🧠 Génération de l'explication 3D bi-volume..."): data_stats_3d_bi = f""" - Volume BLEU: {(rho_3d < threshold_low).sum()} cellules ({(rho_3d < threshold_low).sum()/rho_3d.size*100:.1f}%) - Seuil bas: {threshold_low:.2f} Ω·m (25e percentile) - Volume ROUGE: {(rho_3d > threshold_high).sum()} cellules ({(rho_3d > threshold_high).sum()/rho_3d.size*100:.1f}%) - Seuil haut: {threshold_high:.2f} Ω·m (75e percentile) - Trajectoires détectées: {len(trajectories)} - Dimensions: {n_x}×{n_y}×{n_z} - Résistivité moyenne: {rho_3d.mean():.2f} Ω·m """ explanation_3d_bi = generate_graph_explanation_with_llm( llm, "3d_dual_volume", data_stats_3d_bi, context="Visualisation 3D bi-volume : zones basse résistivité (aquifères) vs haute résistivité (roches)" ) st.info(explanation_3d_bi) else: st.warning("⚠️ LLM non chargé - Statistiques basiques affichées") # Statistiques 3D st.write("**📊 Analyse 3D :**") st.write(f"- **Dimensions du modèle :** {n_x} × {n_y} × {n_z}") st.write(f"- **Volume total :** {n_x*n_y*n_z} cellules") st.write(f"- **Aquifères potentiels :** {(rho_3d < threshold_low).sum()} cellules ({(rho_3d < threshold_low).sum()/rho_3d.size*100:.1f}%)") st.write(f"- **Formations résistives :** {(rho_3d > threshold_high).sum()} cellules ({(rho_3d > threshold_high).sum()/rho_3d.size*100:.1f}%)") st.write(f"- **Trajectoires détectées :** {len(trajectories)}") # Recommandations st.markdown("### 🎯 Recommandations pour Forages") if (rho_3d < threshold_low).sum() > rho_3d.size * 0.1: # > 10% de zones basses st.success("✅ **Zones aquifères détectées** - Bon potentiel pour forages d'eau douce") else: st.warning("⚠️ **Peu de zones aquifères** - Nécessite exploration complémentaire") if len(trajectories) > 0: st.info(f"📍 **{len(trajectories)} interfaces géologiques** identifiées - Structuration complexe du sous-sol") except Exception as e: st.error(f"❌ Erreur lors de la visualisation 3D : {str(e)}") # =================== GÉNÉRATION IA FINALE - SYNTHÈSE COMPLÈTE =================== # Cette section apparaît EN DERNIER, après TOUTES les étapes d'analyse if ('spectra' in st.session_state and 'rho_imputed' in st.session_state and 'rho_3d' in st.session_state): st.markdown("---") st.markdown("---") st.markdown("---") st.header("🎯 GÉNÉRATION IA FINALE - RENDU SCIENTIFIQUEMENT EXACT") st.success("🎉 **TOUTES LES ANALYSES TERMINÉES !** Le LLM peut maintenant créer un rendu PRÉCIS du sous-sol.") st.markdown(""" ### 🧠 POURQUOI CETTE SECTION EST CRUCIALE ? **⚠️ IMPORTANCE SCIENTIFIQUE** : Cette étape finale est la SEULE qui garantit un rendu géologiquement exact ! **Le LLM Mistral va collecter et analyser :** 1. 📊 **Spectres extraits** → Distribution des résistivités (min/max/moyenne) 2. 🔧 **Données imputées** → Valeurs manquantes comblées intelligemment 3. ⚛️ **Modélisation forward** → Simulation physique des mesures électriques 4. 🎯 **Reconstruction 3D** → Volume complet du sous-sol (cellules/convergence) 5. 📍 **Trajectoires détectées** → Structures géologiques linéaires (failles/couches) **🎯 RÉSULTAT** : Le LLM génère un **prompt ultra-précis** qui guide les modèles IA pour créer : - ✅ Coupes géologiques EXACTES (pas approximatives) - ✅ Profondeurs RÉELLES des couches - ✅ Identification PRÉCISE des minéraux et formations - ✅ Structures conformes aux calculs physiques **Sans cette analyse complète** = Image générique ≠ Votre sous-sol réel ⚠️ """) st.markdown(""" ### 🧠 COMMENT ÇA FONCTIONNE ? **🔄 WORKFLOW INTELLIGENT EN TEMPS RÉEL :** **Étape 1 - Collecte par le LLM :** ``` Mistral LLM analyse EN DIRECT toutes vos données : ├─ 📊 Spectres extraits (résistivités mesurées) ├─ 🔧 Imputation (valeurs comblées) ├─ ⚛️ Forward modeling (simulations physiques) ├─ 🎯 Reconstruction 3D (volume complet) └─ 📍 Trajectoires (structures détectées) ``` **Étape 2 - Analyse Intelligente :** ``` Le LLM comprend la géologie en langage naturel : → "Présence d'un aquifère à 5-10m de profondeur" → "Couche argileuse conductrice en surface" → "Socle rocheux résistif à 15m" → "3 interfaces géologiques marquées" ``` **Étape 3 - Génération du Prompt Exact :** ``` Le LLM crée une description PRÉCISE pour les IA génératives : → Profondeurs exactes calculées → Types de roches identifiés → Résistivités mesurées → Structures géométriques détectées ``` **Étape 4 - Création du Sous-Sol Réel :** ``` Les IA génératives (Stable Diffusion, etc.) utilisent ce prompt pour créer une image CONFORME aux données physiques réelles → Coupes géologiques exactes → Minéraux et formations identifiés → Profondeurs calculées → Structures conformes aux mesures ``` **✅ RÉSULTAT** : Sous-sol visualisé = Sous-sol réel (pas une approximation !) """) st.error(""" 🚨 **AVERTISSEMENT SCIENTIFIQUE** : Sans le LLM, les IA génératives créent des images génériques qui NE CORRESPONDENT PAS à vos mesures réelles. Avec le LLM, chaque pixel de l'image est guidé par vos calculs géophysiques → FIABILITÉ SCIENTIFIQUE ! """) # NOUVEAU : Analyse intelligente complète avec Mistral LLM st.markdown("---") st.markdown("### 🤖 Activation du LLM Mistral (OBLIGATOIRE pour précision)") # NOTE: Section obsolète - la génération se fait maintenant avec PyGimli st.info("ℹ️ Les paramètres de génération IA ont été remplacés par la génération de coupes géologiques réelles PyGimli (voir section suivante)") # NOUVEAU : Analyse intelligente complète avec Mistral LLM st.markdown("---") st.markdown("### 🧠 Analyse Intelligente Complète par LLM Mistral") if st.checkbox("🤖 Activer l'analyse LLM complète (recommandé)", value=True, key="enable_llm_final"): st.info("⏳ Chargement du LLM Mistral et collecte des données en cours...") with st.spinner("🤖 Chargement du LLM Mistral..."): llm_pipeline = load_mistral_llm(use_cpu=True) if llm_pipeline is not None: st.success("✅ LLM Mistral chargé !") # Préparer toutes les données pour le LLM st.info("📊 Collecte de TOUTES les données des étapes précédentes...") spectra = st.session_state.get('spectra') rho_imputed = st.session_state.get('rho_imputed') rho_3d = st.session_state.get('rho_3d') rho_reconstructed = st.session_state.get('rho_reconstructed') trajectories = st.session_state.get('trajectories', []) # Afficher ce qui a été collecté with st.expander("🔍 Données collectées par le LLM"): st.write(f"✅ Spectres : {len(spectra) if spectra is not None else 0} mesures") st.write(f"✅ Imputation : {rho_imputed.size if rho_imputed is not None else 0} cellules") st.write(f"✅ Reconstruction 3D : {rho_3d.size if rho_3d is not None else 0} cellules") st.write(f"✅ Trajectoires : {len(trajectories)} structures détectées") if rho_reconstructed is not None: st.write(f"✅ Résistivités : {rho_reconstructed.min():.2f} - {rho_reconstructed.max():.2f} Ω·m") geophysical_data = { 'n_spectra': len(spectra) if spectra is not None else 0, 'rho_min': float(np.min([spectra.min(), rho_imputed.min(), rho_reconstructed.min()])) if all([spectra is not None, rho_imputed is not None, rho_reconstructed is not None]) else 0, 'rho_max': float(np.max([spectra.max(), rho_imputed.max(), rho_reconstructed.max()])) if all([spectra is not None, rho_imputed is not None, rho_reconstructed is not None]) else 0, 'rho_mean': float(rho_reconstructed.mean()) if rho_reconstructed is not None else 0, 'rho_std': float(rho_reconstructed.std()) if rho_reconstructed is not None else 0, 'n_imputed': int(np.isnan(st.session_state.get('rho_matrix', np.array([]))).sum()) if 'rho_matrix' in st.session_state else 0, 'imputation_method': st.session_state.get('imputation_method', 'N/A'), 'model_dims': f"{rho_3d.shape[0]}×{rho_3d.shape[1]}×{rho_3d.shape[2]}" if rho_3d is not None else 'N/A', 'n_cells': int(rho_3d.size) if rho_3d is not None else 0, 'convergence': str(st.session_state.get('convergence_info', 'N/A')), 'n_trajectories': len(trajectories), 'avg_ransac_score': float(np.mean([t['score'] for t in trajectories])) if trajectories else 0 } # Bouton pour lancer l'analyse LLM complète if st.button("🧠 Lancer l'analyse LLM complète", type="primary", key="llm_analysis_complete_btn"): # Créer la barre de progression et le texte de statut progress_bar = st.progress(0) progress_text = st.empty() def update_progress(message, value): """Callback pour mettre à jour la progression""" progress_bar.progress(value) progress_text.text(message) # Lancer l'analyse avec progression interpretation, recommendations, llm_prompt = analyze_data_with_mistral( llm_pipeline, geophysical_data, progress_callback=update_progress ) # Nettoyer les indicateurs de progression progress_bar.empty() progress_text.empty() if interpretation: st.success("✅ Analyse LLM complète terminée !") # Afficher l'interprétation complète st.markdown("#### 📊 Interprétation Géologique en Langage Naturel") st.info(f"**Le LLM a compris votre sous-sol :**\n\n{interpretation}") # Afficher les recommandations if recommendations: st.markdown("#### 🎯 Recommandations Stratégiques") st.warning(f"**Actions concrètes suggérées :**\n\n{recommendations}") # Stocker l'interprétation pour les coupes st.session_state['llm_interpretation'] = interpretation else: st.error("❌ Erreur lors de l'analyse LLM") st.markdown("#### 🗺️ Génération des Coupes Géologiques Réelles") st.success(""" ✅ Le système va maintenant créer DEUX coupes géologiques RÉELLES basées sur les données de résistivité : 1. **Coupe Brute** : Données spectrales initiales (1.3M mesures) 2. **Coupe Interprétée** : Données analysées par le LLM avec interprétation → Visualisations scientifiques EXACTES (pas d'IA générative artistique) """) st.markdown("---") # Paramètres de génération de coupes st.markdown("### ⚙️ Configuration des Coupes Géologiques") col_geo1, col_geo2 = st.columns(2) with col_geo1: depth_max = st.slider("Profondeur maximale (m)", 5, 100, 20, key="depth_max_geo", help="Profondeur à afficher sur les coupes") with col_geo2: show_interpretation = st.checkbox( "Afficher interprétation LLM sur la coupe", value=True, key="show_interpretation_geo", help="Ajoute le texte d'interprétation du LLM sur la coupe" ) # Bouton de génération des COUPES RÉELLES (PyGimli) if st.button("🗺️ GÉNÉRER LES COUPES GÉOLOGIQUES RÉELLES", key="generate_geological_sections", type="primary"): st.session_state['geological_sections_requested'] = True # Affichage persistant des résultats (session_state) if st.session_state.get('geological_sections_requested', False): with st.spinner("🗺️ Génération des coupes géologiques réelles... (10-15s)"): try: # Récupérer toutes les données spectra = st.session_state.get('spectra', None) positions = st.session_state.get('positions', None) rho_imputed = st.session_state.get('rho_imputed', None) rho_3d = st.session_state.get('rho_3d', None) llm_interpretation = st.session_state.get('llm_interpretation', "Analyse en cours...") st.markdown("---") st.markdown("### 📊 COUPE 1 : Données Spectrales Brutes (1.3M mesures)") # COUPE 1 : Données brutes if spectra is not None and positions is not None: # Créer une matrice 2D depuis les spectres x_coords = positions[:,0] y_coords = positions[:,1] x_unique = np.unique(x_coords) y_unique = np.unique(y_coords) rho_matrix_raw = np.full((len(y_unique), len(x_unique)), np.nan) for i, (x, y) in enumerate(zip(x_coords, y_coords)): x_idx = np.where(x_unique == x)[0][0] y_idx = np.where(y_unique == y)[0][0] rho_matrix_raw[y_idx, x_idx] = spectra[i] # Interpoler les NaN pour avoir une coupe continue from scipy.interpolate import griddata points = np.column_stack([x_coords, y_coords]) xi, yi = np.meshgrid(x_unique, y_unique) rho_raw_interp = griddata(points, spectra, (xi, yi), method='cubic', fill_value=np.nanmean(spectra)) fig_coupe1 = create_geological_cross_section_pygimli( rho_raw_interp, title="COUPE 1 : Données Spectrales Brutes (Mesures Terrain)", interpretation_text=None, depth_max=depth_max ) st.pyplot(fig_coupe1, clear_figure=False, use_container_width=True) # Stocker pour le PDF if 'figures_dict' not in st.session_state: st.session_state['figures_dict'] = {} st.session_state['figures_dict']['coupe_spectrale_brute'] = fig_coupe1 st.success(f"✅ Coupe 1 générée : {len(spectra):,} mesures de résistivité visualisées") st.markdown("---") st.markdown("### 🧠 COUPE 2 : Données Analysées par le LLM avec Interprétation") # COUPE 2 : Données analysées if rho_3d is not None: # Extraire une coupe centrale du volume 3D mid_y = rho_3d.shape[1] // 2 rho_slice_analyzed = rho_3d[:, mid_y, :] interpretation_for_plot = llm_interpretation if show_interpretation else None fig_coupe2 = create_geological_cross_section_pygimli( rho_slice_analyzed, title="COUPE 2 : Données Analysées avec Interprétation LLM", interpretation_text=interpretation_for_plot, depth_max=depth_max ) st.pyplot(fig_coupe2, clear_figure=False, use_container_width=True) # Stocker pour le PDF st.session_state['figures_dict']['coupe_analysee_llm'] = fig_coupe2 st.success(f"✅ Coupe 2 générée : Reconstruction 3D avec {rho_3d.size:,} cellules") elif rho_imputed is not None: # Si pas de 3D, utiliser les données imputées fig_coupe2 = create_geological_cross_section_pygimli( rho_imputed, title="COUPE 2 : Données Imputées avec Interprétation LLM", interpretation_text=interpretation_for_plot if show_interpretation else None, depth_max=depth_max ) st.pyplot(fig_coupe2, clear_figure=False, use_container_width=True) # Stocker pour le PDF st.session_state['figures_dict']['coupe_imputee_llm'] = fig_coupe2 st.success(f"✅ Coupe 2 générée : Données imputées avec {rho_imputed.size:,} cellules") # Stocker pour persistance st.session_state['geological_sections_complete'] = True st.markdown("---") st.markdown("### 📊 Statistiques Comparatives") col_stat1, col_stat2 = st.columns(2) with col_stat1: st.metric("**Coupe 1 (Brute)**", f"{len(spectra):,} mesures" if spectra is not None else "N/A", f"{np.min(spectra):.1f} - {np.max(spectra):.1f} Ω·m" if spectra is not None else "") with col_stat2: st.metric("**Coupe 2 (Analysée)**", f"{rho_3d.size:,} cellules" if rho_3d is not None else f"{rho_imputed.size:,} cellules", f"{np.min(rho_3d):.1f} - {np.max(rho_3d):.1f} Ω·m" if rho_3d is not None else f"{np.min(rho_imputed):.1f} - {np.max(rho_imputed):.1f} Ω·m") st.success(""" ✅ **COUPES GÉOLOGIQUES RÉELLES GÉNÉRÉES !** - **Coupe 1** : Représentation directe de vos mesures terrain - **Coupe 2** : Données enrichies par l'analyse LLM et reconstruction 3D → Les deux coupes sont basées sur vos VRAIES données de résistivité (pas d'IA générative) """) except Exception as e: st.error(f"❌ Erreur lors de la génération des coupes : {str(e)}") import traceback with st.expander("🔍 Détails techniques"): st.code(traceback.format_exc()) st.session_state['geological_sections_requested'] = False else: st.info("📸 Veuillez uploader une image géophysique pour commencer l'analyse spectrale.") # --- Sidebar --- logo_path = os.path.join(os.path.dirname(__file__), "logo_belikan.png") if os.path.exists(logo_path): st.sidebar.image(logo_path, width=200) st.sidebar.markdown("**SETRAF - Subaquifère ERT Analysis** \n" "💧 Outil d'analyse géophysique avancé \n" "Expert en hydrogéologie et tomographie électrique\n\n" "**Version Optimisée – 08 Novembre 2025** \n" "✅ Calculateur Ts intelligent (Ravensgate Sonic) \n" "✅ Analyse .dat + détection anomalies (K-Means avec cache) \n" "✅ Tableau résistivité eau (descriptions détaillées) \n" "✅ Pseudo-sections 2D/3D basées sur vos données réelles \n" "✅ **NOUVEAU** : Stratigraphie complète (sols + eaux + roches + minéraux) \n" "✅ **NOUVEAU** : Visualisation 3D interactive des matériaux par couches \n" "✅ **NOUVEAU** : Précision millimétrique (3 décimales sur tous les axes) \n" "✅ **NOUVEAU** : Inversion pyGIMLi - ERT géophysique avancée \n" "✅ **NOUVEAU** : Analyse Spectrale d'Images (Imputation + Reconstruction) \n" "✅ Interprétation multi-matériaux : 8 catégories géologiques \n" "✅ Performance optimisée avec @st.cache_data \n" "✅ Interpolation cubique cachée pour fluidité \n" "✅ Ticks basés sur mesures réelles (0.1, 0.2, 0.3...) \n" "✅ **Export PDF** : Rapports complets avec tous les graphiques\n\n" "**Exports disponibles** : \n" "📥 CSV - Données brutes \n" "📊 Excel - Tableaux formatés \n" "📄 PDF Standard - Rapport d'analyse DTW (150 DPI) \n" "📄 PDF Stratigraphique - Classification géologique complète (150 DPI)\n\n" "**Visualisations avancées** : \n" "🎨 Coupes 2D par type de matériau (8 plages de résistivité) \n" "🌐 Modèle 3D interactif (rotation 360°, zoom) \n" "📊 Histogrammes et profils de distribution \n" "🗺️ Cartographie spatiale des formations géologiques \n" "🔬 Inversion pyGIMLi avec classification hydrogéologique \n" "🖼️ Analyse spectrale d'images avec reconstruction 3D\n\n" "**Catégories géologiques identifiées** : \n" "💧 Eaux (mer, salée, douce, pure) \n" "🧱 Argiles & sols saturés \n" "🏖️ Sables & graviers \n" "🪨 Roches sédimentaires (calcaire, grès, schiste) \n" "🌋 Roches ignées & métamorphiques (granite, basalte) \n" "💎 Minéraux & minerais (graphite, cuivre, or, quartz)\n\n" "**Plages de résistivité** : \n" "- 0.001-1 Ω·m : Minéraux métalliques \n" "- 0.1-10 Ω·m : Eaux salées + argiles marines \n" "- 10-100 Ω·m : Eaux douces + sols fins \n" "- 100-1000 Ω·m : Sables saturés + graviers \n" "- 1000-10000 Ω·m : Roches sédimentaires \n" "- >10000 Ω·m : Socle cristallin (granite, quartzite) \n\n" "**🔬 Module pyGIMLi intégré** : \n" "- Inversion ERT complète avec algorithmes optimisés \n" "- Configurations Wenner, Schlumberger, Dipole-Dipole \n" "- Classification hydrogéologique automatique \n" "- Visualisation avec palette de couleurs physiques \n\n" "**🖼️ Module Analyse Spectrale d'Images** : \n" "- Extraction spectrale RGB vers résistivité synthétique \n" "- Imputation matricielle (Soft-Impute, KNN, Autoencoder) \n" "- Modélisation forward neutrino-inspired \n" "- Reconstruction 3D avec régularisation Tikhonov \n" "- Détection de trajectoires par RANSAC \n" "- Visualisation 3D interactive des anomalies") # ===================== CHAT GLOBAL EN BAS DE PAGE ===================== st.markdown("---") st.markdown("## 💬 Chat IA - Posez vos questions") if st.session_state.get('llm_loaded', False): # Initialiser l'historique de chat if 'chat_messages' not in st.session_state: st.session_state.chat_messages = [] # Afficher les messages for message in st.session_state.chat_messages: with st.chat_message(message["role"]): st.markdown(message["content"]) # Zone de saisie (HORS des tabs) user_question = st.chat_input("Posez une question sur la géophysique, ERT, vos données...") if user_question: st.session_state.chat_messages.append({"role": "user", "content": user_question}) with st.chat_message("assistant"): message_placeholder = st.empty() # Indicateur de recherche RAG message_placeholder.info("🔍 Recherche dans la base de connaissances...") # Utiliser le chat amélioré avec RAG try: # Récupérer la base de connaissances kb = st.session_state.get('ert_knowledge_base') # Génération avec RAG et streaming full_response = enhanced_chat_with_rag( user_question=user_question, llm_pipeline=st.session_state.llm_pipeline, knowledge_base=kb, current_data_context=st.session_state.get('current_data_context') ) # DÉTECTION ET EXÉCUTION D'OUTILS tool_results = "" if 'dataframe' in st.session_state: df = st.session_state['dataframe'] # Détecter les commandes d'outils dans la réponse import re tool_commands = re.findall(r'"([^"]*_(?:resistivite|stats|graphique|anomalies|donnees)\([^"]*\))"', full_response) if tool_commands: tool_results = "\n\n🛠️ EXÉCUTION D'OUTILS:\n" for cmd in tool_commands: result = execute_data_tool(cmd, df) tool_results += f"• {cmd}: {result}\n" # Ajouter les résultats à la réponse full_response += tool_results # Afficher la réponse complète message_placeholder.markdown(full_response) # Ajouter à l'historique st.session_state.chat_messages.append({"role": "assistant", "content": full_response}) except Exception as e: error_msg = f"❌ Erreur génération: {str(e)[:100]}" message_placeholder.error(error_msg) st.session_state.chat_messages.append({"role": "assistant", "content": error_msg}) st.session_state.chat_messages.append({"role": "assistant", "content": full_response}) st.rerun() # Boutons d'action if st.session_state.chat_messages: col_clear, col_export = st.columns([1, 1]) with col_clear: if st.button("🗑️ Effacer la conversation"): st.session_state.chat_messages = [] st.rerun() with col_export: if st.button("💾 Exporter la conversation"): conversation_text = "" for msg in st.session_state.chat_messages: role = "**Vous**" if msg["role"] == "user" else "**Assistant IA**" conversation_text += f"{role}:\n{msg['content']}\n\n---\n\n" st.download_button( "📥 Télécharger la conversation", conversation_text, f"conversation_ia_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt", "text/plain" ) else: st.info("ℹ️ Le chat IA est disponible lorsque le modèle LLM est chargé.")