# 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 :
- Probabilité HAUTE (> 70%) : Interprétation la plus probable
- Probabilité MOYENNE (40-70%) : Possible selon le contexte
- Probabilité BASSE (< 40%) : Peu probable, nécessite confirmation
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é.")