from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Union
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
PathLike = Union[str, Path]
def _as_1d(a: Any) -> np.ndarray:
v = np.asarray(a, dtype=float).ravel()
m = np.isfinite(v) & (v > 0.0)
return v[m]
def _prep_curve(
misfit: Any,
rough: Any,
lam: Any | None,
*,
sort: str = "auto", # auto|x|lambda
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
x = _as_1d(rough)
y = _as_1d(misfit)
n = int(min(x.size, y.size))
x, y = x[:n], y[:n]
if lam is None:
l = np.arange(n, dtype=float)
else:
l = np.asarray(lam, dtype=float).ravel()[:n]
if sort == "x":
idx = np.argsort(x)
elif sort == "lambda":
idx = np.argsort(l)
else:
mono = np.all(np.diff(l) >= 0) or np.all(np.diff(l) <= 0)
idx = np.argsort(l) if mono else np.argsort(x)
return x[idx], y[idx], l[idx]
def _movavg(v: np.ndarray, w: int) -> np.ndarray:
w = int(max(1, w))
if v.size == 0 or w == 1:
return v
k = np.ones(w, dtype=float) / float(w)
return np.convolve(v, k, mode="same")
def _curvature_kappa(
x: np.ndarray,
y: np.ndarray,
*,
smooth: int = 3,
) -> np.ndarray:
lx = np.log10(np.maximum(x, 1e-300))
ly = np.log10(np.maximum(y, 1e-300))
if smooth > 1:
lx = _movavg(lx, smooth)
ly = _movavg(ly, smooth)
t = np.arange(lx.size, dtype=float)
x1 = np.gradient(lx, t, edge_order=2)
y1 = np.gradient(ly, t, edge_order=2)
x2 = np.gradient(x1, t, edge_order=2)
y2 = np.gradient(y1, t, edge_order=2)
den = (x1 * x1 + y1 * y1 + 1e-24) ** 1.5
k = np.abs(x1 * y2 - y1 * x2) / den
return k
def _maxdist_knee(
x: np.ndarray,
y: np.ndarray,
) -> np.ndarray:
lx = np.log10(np.maximum(x, 1e-300))
ly = np.log10(np.maximum(y, 1e-300))
p0 = np.array([lx[0], ly[0]], dtype=float)
p1 = np.array([lx[-1], ly[-1]], dtype=float)
v = p1 - p0
den = np.hypot(v[0], v[1]) + 1e-24
# signed distance to end-point line
d = np.abs(v[1] * (lx - p0[0]) - v[0] * (ly - p0[1])) / den
return d
def _pick_corner(
x: np.ndarray,
y: np.ndarray,
*,
method: str = "curvature", # curvature|maxdist
smooth: int = 3,
skip: int = 1,
) -> tuple[int, np.ndarray]:
n = int(min(x.size, y.size))
if n <= 2:
return 0, np.zeros(n)
if method == "maxdist":
s = _maxdist_knee(x, y)
else:
s = _curvature_kappa(x, y, smooth=smooth)
lo = int(max(0, skip))
hi = int(max(lo + 1, n - skip))
j = int(lo + np.nanargmax(s[lo:hi]))
return j, s
[docs]
def lcurve_table(
misfit: Any,
rough: Any,
lam: Any | None = None,
*,
sort: str = "auto",
method: str = "curvature",
smooth: int = 3,
skip: int = 1,
return_dict: bool = False,
):
x, y, l = _prep_curve(misfit, rough, lam, sort=sort)
if x.size == 0:
if return_dict:
return dict(rough=x, misfit=y, lam=l, curv=x, slope=x, corner=None)
if pd is None:
return None
df = pd.DataFrame(columns=["rough", "misfit", "lam", "curv", "slope"])
df.attrs["corner_idx"] = None
return df
j, s = _pick_corner(x, y, method=method, smooth=smooth, skip=skip)
lx = np.log10(x)
ly = np.log10(y)
# local slope d log(misfit) / d log(rough)
sl = np.gradient(ly) / (np.gradient(lx) + 1e-24)
if return_dict:
return dict(rough=x, misfit=y, lam=l, curv=s, slope=sl, corner=j)
df = pd.DataFrame(dict(rough=x, misfit=y, lam=l, curv=s, slope=sl))
df.attrs["corner_idx"] = j
return df
# ------------------------- real-inversion adapters ----------------------- #
[docs]
@dataclass
class LCurveData:
"""Misfit/roughness/lambda sweep extracted from a real inversion log.
``misfit[i]``, ``rough[i]``, ``lam[i]``, and ``iterations[i]`` all
describe the same iteration, so the arrays can be passed straight
into :func:`lcurve_table` or :func:`plot_lcurve` -- which is exactly
what :meth:`table` and :meth:`plot` do.
"""
misfit: np.ndarray
rough: np.ndarray
lam: np.ndarray
iterations: np.ndarray
backend: str
source: Path
[docs]
def table(self, **kwargs: Any):
"""Call :func:`lcurve_table` on this sweep."""
return lcurve_table(self.misfit, self.rough, self.lam, **kwargs)
[docs]
def plot(self, **kwargs: Any):
"""Call :func:`plot_lcurve` on this sweep."""
kwargs.setdefault("labels", [self.backend])
return plot_lcurve(self.misfit, self.rough, self.lam, **kwargs)
def _finite_positive_mask(*arrays: np.ndarray) -> np.ndarray:
m = np.ones(arrays[0].shape, dtype=bool)
for a in arrays:
m &= np.isfinite(a) & (a > 0.0)
return m
[docs]
def lcurve_from_occam2d(path: PathLike, **kwargs: Any) -> LCurveData:
"""Build an :class:`LCurveData` sweep from an Occam2D convergence log.
Reads the per-iteration ``LogFile.logfile`` written by the Occam2D
Fortran binary with :class:`pycsamt.models.occam2d.log.OccamLog`,
which already parses accepted RMS misfit, roughness, and Lagrange
multiplier for every completed iteration. Rows with a non-finite or
non-positive misfit or roughness (for example a final iteration that
stopped on "Convergence problems" before writing ``ROUGHNESS IS``)
are dropped, since :func:`lcurve_table` requires strictly positive
values for its log-log scoring.
Parameters
----------
path : path-like
Path to an Occam2D log file, typically ``LogFile.logfile``.
**kwargs
Forwarded to ``OccamLog.read`` (for example ``verbose``).
Returns
-------
LCurveData
``rough`` holds Occam's reported ``ROUGHNESS IS`` values,
``misfit`` the accepted RMS, and ``lam`` the accepted Lagrange
multiplier (linear scale, not log10).
Examples
--------
>>> from pycsamt.emtools.lcurve import lcurve_from_occam2d
>>> sweep = lcurve_from_occam2d("data/occam2D/LogFile.logfile")
>>> sweep.backend
'occam2d'
>>> ax = sweep.plot()
"""
from pycsamt.models.occam2d.log import OccamLog
log = OccamLog.read(path, **kwargs)
mask = _finite_positive_mask(log.rms, log.roughness)
return LCurveData(
misfit=log.rms[mask],
rough=log.roughness[mask],
lam=log.lagrange[mask],
iterations=log.iterations[mask],
backend="occam2d",
source=Path(path),
)
[docs]
def lcurve_from_modem(path: PathLike, **kwargs: Any) -> LCurveData:
"""Build an :class:`LCurveData` sweep from a ModEM NLCG log.
Reads the per-iteration ``Completed NLCG iteration`` blocks written
by ModEM with :class:`pycsamt.models.modem.log.ModEmLog`. ModEM
reports the model-regularization term directly as ``m2`` rather than
a roughness norm, so ``rough`` here is ``m2`` -- the same quantity
:math:`\\lambda\\,\\Phi_m(m)` trades off against data misfit in
ModEM's objective function, and it plays the identical x-axis role
on an L-curve.
Parameters
----------
path : path-like
Path to a ModEM log file, typically ``Modular_NLCG.log``.
**kwargs
Forwarded to ``ModEmLog.read`` (for example ``verbose``).
Returns
-------
LCurveData
``rough`` holds ModEM's ``m2`` model-norm term, ``misfit`` the
reported ``rms``, and ``lam`` the damping parameter ``lambda``.
Examples
--------
>>> from pycsamt.emtools.lcurve import lcurve_from_modem
>>> sweep = lcurve_from_modem(
... "data/modem/willy_27freq_watex_line02_sample/Modular_NLCG.log"
... )
>>> sweep.backend
'modem'
>>> ax = sweep.plot()
"""
from pycsamt.models.modem.log import ModEmLog
log = ModEmLog.read(path, **kwargs)
mask = _finite_positive_mask(log.rms, log.model_norm)
return LCurveData(
misfit=log.rms[mask],
rough=log.model_norm[mask],
lam=log.lagrange[mask],
iterations=log.iterations[mask],
backend="modem",
source=Path(path),
)
[docs]
def lcurve_from_mare2dem(path: PathLike, **kwargs: Any) -> LCurveData:
"""Build an :class:`LCurveData` sweep from a MARE2DEM convergence log.
Reads the per-iteration ``** Iteration N **`` blocks written by
MARE2DEM with :class:`pycsamt.models.mare2dem.log.Mare2DEMLog`,
which already parses ``Model Misfit``, ``Roughness``, and
``Optimal Mu`` for every completed iteration. MARE2DEM reports
``Optimal Mu`` as :math:`\\log_{10}\\mu`, so it is converted back to
linear scale here to match the convention used by the other two
adapters.
Parameters
----------
path : path-like
Path to a MARE2DEM log file, typically ``*.logfile``.
**kwargs
Accepted for interface symmetry with the other adapters;
``Mare2DEMLog`` takes no extra keyword arguments.
Returns
-------
LCurveData
``rough`` holds MARE2DEM's reported roughness, ``misfit`` the
model misfit, and ``lam`` the optimal mu converted to linear
scale (``10 ** log10_mu``).
Examples
--------
>>> from pycsamt.emtools.lcurve import lcurve_from_mare2dem
>>> sweep = lcurve_from_mare2dem(
... "data/mare2dem/demo_mt_inversion/demo.logfile"
... )
>>> sweep.backend
'mare2dem'
>>> ax = sweep.plot()
"""
from pycsamt.models.mare2dem.log import Mare2DEMLog
del kwargs # accepted for interface symmetry only
log = Mare2DEMLog(path)
misfit = np.array([r.rms for r in log.iterations], dtype=float)
rough = np.array([r.roughness for r in log.iterations], dtype=float)
lam = 10.0 ** np.array([r.lambda_ for r in log.iterations], dtype=float)
iterations = np.array([r.iteration for r in log.iterations], dtype=int)
mask = _finite_positive_mask(misfit, rough)
return LCurveData(
misfit=misfit[mask],
rough=rough[mask],
lam=lam[mask],
iterations=iterations[mask],
backend="mare2dem",
source=Path(path),
)
# ------------------------------- plotting ------------------------------- #
[docs]
def plot_lcurve(
misfit: Any | Sequence[Any],
rough: Any | Sequence[Any],
lam: Any | Sequence[Any] | None = None,
*,
labels: Sequence[str] | None = None,
colors: Sequence[str] | None = None,
cmap: str = "viridis",
marker: str = "o",
ms: float = 3.0,
lw: float = 1.4,
alpha: float = 0.9,
show_points: bool = True,
show_path: bool = True,
arrow_every: int = 0, # 0 disables
method: str = "curvature",
smooth: int = 3,
skip: int = 1,
show_corner: bool = True,
corner_style: dict[str, Any] | None = None,
show_inset: bool = True,
inset_loc: tuple[float, float, float, float] = (0.62, 0.12, 0.32, 0.32),
label_every: int = 0, # 0 disables per-point lambda/tau labels
label_prefix: str = "",
label_fontsize: float = 7.0,
target_misfit: float | None = None,
target_label: str = "target misfit",
figsize: tuple[float, float] = (6.0, 4.6),
ax: plt.Axes | None = None,
):
# normalize to list of curves
if isinstance(misfit, (list, tuple)):
Ms = list(misfit)
Rs = list(rough) # type: ignore
Ls = list(lam) if isinstance(lam, (list, tuple)) else [None] * len(Ms)
labs = (
list(labels)
if labels is not None
else [f"C{i}" for i in range(len(Ms))]
)
else:
Ms = [misfit]
Rs = [rough]
Ls = [lam]
labs = [labels[0]] if labels else ["curve"]
if colors is None:
cols = [None] * len(Ms)
else:
cols = list(colors)
# main axes
if ax is None:
fig, ax = plt.subplots(figsize=figsize)
else:
fig = ax.figure
# store corners for legend text
leg = []
# Shared inset axes: created once and reused across curves, rather
# than once per curve at the identical `inset_loc` (which used to
# silently stack N insets on top of each other, leaving only the
# last curve's score visible).
axins = None
for i, (m, r, la) in enumerate(zip(Ms, Rs, Ls)):
x, y, l = _prep_curve(m, r, la, sort="auto")
if x.size == 0:
continue
j, score = _pick_corner(x, y, method=method, smooth=smooth, skip=skip)
c = cols[i]
if c is None:
c = plt.get_cmap(cmap)(0.15 + 0.65 * i / max(1, len(Ms) - 1))
# path and points
ax.set_xscale("log")
ax.set_yscale("log")
if show_path:
ax.plot(x, y, "-", color=c, lw=lw, alpha=alpha)
if show_points:
ax.scatter(
x,
y,
c=np.linspace(0, 1, x.size),
s=12 * ms,
cmap=cmap,
edgecolors="none",
alpha=0.85,
)
# optional arrows showing λ direction
if arrow_every and x.size > 2:
step = int(max(1, arrow_every))
for k in range(0, x.size - step, step):
ax.annotate(
"",
xy=(x[k + step], y[k + step]),
xytext=(x[k], y[k]),
arrowprops=dict(
arrowstyle="->", lw=0.8, color=c, shrinkA=0, shrinkB=0
),
)
# per-point lambda/tau labels, e.g. "τ=100" at every Nth point
if label_every and x.size > 0:
step = int(max(1, label_every))
for k in range(0, x.size, step):
ax.annotate(
f"{label_prefix}{l[k]:.3g}",
xy=(x[k], y[k]),
xytext=(4, 4),
textcoords="offset points",
fontsize=label_fontsize,
color=c,
)
# corner highlight
if show_corner:
sty = dict(marker="*", ms=9, mec="k", mfc=c, mew=0.8)
if corner_style:
sty.update(corner_style)
ax.plot([x[j]], [y[j]], **sty)
leg.append(f"{labs[i]} λ*≈{l[j]:.3g}")
else:
leg.append(labs[i])
# inset curvature / distance -- one shared inset for all curves
if show_inset:
if axins is None:
axins = fig.add_axes(inset_loc)
axins.set_xticks([])
axins.set_yticks([])
axins.set_title(
"curv" if method == "curvature" else "knee",
fontsize=8,
)
idx = np.arange(score.size)
axins.plot(idx, score, "-", color=c, lw=1.0)
axins.axvline(j, color=c, ls="--", lw=1.0)
if target_misfit is not None:
ax.axhline(
target_misfit, color="0.2", ls=":", lw=1.2, label=target_label
)
leg.append(target_label)
ax.grid(True, alpha=0.25, which="both")
ax.set_xlabel("||Lm|| (model roughness)")
ax.set_ylabel("||Gm−d|| (data misfit)")
if leg:
ax.legend(leg, loc="best", frameon=False, fontsize=9)
return ax