Code/utils/graph_utils.py
Stéphan Peccini 6d2e877341
feat(audit): audit qualité complet — 907→0 erreurs ruff + fix multiselect labels
- Correction des 907 erreurs ruff (pathlib, imports, nommage, simplifications, docstrings)
- Fix déduplication labels dans multiselect nœuds d'arrivée (analyse)
- Expansion 1→N label→IDs pour le Sankey (Pays d'opération)
- Ajout CLAUDE.md et document de design de l'audit
- Mise à jour .gitignore (artefacts tests exploratoires)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:52:01 +01:00

326 lines
11 KiB
Python

import logging
import pathlib
import networkx as nx
import pandas as pd
import streamlit as st
import yaml
from networkx.drawing.nx_agraph import read_dot
from config import DOT_FILE
from utils.gitea import charger_schema_depuis_gitea
from utils.logger import setup_logger
logger = setup_logger(__name__)
def extraire_chemins_depuis(G, source):
"""Extrait tous les chemins depuis un noeud source jusqu'aux feuilles du graphe.
Utilise un parcours en profondeur iteratif (DFS) pour explorer tous les chemins
possibles depuis le noeud source. Evite les cycles en verifiant que chaque noeud
n'apparait qu'une fois par chemin.
Args:
G: Graphe NetworkX dirige.
source: Noeud de depart.
Returns:
list[list[str]]: Liste de chemins, ou chaque chemin est une liste de noeuds.
"""
chemins = []
stack = [(source, [source])]
while stack:
(node, path) = stack.pop()
voisins = list(G.successors(node))
if not voisins:
chemins.append(path)
else:
for voisin in voisins:
if voisin not in path:
stack.append((voisin, path + [voisin]))
return chemins
def extraire_chemins_vers(G, target, niveau_demande):
"""Extrait tous les chemins vers un noeud cible contenant un niveau specifique.
Parcourt le graphe inverse depuis la cible vers les racines, et filtre uniquement
les chemins qui contiennent au moins un noeud du niveau demande.
Args:
G: Graphe NetworkX dirige.
target: Noeud d'arrivee.
niveau_demande: Niveau hierarchique requis dans le chemin (0=Produit, 1=Composant, etc.).
Returns:
list[list[str]]: Liste de chemins (du niveau demande vers la cible) qui contiennent
au moins un noeud du niveau demande.
"""
chemins = []
reverse_G = G.reverse()
niveaux = nx.get_node_attributes(G, "niveau")
stack = [(target, [target])]
while stack:
(node, path) = stack.pop()
voisins = list(reverse_G.successors(node))
if not voisins:
chemin_inverse = list(reversed(path))
contient_niveau = any(
int(niveaux.get(n, -1)) == niveau_demande for n in chemin_inverse
)
if contient_niveau:
chemins.append(chemin_inverse)
else:
for voisin in voisins:
if voisin not in path:
stack.append((voisin, path + [voisin]))
return chemins
def recuperer_donnees(graph, noeuds):
"""Recupere les donnees IHH et ICS pour les noeuds d'operations-minerais.
Calcule l'ICS moyen pour chaque minerai base sur les aretes entrantes depuis
les fabrications, puis extrait les donnees IHH (pays/acteurs) pour chaque operation.
Args:
graph: Graphe NetworkX contenant les attributs ihh_pays, ihh_acteurs, ics.
noeuds: Liste de noeuds au format "Operation_Minerai" (ex: "Traitement_Lithium").
Returns:
pd.DataFrame: DataFrame avec colonnes categorie, nom, ihh_pays, ihh_acteurs, ics_minerai, ics_cat.
"""
donnees = []
ics = {}
for noeud in noeuds:
try:
operation, minerai = noeud.split('_', 1)
except ValueError:
logging.warning(f"Nom de nœud inattendu : {noeud}")
continue
if operation == "Traitement":
try:
fabrications = list(graph.predecessors(minerai))
valeurs = [
int(float(graph.get_edge_data(f, minerai)[0].get('ics', 0)) * 100)
for f in fabrications
if graph.get_edge_data(f, minerai)
]
if valeurs:
ics[minerai] = round(sum(valeurs) / len(valeurs))
except Exception as e:
logging.warning(f"Erreur criticité pour {noeud} : {e}")
ics[minerai] = 50
for noeud in noeuds:
try:
operation, minerai = noeud.split('_', 1)
ihh_pays = int(graph.nodes[noeud].get('ihh_pays', 0))
ihh_acteurs = int(graph.nodes[noeud].get('ihh_acteurs', 0))
ics_val = ics.get(minerai, 50)
ics_cat = 1 if ics_val <= 33 else (2 if ics_val <= 66 else 3)
donnees.append({
'categorie': operation,
'nom': minerai,
'ihh_pays': ihh_pays,
'ihh_acteurs': ihh_acteurs,
'ics_minerai': ics_val,
'ics_cat': ics_cat
})
except Exception as e:
logging.error(f"Erreur sur le nœud {noeud} : {e}", exc_info=True)
return pd.DataFrame(donnees)
def recuperer_donnees_2(graph, noeuds_2):
"""Recupere les donnees IVC et IHH pour les minerais (niveau 2).
Extrait l'IVC du minerai et les IHH d'extraction/reserves pour chaque minerai.
Ignore les minerais dont les noeuds associes sont manquants.
Args:
graph: Graphe NetworkX contenant les attributs ivc, ihh_pays.
noeuds_2: Liste de noms de minerais (niveau 2).
Returns:
list[dict]: Liste de dictionnaires avec cles nom, ivc, ihh_extraction, ihh_reserves.
"""
donnees = []
for minerai in noeuds_2:
try:
missing = []
if not graph.has_node(minerai):
missing.append(minerai)
if not graph.has_node(f"Extraction_{minerai}"):
missing.append(f"Extraction_{minerai}")
if not graph.has_node(f"Reserves_{minerai}"):
missing.append(f"Reserves_{minerai}")
if missing:
logger.warning(f"Nœuds manquants pour {minerai} : {', '.join(missing)} — Ignoré.")
continue
ivc = int(graph.nodes[minerai].get('ivc', 0))
ihh_extraction_pays = int(graph.nodes[f"Extraction_{minerai}"].get('ihh_pays', 0))
ihh_reserves_pays = int(graph.nodes[f"Reserves_{minerai}"].get('ihh_pays', 0))
donnees.append({
'nom': minerai,
'ivc': ivc,
'ihh_extraction': ihh_extraction_pays,
'ihh_reserves': ihh_reserves_pays
})
except Exception as e:
logger.error(f"Erreur avec le nœud {minerai} : {e}", exc_info=True)
return donnees
def load_seuils_config(path: str = "assets/config.yaml") -> dict:
"""Charge les seuils depuis le fichier de configuration YAML.
Args:
path (str): Chemin vers le fichier de configuration.
Returns:
dict: Dictionnaire contenant les seuils pour chaque indice.
"""
try:
data = yaml.safe_load(pathlib.Path(path).read_text(encoding="utf-8"))
return data.get("seuils", {})
except Exception as e:
logging.warning(f"Erreur lors du chargement des seuils depuis {path}: {e}")
# Valeurs par défaut en cas d'erreur
return {
"ISG": {"vert": {"max": 40}, "orange": {"min": 40, "max": 70}, "rouge": {"min": 70}},
"IHH": {"vert": {"max": 15}, "orange": {"min": 15, "max": 25}, "rouge": {"min": 25}},
"IVC": {"vert": {"max": 15}, "orange": {"min": 15, "max": 60}, "rouge": {"min": 60}}
}
def determiner_couleur_par_seuil(valeur: int, seuils_indice: dict) -> str:
"""Détermine la couleur en fonction de la valeur et des seuils configurés.
Logique alignée avec determine_threshold_color du projet.
Args:
valeur (int): Valeur de l'indice à évaluer.
seuils_indice (dict): Seuils pour cet indice depuis la configuration.
Returns:
str: Couleur correspondante ("darkgreen", "orange", "darkred", "gray").
"""
if valeur < 0:
return "gray"
# Vérifier d'abord le seuil rouge (priorité la plus haute)
if "rouge" in seuils_indice and "min" in seuils_indice["rouge"] and valeur >= seuils_indice["rouge"]["min"]:
return "darkred"
# Ensuite le seuil orange
if "orange" in seuils_indice and "min" in seuils_indice["orange"] and "max" in seuils_indice["orange"] and valeur >= seuils_indice["orange"]["min"] and valeur < seuils_indice["orange"]["max"]:
return "orange"
# Seuil vert (valeurs inférieures au seuil orange)
if "vert" in seuils_indice and "max" in seuils_indice["vert"] and valeur < seuils_indice["vert"]["max"]:
return "darkgreen"
# Par défaut orange si on ne trouve pas de correspondance exacte
return "orange"
def couleur_noeud(n: str, niveaux: dict, G: nx.DiGraph) -> str:
"""Détermine la couleur d'un nœud en fonction de son niveau et de ses attributs.
Utilise les seuils définis dans le fichier de configuration.
Args:
n (str): Nom du nœud.
niveaux (dict): Dictionnaire associant chaque nœud à son niveau.
G (nx.DiGraph): Graphe NetworkX contenant les données.
Returns:
str: Couleur du nœud.
"""
niveau = niveaux.get(n, 99)
attrs = G.nodes[n]
seuils = load_seuils_config()
# Niveau 99 : pays géographique avec isg
if niveau == 99:
isg = int(attrs.get("isg", -1))
if isg >= 0 and "ISG" in seuils:
return determiner_couleur_par_seuil(isg, seuils["ISG"])
return "gray"
# Niveau 11 ou 12 connecté à un pays géographique
if niveau in (11, 12, 1011, 1012):
for succ in G.successors(n):
if niveaux.get(succ) == 99:
isg = int(G.nodes[succ].get("isg", -1))
if isg >= 0 and "ISG" in seuils:
return determiner_couleur_par_seuil(isg, seuils["ISG"])
return "gray"
# Logique existante pour IHH / IVC
if niveau in (10, 1010) and attrs.get("ihh_pays"):
ihh = int(attrs["ihh_pays"])
if "IHH" in seuils:
return determiner_couleur_par_seuil(ihh, seuils["IHH"])
# Fallback vers les anciennes valeurs
return (
"darkgreen" if ihh <= 15 else
"orange" if ihh <= 25 else
"darkred"
)
if niveau == 2 and attrs.get("ivc"):
ivc = int(attrs["ivc"])
if "IVC" in seuils:
return determiner_couleur_par_seuil(ivc, seuils["IVC"])
# Fallback vers les anciennes valeurs
return (
"darkgreen" if ivc <= 15 else
"orange" if ivc <= 30 else
"darkred"
)
return "lightblue"
def charger_graphe():
"""Charge le graphe DOT depuis Gitea et le stocke dans st.session_state.
Telecharge le schema DOT depuis Gitea (avec cache local), parse le fichier DOT
en graphe NetworkX, et stocke le resultat dans session_state. Cree egalement
une copie pour les visualisations IVC.
Returns:
bool: True si le graphe a ete charge avec succes, False sinon.
Note:
Le graphe est stocke dans st.session_state["G_temp"] et st.session_state["G_temp_ivc"].
"""
if "G_temp" not in st.session_state:
try:
if charger_schema_depuis_gitea(DOT_FILE):
st.session_state["G_temp"] = read_dot(DOT_FILE)
st.session_state["G_temp_ivc"] = st.session_state["G_temp"].copy()
dot_file_path = True
else:
dot_file_path = False
except Exception as e:
st.error(f"Erreur de lecture du fichier DOT : {e}")
dot_file_path = False
else:
dot_file_path = True
if dot_file_path:
return dot_file_path
st.error("Impossible de charger le graphe pour cet onglet.")
return dot_file_path