Source code for pycsamt.ai.plot.compare

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
Side-by-side true vs predicted 1-D resistivity profile panels.

:func:`plot_compare` is the primary entry point.  Each panel shows:

* Predicted model (solid, purple, ``EM_COLORS['pred']``)
* True model (dashed, green, ``EM_COLORS['true']``)
* Shaded difference band (alpha = 0.15)
* Per-site RMSE annotation

Usage
-----
>>> from pycsamt.ai.plot.compare import plot_compare
>>> fig = plot_compare(true_models, pred_models, n_cols=5)
>>> fig.savefig("comparison.png", dpi=300)
"""

from __future__ import annotations

from collections.abc import Sequence

import numpy as np

__all__ = ["plot_compare", "plot_profile_pair"]


# ─────────────────────────────────────────────────────────────────────────────
# Public API
# ─────────────────────────────────────────────────────────────────────────────


[docs] def plot_compare( true_models, pred_models, *, n_cols: int = 5, max_sites: int = 20, depth_max: float | None = None, log_scale: bool = True, show_rmse: bool = True, site_labels: Sequence[str] | None = None, figsize_per_panel: tuple = (2.2, 4.0), title: str | None = None, style: bool = True, ): """ Multi-panel comparison of true and predicted 1-D resistivity profiles. Parameters ---------- true_models : list of LayeredModel or ndarray (n_sites, n_params) Ground-truth models. pred_models : list of LayeredModel or ndarray (n_sites, n_params) Network-predicted models (same order as *true_models*). n_cols : int Number of columns in the panel grid. max_sites : int Maximum number of sites to plot (first *max_sites* are used). depth_max : float or None Maximum depth axis value [m]. log_scale : bool Log₁₀ x-axis for resistivity. show_rmse : bool Annotate each panel with per-site log₁₀(ρ) RMSE. site_labels : list of str or None Panel titles (default: ``'Site 0'``, ``'Site 1'``, …). figsize_per_panel : (w, h) Size of each individual panel in inches. title : str or None Figure suptitle. style : bool Apply :class:`~pycsamt.ai.plot._style.EMStyle`. Returns ------- fig : Figure """ import matplotlib.pyplot as plt from ._style import EM_COLORS, EMStyle n_sites = min(len(true_models), len(pred_models), max_sites) n_rows = int(np.ceil(n_sites / n_cols)) fw = figsize_per_panel[0] * n_cols fh = figsize_per_panel[1] * n_rows ctx = EMStyle() if style else _NullContext() with ctx: fig, axes = plt.subplots( n_rows, n_cols, figsize=(fw, fh), sharey=False, ) axes = np.asarray(axes).ravel() for i in range(n_sites): ax = axes[i] label = site_labels[i] if site_labels else f"Site {i}" tm = _to_model(true_models[i]) pm = _to_model(pred_models[i]) _draw_pair( ax, tm, pm, depth_max=depth_max, log_scale=log_scale, show_rmse=show_rmse, label=label, colors=EM_COLORS, ) # Hide empty panels for j in range(n_sites, len(axes)): axes[j].set_visible(False) if title: fig.suptitle(title, fontsize=13, y=1.01) fig.tight_layout() return fig
[docs] def plot_profile_pair( true_model, pred_model, *, ax=None, depth_max: float | None = None, log_scale: bool = True, show_rmse: bool = True, legend: bool = True, style: bool = True, ): """ Plot a single true/predicted resistivity–depth pair on *ax*. Parameters ---------- true_model, pred_model : LayeredModel or ndarray (n_params,) ax : Axes or None depth_max : float or None log_scale : bool show_rmse : bool legend : bool style : bool Returns ------- ax : Axes """ import matplotlib.pyplot as plt from ._style import EM_COLORS, EMStyle if ax is None: ctx = EMStyle() if style else _NullContext() with ctx: _, ax = plt.subplots(figsize=(3.5, 5)) tm = _to_model(true_model) pm = _to_model(pred_model) _draw_pair( ax, tm, pm, depth_max=depth_max, log_scale=log_scale, show_rmse=show_rmse, label="", colors=EM_COLORS, legend=legend, ) return ax
# ───────────────────────────────────────────────────────────────────────────── # Internal helpers # ───────────────────────────────────────────────────────────────────────────── def _draw_pair( ax, true_m, pred_m, *, depth_max, log_scale, show_rmse, label, colors, legend=False, ): """Draw one profile pair onto *ax*.""" # True model _plot_profile( ax, true_m, color=colors["true"], linestyle="--", lw=1.4, label="True" ) # Predicted model _plot_profile( ax, pred_m, color=colors["pred"], linestyle="-", lw=1.8, label="Predicted", ) # Shade the difference _shade_diff(ax, true_m, pred_m) ax.invert_yaxis() if log_scale: ax.set_xscale("log") dmax = depth_max or _max_depth(true_m, pred_m) ax.set_ylim(dmax, 0.0) ax.set_xlabel(r"ρ (Ω·m)", fontsize=9) ax.set_ylabel("Depth (m)", fontsize=9) ax.tick_params(labelsize=8) ax.set_title(label, fontsize=9, pad=3) if show_rmse: err = _rmse_log_rho(true_m, pred_m) if np.isfinite(err): ax.text( 0.97, 0.97, f"RMSE={err:.3f}", transform=ax.transAxes, ha="right", va="top", fontsize=7.5, color=colors["error"], bbox=dict( facecolor="white", alpha=0.7, edgecolor="none", pad=1 ), ) if legend: ax.legend(fontsize=8, loc="lower left") def _plot_profile(ax, model, **kw): """Staircase resistivity–depth plot.""" rho, depth = _staircase(model) ax.plot(rho, depth, **kw) def _shade_diff(ax, true_m, pred_m): """Shade area between true and predicted profiles.""" rho_t, depth = _staircase(true_m) rho_p, _ = _staircase(pred_m) n = min(len(rho_t), len(rho_p)) ax.fill_betweenx( depth[:n], rho_t[:n], rho_p[:n], alpha=0.12, color="#762a83" ) def _staircase(model): """Convert LayeredModel or ndarray to (rho_steps, depth_steps).""" rho, depths = _model_arrays(model) # Build staircase: each layer contributes top and bottom value n = len(rho) rho_step = np.repeat(rho, 2) d_step = np.empty(2 * n) d_step[0::2] = depths d_step[1::2] = np.concatenate([depths[1:], [depths[-1] * 1.5]]) return rho_step, d_step def _model_arrays(model): """Return (rho_array, depth_array) from LayeredModel or vector.""" try: from pycsamt.forward.synthetic import LayeredModel if isinstance(model, LayeredModel): return model.resistivity, model.depth except ImportError: pass # Assume flat vector: first half = log10(rho), second half = thick v = np.asarray(model, dtype=float).ravel() n = (len(v) + 1) // 2 rho = 10.0 ** v[:n] thick = np.maximum(v[n:], 1.0) depths = np.concatenate([[0.0], np.cumsum(thick)]) return rho, depths def _max_depth(true_m, pred_m): _, dt = _model_arrays(true_m) _, dp = _model_arrays(pred_m) return float(max(dt[-1], dp[-1])) * 1.1 def _rmse_log_rho(true_m, pred_m): rho_t, _ = _model_arrays(true_m) rho_p, _ = _model_arrays(pred_m) n = min(len(rho_t), len(rho_p)) diff = np.log10(np.maximum(rho_t[:n], 1e-6)) - np.log10( np.maximum(rho_p[:n], 1e-6) ) return float(np.sqrt(np.mean(diff**2))) def _to_model(m): """Pass-through — already a model or array.""" return m class _NullContext: def __enter__(self): return self def __exit__(self, *_): pass