Suppression éclairage solaire, GPU accéléré, --file multi, tests unitaires

- Suppression de generate_solar (éclairage solaire) des visualisations
- Accélération GPU de hillshade, slope, aspect, curvature, depressions,
  anomalies, roughness, texture GLCM, flow (sink filling)
- Nettoyage mémoire GPU entre visualisations (gpu_cleanup)
- Correction OOM texture GLCM: calcul entropie bin par bin au lieu d'un
  tableau 3D massif sur GPU
- Correction bug: xp_minimum_filter manquant dans imports visualizations
- Option --file accepte plusieurs noms complets sans extension
- run.sh affiche l'aide si appelé sans arguments
- Option --test pour exécuter les tests unitaires dans Docker
- Filtre ReturnNumber>=1 intégré dans le pipeline PDAL (plus d'erreur SMRF)
- 60 tests unitaires: GPU, visualisations, rendering, DTM, pipeline, CLI
- Ajout pytest au Dockerfile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jacquin Antoine
2026-05-10 00:57:39 +02:00
parent f07e915f6d
commit ad762e682d
17 changed files with 998 additions and 252 deletions

View File

@ -10,11 +10,10 @@ from pathlib import Path
import numpy as np
import rasterio
from scipy import ndimage
from scipy.ndimage import generic_filter, gaussian_filter, uniform_filter, minimum_filter
from scipy.ndimage import generic_filter
from scipy.stats import binned_statistic_2d
from .gpu import HAS_GPU, to_gpu, to_cpu, xp_gaussian_filter, xp_uniform_filter
from .gpu import HAS_GPU, to_gpu, to_cpu, xp_gaussian_filter, xp_uniform_filter, xp_minimum_filter
logger = logging.getLogger("lidar")
@ -58,31 +57,38 @@ def _read_dem(dem_file):
# ============================================================
def generate_hillshade(dem_file, basename, vis_dir, resolution):
"""Generate multi-directional hillshade (NW, NE, SW, SE)."""
logger.info(" → Hillshade multidirectionnel...")
"""Generate multi-directional hillshade (NW, NE, SW, SE) — GPU if available."""
gpu_tag = " [GPU]" if HAS_GPU else ""
logger.info(f" → Hillshade multidirectionnel{gpu_tag}...")
t0 = time.time()
output = vis_dir / f"{basename}_hillshade_multi.tif"
try:
dem, transform, crs = _read_dem(dem_file)
dy, dx = np.gradient(dem)
dem_np, transform, crs = _read_dem(dem_file)
dem = to_gpu(dem_np)
dy, dx = xp.gradient(dem)
azimuts = [315, 45, 225, 135]
altitude = 30
hillshades = []
for az in azimuts:
az_rad = np.radians(az)
alt_rad = np.radians(altitude)
slope = np.arctan(np.sqrt(dx**2 + dy**2))
aspect = np.arctan2(dy, dx)
hs = np.sin(alt_rad) * np.sin(slope) + \
np.cos(alt_rad) * np.cos(slope) * np.cos(az_rad - aspect)
hillshades.append(np.clip(hs, 0, 1))
slope = xp.arctan(xp.sqrt(dx**2 + dy**2))
aspect = xp.arctan2(dy, dx)
sin_slope = xp.sin(slope)
cos_slope = xp.cos(slope)
combined = np.mean(hillshades, axis=0)
_save_tif(output, combined, transform, crs)
logger.info(f" ✓ Hillshade terminé ({time.time()-t0:.1f}s)")
alt_rad = xp.radians(xp.array(altitude))
sin_alt = xp.sin(alt_rad)
cos_alt = xp.cos(alt_rad)
for az in azimuts:
az_rad = xp.radians(xp.array(az))
hs = sin_alt * sin_slope + cos_alt * cos_slope * xp.cos(az_rad - aspect)
hillshades.append(xp.clip(hs, 0, 1))
combined = xp.mean(xp.array(hillshades), axis=0)
_save_tif(output, to_cpu(combined), transform, crs)
logger.info(f" ✓ Hillshade terminé ({time.time()-t0:.1f}s){gpu_tag}")
return output
except Exception as e:
logger.error(f" ✗ Erreur hillshade: {e}", exc_info=True)
@ -90,17 +96,19 @@ def generate_hillshade(dem_file, basename, vis_dir, resolution):
def generate_slope(dem_file, basename, vis_dir, resolution):
"""Generate slope map (degrees)."""
logger.info(" → Pente (Slope)...")
"""Generate slope map (degrees) — GPU if available."""
gpu_tag = " [GPU]" if HAS_GPU else ""
logger.info(f" → Pente (Slope){gpu_tag}...")
t0 = time.time()
output = vis_dir / f"{basename}_slope.tif"
try:
dem, transform, crs = _read_dem(dem_file)
dy, dx = np.gradient(dem)
slope = np.arctan(np.sqrt(dx**2 + dy**2)) * 180 / np.pi
_save_tif(output, slope, transform, crs)
logger.info(f" ✓ Pente terminée ({time.time()-t0:.1f}s)")
dem_np, transform, crs = _read_dem(dem_file)
dem = to_gpu(dem_np)
dy, dx = xp.gradient(dem)
slope = xp.arctan(xp.sqrt(dx**2 + dy**2)) * 180 / xp.pi
_save_tif(output, to_cpu(slope), transform, crs)
logger.info(f" ✓ Pente terminée ({time.time()-t0:.1f}s){gpu_tag}")
return output
except Exception as e:
logger.error(f" ✗ Erreur slope: {e}", exc_info=True)
@ -108,18 +116,20 @@ def generate_slope(dem_file, basename, vis_dir, resolution):
def generate_aspect(dem_file, basename, vis_dir, resolution):
"""Generate aspect (slope orientation) map."""
logger.info(" → Aspect (Orientation)...")
"""Generate aspect (slope orientation) map — GPU if available."""
gpu_tag = " [GPU]" if HAS_GPU else ""
logger.info(f" → Aspect (Orientation){gpu_tag}...")
t0 = time.time()
output = vis_dir / f"{basename}_aspect.tif"
try:
dem, transform, crs = _read_dem(dem_file)
dy, dx = np.gradient(dem)
aspect = np.arctan2(dy, dx) * 180 / np.pi
aspect = np.mod(aspect, 360)
_save_tif(output, aspect, transform, crs)
logger.info(f" ✓ Aspect terminé ({time.time()-t0:.1f}s)")
dem_np, transform, crs = _read_dem(dem_file)
dem = to_gpu(dem_np)
dy, dx = xp.gradient(dem)
aspect = xp.arctan2(dy, dx) * 180 / xp.pi
aspect = xp.mod(aspect, 360)
_save_tif(output, to_cpu(aspect), transform, crs)
logger.info(f" ✓ Aspect terminé ({time.time()-t0:.1f}s){gpu_tag}")
return output
except Exception as e:
logger.error(f" ✗ Erreur aspect: {e}", exc_info=True)
@ -127,49 +137,28 @@ def generate_aspect(dem_file, basename, vis_dir, resolution):
def generate_curvature(dem_file, basename, vis_dir, resolution):
"""Generate curvature (terrain concavity/convexity) map."""
logger.info(" → Courbure (Curvature)...")
"""Generate curvature (terrain concavity/convexity) map — GPU if available."""
gpu_tag = " [GPU]" if HAS_GPU else ""
logger.info(f" → Courbure (Curvature){gpu_tag}...")
t0 = time.time()
output = vis_dir / f"{basename}_curvature.tif"
try:
dem, transform, crs = _read_dem(dem_file)
dz_dx = np.gradient(dem, axis=1)
dz_dy = np.gradient(dem, axis=0)
d2z_dx2 = np.gradient(dz_dx, axis=1)
d2z_dy2 = np.gradient(dz_dy, axis=0)
dem_np, transform, crs = _read_dem(dem_file)
dem = to_gpu(dem_np)
dz_dx = xp.gradient(dem, axis=1)
dz_dy = xp.gradient(dem, axis=0)
d2z_dx2 = xp.gradient(dz_dx, axis=1)
d2z_dy2 = xp.gradient(dz_dy, axis=0)
curvature = (d2z_dx2 + d2z_dy2) / 2
_save_tif(output, curvature, transform, crs)
logger.info(f" ✓ Courbure terminée ({time.time()-t0:.1f}s)")
_save_tif(output, to_cpu(curvature), transform, crs)
logger.info(f" ✓ Courbure terminée ({time.time()-t0:.1f}s){gpu_tag}")
return output
except Exception as e:
logger.error(f" ✗ Erreur curvature: {e}", exc_info=True)
return None
def generate_solar(dem_file, basename, vis_dir, resolution):
"""Generate solar irradiance simulation."""
logger.info(" → Éclairage Solaire...")
t0 = time.time()
output = vis_dir / f"{basename}_solar.tif"
try:
dem, transform, crs = _read_dem(dem_file)
dy, dx = np.gradient(dem)
slope = np.arctan(np.sqrt(dx**2 + dy**2))
aspect = np.arctan2(dy, dx)
az_rad = np.radians(90)
alt_rad = np.radians(30)
solar = np.sin(alt_rad) * np.sin(slope) + \
np.cos(alt_rad) * np.cos(slope) * np.cos(az_rad - aspect)
solar = np.clip(solar, 0, 1)
_save_tif(output, solar, transform, crs)
logger.info(f" ✓ Solaire terminé ({time.time()-t0:.1f}s)")
return output
except Exception as e:
logger.error(f" ✗ Erreur solar: {e}", exc_info=True)
return None
# ============================================================
# GPU-accelerated visualizations
@ -390,45 +379,46 @@ def generate_tpi(dem_file, basename, vis_dir, resolution):
# ============================================================
def generate_depressions(dem_file, basename, vis_dir, resolution):
"""Depression detection using hydrological sink filling."""
logger.info(" → Détection dépressions (hydrologique)...")
"""Depression detection using hydrological sink filling — GPU if available."""
gpu_tag = " [GPU]" if HAS_GPU else ""
logger.info(f" → Détection dépressions (hydrologique){gpu_tag}...")
t0 = time.time()
output = vis_dir / f"{basename}_depressions.tif"
try:
dem, transform, crs = _read_dem(dem_file)
from scipy.ndimage import binary_dilation, generate_binary_structure
dem_filled = dem.copy()
nodata_mask = np.isnan(dem_filled)
dem_filled[nodata_mask] = np.nanmax(dem) + 1000
dem_np, transform, crs = _read_dem(dem_file)
dem = to_gpu(dem_np)
from scipy.ndimage import generate_binary_structure
struct = generate_binary_structure(2, 2)
dem_filled = xp.copy(dem)
nodata_mask = xp.isnan(dem_filled)
dem_filled[nodata_mask] = xp.nanmax(dem) + 1000
changed = True
iterations = 0
max_iter = 100
while changed and iterations < max_iter:
from scipy.ndimage import minimum_filter as scipy_min_filter
neighbor_min = scipy_min_filter(dem_filled, footprint=struct)
neighbor_min = xp_minimum_filter(dem_filled, footprint=struct)
sinks = (dem_filled < neighbor_min) & ~nodata_mask
if not np.any(sinks):
if not xp.any(sinks):
break
new_dem = np.maximum(dem_filled, neighbor_min)
new_dem[nodata_mask] = np.nan
changed = np.any(new_dem != dem_filled)
new_dem = xp.maximum(dem_filled, neighbor_min)
new_dem[nodata_mask] = xp.nan
changed = bool(xp.any(new_dem != dem_filled))
dem_filled = new_dem
iterations += 1
depressions = dem_filled - dem
depressions[nodata_mask] = np.nan
depressions = to_cpu(dem_filled - dem)
depressions[to_cpu(nodata_mask)] = np.nan
depressions = np.where(depressions > 0.01, depressions, 0)
_save_tif(output, depressions, transform, crs)
logger.info(f" ✓ Dépressions terminé ({time.time()-t0:.1f}s)")
logger.info(f" ✓ Dépressions terminé ({time.time()-t0:.1f}s){gpu_tag}")
return output
except Exception as e:
logger.error(f" ✗ Erreur dépressions: {e}", exc_info=True)
@ -489,24 +479,27 @@ def generate_sailore(dem_file, basename, vis_dir, resolution):
# ============================================================
def generate_roughness(dem_file, basename, vis_dir, resolution):
"""Surface roughness - standard deviation of elevation in a window."""
logger.info(" → Rugosité de surface...")
"""Surface roughness - standard deviation of elevation in a window (GPU-accelerated)."""
gpu_tag = " [GPU]" if HAS_GPU else ""
logger.info(f" → Rugosité de surface{gpu_tag}...")
t0 = time.time()
output = vis_dir / f"{basename}_roughness.tif"
try:
dem, transform, crs = _read_dem(dem_file)
dem_np, transform, crs = _read_dem(dem_file)
dem = to_gpu(dem_np.astype(np.float64))
window_size = int(5 / resolution)
if window_size % 2 == 0:
window_size += 1
def std_filter(arr):
return np.nanstd(arr)
# Vectorized std: sqrt(E[X²] - (E[X])²) via uniform_filter (GPU-accelerated)
local_mean = xp_uniform_filter(dem, size=window_size)
local_mean_sq = xp_uniform_filter(dem * dem, size=window_size)
roughness = xp.sqrt(local_mean_sq - local_mean * local_mean)
roughness = generic_filter(dem.astype(np.float64), std_filter,
size=window_size, mode='nearest')
roughness = to_cpu(roughness)
_save_tif(output, roughness, transform, crs)
logger.info(f" ✓ Rugosité terminée ({time.time()-t0:.1f}s)")
logger.info(f" ✓ Rugosité terminée ({time.time()-t0:.1f}s){gpu_tag}")
return output
except Exception as e:
logger.error(f" ✗ Erreur rugosité: {e}", exc_info=True)
@ -518,29 +511,33 @@ def generate_roughness(dem_file, basename, vis_dir, resolution):
# ============================================================
def generate_anomalies(dem_file, basename, vis_dir, resolution):
"""Statistical anomaly detection - z-score of local relief + Local Moran's I."""
logger.info(" → Détection anomalies statistiques...")
"""Statistical anomaly detection - z-score of local relief + Local Moran's I — GPU if available."""
gpu_tag = " [GPU]" if HAS_GPU else ""
logger.info(f" → Détection anomalies statistiques{gpu_tag}...")
t0 = time.time()
output = vis_dir / f"{basename}_anomalies.tif"
try:
dem, transform, crs = _read_dem(dem_file)
dem_np, transform, crs = _read_dem(dem_file)
dem = to_gpu(dem_np)
lrm = dem - gaussian_filter(dem, sigma=15 / resolution)
lrm_mean = np.nanmean(lrm)
lrm_std = max(np.nanstd(lrm), 0.01)
lrm = dem - xp_gaussian_filter(dem, sigma=15 / resolution)
lrm_mean = xp.nanmean(lrm)
lrm_std = max(float(xp.nanstd(lrm)), 0.01)
z_score = (lrm - lrm_mean) / lrm_std
window = int(10 / resolution)
if window % 2 == 0:
window += 1
local_mean = uniform_filter(z_score, size=window)
morans_i = z_score * (local_mean - np.nanmean(z_score)) / np.nanstd(z_score)
anomaly_score = np.abs(z_score) * np.sign(morans_i)
local_mean = xp_uniform_filter(z_score, size=window)
z_mean = xp.nanmean(z_score)
z_std = max(float(xp.nanstd(z_score)), 0.01)
morans_i = z_score * (local_mean - z_mean) / z_std
anomaly_score = xp.abs(z_score) * xp.sign(morans_i)
_save_tif(output, anomaly_score, transform, crs)
logger.info(f" ✓ Anomalies terminé ({time.time()-t0:.1f}s)")
_save_tif(output, to_cpu(anomaly_score), transform, crs)
logger.info(f" ✓ Anomalies terminé ({time.time()-t0:.1f}s){gpu_tag}")
return output
except Exception as e:
logger.error(f" ✗ Erreur anomalies: {e}", exc_info=True)
@ -595,21 +592,24 @@ def generate_wavelet(dem_file, basename, vis_dir, resolution):
# ============================================================
def generate_texture(dem_file, basename, vis_dir, resolution):
"""GLCM texture analysis on hillshade - contrast, entropy, homogeneity."""
logger.info(" → Texture GLCM...")
"""GLCM-inspired texture analysis contrast, entropy, homogeneity (GPU-accelerated)."""
gpu_tag = " [GPU]" if HAS_GPU else ""
logger.info(f" → Texture GLCM{gpu_tag}...")
t0 = time.time()
output = vis_dir / f"{basename}_texture.tif"
try:
dem, transform, crs = _read_dem(dem_file)
dem_np, transform, crs = _read_dem(dem_file)
gy, gx = np.gradient(dem, resolution)
# Hillshade — compute on CPU to avoid holding DEM on GPU during texture
gy, gx = np.gradient(dem_np, resolution)
slope = np.arctan(np.sqrt(gx**2 + gy**2))
alt_rad = np.radians(45)
az_rad = np.radians(315)
aspect = np.arctan2(gy, gx)
shading = (np.sin(alt_rad) * np.cos(slope) +
np.cos(alt_rad) * np.sin(slope) *
np.cos(az_rad - np.arctan2(gy, gx)))
np.cos(az_rad - aspect))
hillshade = np.clip(shading, 0, 1)
valid = hillshade[~np.isnan(hillshade)]
@ -617,31 +617,39 @@ def generate_texture(dem_file, basename, vis_dir, resolution):
raise ValueError("No valid data for texture analysis")
lo, hi = np.percentile(valid, (1, 99))
img = np.clip((hillshade - lo) / max(hi - lo, 0.001), 0, 1)
img_uint8 = (img * 255).astype(np.uint8)
del hillshade, shading, slope, aspect, gy, gx # free memory
window = int(5 / resolution)
if window % 2 == 0:
window += 1
def local_variance(arr):
return np.var(arr.astype(np.float64))
# Contrast (variance) — GPU-accelerated
img_gpu = to_gpu(img.astype(np.float32))
local_mean = xp_uniform_filter(img_gpu, size=window)
local_mean_sq = xp_uniform_filter(img_gpu * img_gpu, size=window)
contrast = to_cpu(local_mean_sq - local_mean * local_mean).astype(np.float64)
del img_gpu, local_mean, local_mean_sq # free GPU memory
def local_entropy(arr):
hist, _ = np.histogram(arr.astype(np.float64), bins=16, range=(0, 256))
hist = hist / max(hist.sum(), 1)
hist = hist[hist > 0]
return -np.sum(hist * np.log2(hist))
# Entropy — compute bin-by-bin to avoid large 3D allocation
n_bins = 16
img_uint8 = np.clip(img * 255, 0, 255).astype(np.uint8)
quantized = (img_uint8 // (256 // n_bins)).astype(np.int32)
entropy = np.zeros_like(img, dtype=np.float64)
win_area = max(window * window, 1)
def local_homogeneity(arr):
arr_f = arr.astype(np.float64)
return np.mean(1.0 / (1.0 + (arr_f - np.mean(arr_f)) ** 2))
for b in range(n_bins):
plane = (quantized == b).astype(np.float32)
plane_gpu = to_gpu(plane)
prob_plane = to_cpu(xp_uniform_filter(plane_gpu, size=window))
prob_val = prob_plane / win_area
prob_val = np.clip(prob_val, 1e-10, None)
entropy -= prob_val * np.log2(prob_val)
del plane_gpu # free GPU memory per bin
contrast = generic_filter(img_uint8.astype(np.float64), local_variance,
size=window, mode='nearest')
entropy = generic_filter(img_uint8.astype(np.float64), local_entropy,
size=window, mode='nearest')
homogeneity = generic_filter(img_uint8.astype(np.float64), local_homogeneity,
size=window, mode='nearest')
del quantized, img_uint8 # free CPU memory
# Homogeneity — 1 / (1 + variance)
homogeneity = 1.0 / (1.0 + contrast)
def norm(arr):
valid_arr = arr[~np.isnan(arr)]
@ -650,12 +658,10 @@ def generate_texture(dem_file, basename, vis_dir, resolution):
std_val = max(np.std(valid_arr), 0.01)
return (arr - np.mean(valid_arr)) / std_val
texture_combined = (0.4 * norm(contrast) +
0.4 * norm(entropy) -
0.2 * norm(homogeneity))
texture_combined = 0.4 * norm(contrast) + 0.4 * norm(entropy) - 0.2 * norm(homogeneity)
_save_tif(output, texture_combined, transform, crs)
logger.info(f" ✓ Texture terminée ({time.time()-t0:.1f}s)")
logger.info(f" ✓ Texture terminée ({time.time()-t0:.1f}s){gpu_tag}")
return output
except Exception as e:
logger.error(f" ✗ Erreur texture GLCM: {e}", exc_info=True)
@ -667,55 +673,58 @@ def generate_texture(dem_file, basename, vis_dir, resolution):
# ============================================================
def generate_flow(dem_file, basename, vis_dir, resolution):
"""Flow accumulation using D8 algorithm.
Identifies drainage patterns, ditches, and enclosure boundaries.
"""
logger.info(" → Accumulation de flux D8...")
"""Flow accumulation using D8 algorithm — sink filling on GPU, accumulation on CPU."""
gpu_tag = " [GPU]" if HAS_GPU else ""
logger.info(f" → Accumulation de flux D8{gpu_tag}...")
t0 = time.time()
output = vis_dir / f"{basename}_flow.tif"
try:
dem, transform, crs = _read_dem(dem_file)
rows, cols = dem.shape
nodata_mask = np.isnan(dem)
dem_np, transform, crs = _read_dem(dem_file)
rows, cols = dem_np.shape
nodata_mask = np.isnan(dem_np)
from scipy.ndimage import minimum_filter as scipy_min_filter, generate_binary_structure
dem_filled = dem.copy()
dem_filled[nodata_mask] = np.nanmax(dem) + 1000
# Sink filling — GPU-accelerated
dem_gpu = to_gpu(dem_np)
nodata_mask_gpu = xp.isnan(dem_gpu)
dem_filled = xp.copy(dem_gpu)
dem_filled[nodata_mask_gpu] = xp.nanmax(dem_gpu) + 1000
from scipy.ndimage import generate_binary_structure
struct = generate_binary_structure(2, 2)
for _ in range(50):
neighbor_min = scipy_min_filter(dem_filled, footprint=struct)
sinks = (dem_filled < neighbor_min) & ~nodata_mask
if not np.any(sinks):
neighbor_min = xp_minimum_filter(dem_filled, footprint=struct)
sinks = (dem_filled < neighbor_min) & ~nodata_mask_gpu
if not xp.any(sinks):
break
dem_filled = np.where(sinks, neighbor_min, dem_filled)
dem_filled = xp.where(sinks, neighbor_min, dem_filled)
dem_filled[nodata_mask] = np.nan
dem_filled[nodata_mask_gpu] = xp.nan
dem_filled_np = to_cpu(dem_filled)
# D8 slope + accumulation — CPU (sequential by nature)
dx8 = [1, 1, 0, -1, -1, -1, 0, 1]
dy8 = [0, 1, 1, 1, 0, -1, -1, -1]
dist8 = [1.0, np.sqrt(2), 1.0, np.sqrt(2), 1.0, np.sqrt(2), 1.0, np.sqrt(2)]
flow_dir = np.full((rows, cols), -1, dtype=np.int8)
max_slope = np.full((rows, cols), 0.0)
max_slope = np.zeros((rows, cols), dtype=np.float64)
padded = np.pad(dem_filled, 1, mode='constant',
constant_values=np.nanmax(dem_filled) + 10000)
padded = np.pad(dem_filled_np, 1, mode='constant',
constant_values=np.nanmax(dem_filled_np[~np.isnan(dem_filled_np)]) + 10000)
for d in range(8):
nx = 1 + dx8[d]
ny = 1 + dy8[d]
neighbor_elev = padded[ny:ny + rows, nx:nx + cols]
slope = (dem_filled - neighbor_elev) / (dist8[d] * resolution)
slope = (dem_filled_np - neighbor_elev) / (dist8[d] * resolution)
slope[nodata_mask] = -1
better = slope > max_slope
flow_dir[better] = d
max_slope[better] = slope[better]
flat_dem = dem_filled[~nodata_mask].flatten()
flat_dem = dem_filled_np[~nodata_mask].flatten()
valid_indices = np.where(~nodata_mask.flatten())[0]
sort_order = valid_indices[np.argsort(-flat_dem)]
@ -733,7 +742,7 @@ def generate_flow(dem_file, basename, vis_dir, resolution):
flow_log = np.log1p(flow_acc)
_save_tif(output, flow_log, transform, crs)
logger.info(f" ✓ Flux terminé ({time.time()-t0:.1f}s)")
logger.info(f" ✓ Flux terminé ({time.time()-t0:.1f}s){gpu_tag}")
return output
except Exception as e:
logger.error(f" ✗ Erreur flux: {e}", exc_info=True)