Source code for pycsamt.emtools.dimensionality

# pycsamt/emtools/dimensionality.py
from __future__ import annotations

from typing import Any

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from matplotlib.lines import Line2D

from ..api.labels import LOG10_PERIOD_LABEL
from ..api.station import PYCSAMT_STATION_RENDERING
from ..api.view import maybe_wrap_frame
from ._core import (
    _apply_each,
    _get_t_block,
    _get_z_block,
    _iter_items,
    _name,
    ensure_sites,
)
from .strike import (
    estimate_strike_consensus,
    estimate_strike_phase_tensor,
    estimate_strike_sweep,
    strike_curve_sweep,
)
from .tensor import build_phase_tensor_table
from .tensor import rotate as _tensor_rotate
from .tensor import (
    rotate_to_strike as _tensor_rotate_to_strike,
)

# -------------------------- local helpers ------------------------------- #


def _det_phase_from_z(z: np.ndarray) -> np.ndarray:
    detz = z[:, 0, 0] * z[:, 1, 1] - z[:, 0, 1] * z[:, 1, 0]
    return np.degrees(np.angle(detz))


def _rho_det_from_z(z: np.ndarray, fr: np.ndarray) -> np.ndarray:
    zx = z[:, 0, 1]
    zy = z[:, 1, 0]
    rx = 0.2 * (np.abs(zx) ** 2) / (fr + 1e-24)
    ry = 0.2 * (np.abs(zy) ** 2) / (fr + 1e-24)
    rdet = np.sqrt(rx * ry)
    return rdet


def _tip_amp(t: np.ndarray | None) -> np.ndarray | None:
    if t is None:
        return None
    return np.sqrt(np.abs(t[:, 0]) ** 2 + np.abs(t[:, 1]) ** 2)


[docs] def phase_features_table( sites: Any, *, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, api: bool | None = None, ) -> Any: S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) pt = build_phase_tensor_table( S, recursive=False, on_dup=on_dup, strict=False, verbose=verbose, ) if pt.empty: cols = [ "station", "freq", "period", "beta_abs", "ellipt_abs", "logrho_det", "phi_det", "tip_amp", ] df = pd.DataFrame(columns=cols) return maybe_wrap_frame( df, api=api, name="phase_features_table", kind="emtools.dimensionality.features", source=sites, ) rows: list[dict[str, float]] = [] for i, ed in enumerate(_iter_items(S)): st = _name(ed, i) Z, z, fr = _get_z_block(ed) if Z is None: continue T, t, _ = _get_t_block(ed) rho = _rho_det_from_z(z, fr) lgr = np.log10(np.maximum(rho, 1e-12)) ph = _det_phase_from_z(z) ta = _tip_amp(t) if T is not None else None mask = pt["station"] == st pdf = pt.loc[mask] if pdf.empty: continue # align by nearest period p_pt = pdf["period"].to_numpy() p_z = 1.0 / fr idx = np.searchsorted(p_pt, p_z) idx = np.clip(idx, 0, len(p_pt) - 1) beta_abs = np.abs(pdf["beta"].to_numpy()[idx]) ellipt = np.abs(pdf["ellipt"].to_numpy()[idx]) for j in range(len(fr)): rows.append( dict( station=st, freq=float(fr[j]), period=float(p_z[j]), beta_abs=float(beta_abs[j]), ellipt_abs=float(ellipt[j]), logrho_det=float(lgr[j]), phi_det=float(ph[j]), tip_amp=float(ta[j]) if ta is not None else np.nan, ) ) df = pd.DataFrame.from_records(rows) return maybe_wrap_frame( df, api=api, name="phase_features_table", kind="emtools.dimensionality.features", source=sites, description="Phase tensor and impedance-derived dimensionality features.", )
[docs] def classify_dimensionality( sites: Any, *, skew_th: float = 3.0, ellipt_th: float = 0.2, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, api: bool | None = None, ) -> Any: df = phase_features_table( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, api=False, ) if df.empty: return maybe_wrap_frame( df, api=api, name="dimensionality_table", kind="emtools.dimensionality.classification", source=sites, ) lab = np.full(len(df), 2, dtype=int) ok2 = df["beta_abs"] <= skew_th lab[ok2 & (df["ellipt_abs"] <= ellipt_th)] = 0 lab[ok2 & (df["ellipt_abs"] > ellipt_th)] = 1 out = df.copy() out["dim"] = lab # 0=1D, 1=2D, 2=3D return maybe_wrap_frame( out, api=api, name="dimensionality_table", kind="emtools.dimensionality.classification", source=sites, description="Rule-based dimensionality labels from phase features.", )
# -------------------- pre-2D inversion assessment ----------------------- #
[docs] def pre2d_inversion_assessment( sites: Any, *, band: tuple[float, float] | None = None, skew_th: float = 3.0, ellipt_th: float = 0.2, rotation_applied: bool = False, rotation_method: str = "consensus", groom_bailey_attempted: bool = False, groom_bailey_applied: bool = False, groom_bailey_reason: str | None = None, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, api: bool | None = None, ) -> Any: """Summarise dimensionality and strike checks before 2-D inversion. The table is designed for audit trails and manuscript responses. It combines phase-tensor skew/ellipticity dimensionality labels, impedance sweep strike, phase-tensor strike, consensus strike, and frequency-dependent strike variability. It also records whether data were rotated to strike and whether Groom-Bailey decomposition was attempted/applied. """ S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) dim = classify_dimensionality( S, skew_th=skew_th, ellipt_th=ellipt_th, recursive=False, on_dup=on_dup, strict=False, verbose=verbose, api=False, ) if band is not None and not dim.empty: lo, hi = float(band[0]), float(band[1]) dim = dim[(dim["period"] >= lo) & (dim["period"] <= hi)].copy() sweep = estimate_strike_sweep( S, band=band, recursive=False, on_dup=on_dup, strict=False, verbose=verbose, ) pt = estimate_strike_phase_tensor( S, band=band, recursive=False, on_dup=on_dup, strict=False, verbose=verbose, ) consensus = estimate_strike_consensus( S, band=band, recursive=False, on_dup=on_dup, strict=False, verbose=verbose, ) curve = strike_curve_sweep( S, recursive=False, on_dup=on_dup, strict=False, verbose=verbose, ) if band is not None and not curve.empty: lo, hi = float(band[0]), float(band[1]) curve = curve[ (curve["period"] >= lo) & (curve["period"] <= hi) ].copy() def _by_station(table: pd.DataFrame, station: str, col: str) -> float: if table.empty or col not in table.columns: return np.nan sub = table[table["station"].astype(str) == str(station)] if sub.empty: return np.nan vals = sub[col].to_numpy(dtype=float) return ( float(np.nanmedian(vals)) if np.isfinite(vals).any() else np.nan ) stations = [] for i, ed in enumerate(_iter_items(S)): stations.append(_name(ed, i)) if not stations and not dim.empty: stations = sorted(dim["station"].astype(str).unique()) gb_reason = groom_bailey_reason if gb_reason is None: gb_reason = ( "Groom-Bailey decomposition was not requested for this " "pre-2D assessment. Run pycsamt.emtools.groom_bailey_table " "or groom_bailey_decomposition to estimate and document it." ) rows: list[dict[str, Any]] = [] for station in stations: sdf = dim[dim["station"].astype(str) == str(station)] n = int(len(sdf)) if n: dim_vals = sdf["dim"].to_numpy(dtype=int) frac_1d = float(np.mean(dim_vals == 0)) frac_2d = float(np.mean(dim_vals == 1)) frac_3d = float(np.mean(dim_vals == 2)) beta_med = float(np.nanmedian(sdf["beta_abs"])) beta_p95 = float(np.nanpercentile(sdf["beta_abs"], 95)) ellipt_med = float(np.nanmedian(sdf["ellipt_abs"])) else: frac_1d = frac_2d = frac_3d = np.nan beta_med = beta_p95 = ellipt_med = np.nan cdf = curve[curve["station"].astype(str) == str(station)] if cdf.empty or "ang" not in cdf.columns: strike_curve_iqr = np.nan else: ang = cdf["ang"].to_numpy(dtype=float) strike_curve_iqr = ( float(np.nanpercentile(ang, 75) - np.nanpercentile(ang, 25)) if np.isfinite(ang).any() else np.nan ) cons_ang = _by_station(consensus, station, "ang") cons_iqr = _by_station(consensus, station, "iqr") if np.isfinite(frac_3d) and frac_3d > 0.5: recommendation = "review_3d_effects_before_2d" elif np.isfinite(cons_iqr) and cons_iqr > 20.0: recommendation = "unstable_strike_review_band" else: recommendation = "acceptable_for_2d_with_documented_rotation" rows.append( dict( station=station, period_min_s=float(band[0]) if band is not None else np.nan, period_max_s=float(band[1]) if band is not None else np.nan, n_samples=n, frac_1d=frac_1d, frac_2d=frac_2d, frac_3d=frac_3d, beta_abs_median=beta_med, beta_abs_p95=beta_p95, ellipt_abs_median=ellipt_med, strike_sweep_deg=_by_station(sweep, station, "ang"), strike_pt_deg=_by_station(pt, station, "ang"), strike_consensus_deg=cons_ang, strike_consensus_iqr_deg=cons_iqr, strike_curve_iqr_deg=strike_curve_iqr, rotated_to_strike=bool(rotation_applied), rotation_method=str(rotation_method), rotation_angle_deg=cons_ang if rotation_applied else np.nan, groom_bailey_attempted=bool(groom_bailey_attempted), groom_bailey_applied=bool(groom_bailey_applied), groom_bailey_reason=str(gb_reason), recommendation=recommendation, ) ) df = pd.DataFrame.from_records(rows) return maybe_wrap_frame( df, api=api, name="pre2d_inversion_assessment", kind="emtools.dimensionality.pre2d_assessment", source=sites, description=( "Dimensionality, strike, rotation, and Groom-Bailey status " "before 2-D inversion." ), )
# ---------------------- site-level masking/projection -------------------- #
[docs] def mask_by_dimensionality( sites: Any, *, keep: tuple[int, ...] = (0, 1), inplace: bool = False, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ): S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) tbl = classify_dimensionality( S, recursive=False, on_dup=on_dup, strict=False, verbose=verbose, ) if tbl.empty: return S def _one(Si): ed = next(_iter_items(Si)) st = getattr(ed, "station", None) or getattr(ed, "name", None) Z, z, fr = _get_z_block(ed) T, t, ft = _get_t_block(ed) if Z is None: return Si sdf = tbl[tbl["station"] == st] if sdf.empty: return Si # map per freq via nearest period per = 1.0 / fr p_ref = sdf["period"].to_numpy() idx = np.searchsorted(p_ref, per) idx = np.clip(idx, 0, len(p_ref) - 1) dsel = sdf["dim"].to_numpy()[idx] mkeep = np.isin(dsel, keep) z2 = z.copy() z2[~mkeep] = np.nan Z.z = z2 if T is not None and t is not None: t2 = t.copy() t2[~mkeep] = np.nan T.tipper = t2 return Si return _apply_each(S, _one, inplace=inplace, verbose=verbose)
[docs] def project_to_2d( sites: Any, *, strike: float | None = None, method: str = "swift", antisym: bool = True, inplace: bool = False, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ): S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) # rotate if strike is None: S = _tensor_rotate_to_strike( S, method=method, inplace=inplace, ) else: S = _tensor_rotate( S, float(strike), inplace=inplace, ) # antisymmetrize off-diagonals if antisym: from .tensor import antisymmetrize S = antisymmetrize( S, how="rms", inplace=True if inplace else False, ) return S
# --------------------- dictionary learning (MOD + ISTA) ------------------ # def _standardize(X: np.ndarray): mu = np.nanmean(X, axis=0) sd = np.nanstd(X, axis=0) + 1e-12 Z = (X - mu) / sd Z[np.isnan(Z)] = 0.0 return Z, mu, sd def _soft(x: np.ndarray, t: float) -> np.ndarray: return np.sign(x) * np.maximum(np.abs(x) - t, 0.0) def _ista( D: np.ndarray, x: np.ndarray, lam: float, n_iter: int ) -> np.ndarray: # min 0.5||x - D a||^2 + lam||a|| a = np.zeros(D.shape[1], dtype=float) smax = np.linalg.svd(D, compute_uv=False)[0] L = (smax**2) + 1e-12 t = 1.0 / L for _ in range(n_iter): r = x - D @ a a = _soft(a + t * (D.T @ r), lam * t) return a def _mod_update(X: np.ndarray, A: np.ndarray) -> np.ndarray: # D = X A^T (A A^T)^-1 ; normalize atoms At = A.T G = A @ At eps = 1e-8 * np.eye(G.shape[0]) D = (X @ At) @ np.linalg.pinv(G + eps) # normalize columns n = np.linalg.norm(D, axis=0) + 1e-12 D = D / n return D def _feature_matrix(df: pd.DataFrame) -> tuple[np.ndarray, list[str]]: cols = ["beta_abs", "ellipt_abs", "logrho_det", "tip_amp"] # copy=True: recent pandas can hand back a read-only view for a # single-dtype frame, which the in-place NaN fill below cannot write to. X = df[cols].to_numpy(dtype=float, copy=True) X[np.isnan(X)] = 0.0 return X, cols
[docs] def learn_dim_dictionary( sites: Any, *, n_atoms: int = 6, lam: float = 0.05, n_iter: int = 40, code_iter: int = 50, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ) -> dict[str, Any]: df = phase_features_table( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) if df.empty: return dict( D=None, A=None, mu=None, sd=None, feat=[], meta=dict(samples=0), ) X, feats = _feature_matrix(df) Z, mu, sd = _standardize(X) n, f = Z.shape k = int(max(2, min(n_atoms, max(2, f * 2)))) rng = np.random.default_rng(1234) D = rng.normal(size=(f, k)).astype(float) # normalize initial atoms D = D / (np.linalg.norm(D, axis=0) + 1e-12) A = np.zeros((k, n), dtype=float) for _it in range(n_iter): # code step for i in range(n): A[:, i] = _ista(D, Z[i], lam, code_iter) # dict step D = _mod_update(Z.T, A) meta = dict( samples=n, stations=df["station"].tolist(), period=df["period"].to_numpy(), feats=feats, ) return dict(D=D, A=A, mu=mu, sd=sd, feat=feats, meta=meta)
def _auto_label_atoms(D: np.ndarray, feats: list[str]) -> np.ndarray: # simple rule on atom means (beta vs ellipticity) jB = feats.index("beta_abs") jE = feats.index("ellipt_abs") b = np.abs(D[jB, :]) e = np.abs(D[jE, :]) lab = np.full(D.shape[1], 2, dtype=int) lab[(b <= 0.35) & (e <= 0.15)] = 0 lab[(b <= 0.35) & (e > 0.15)] = 1 return lab
[docs] def encode_dimensionality( sites: Any, model: dict[str, Any], *, lam: float = 0.05, code_iter: int = 50, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, api: bool | None = None, ) -> Any: D = model.get("D", None) mu = model.get("mu", None) sd = model.get("sd", None) feats = model.get("feat", []) if D is None or mu is None or sd is None: df = pd.DataFrame() return maybe_wrap_frame( df, api=api, name="dimensionality_encoding", kind="emtools.dimensionality.encoding", source=sites, ) df = phase_features_table( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, api=False, ) if df.empty: return maybe_wrap_frame( df, api=api, name="dimensionality_encoding", kind="emtools.dimensionality.encoding", source=sites, ) X, _ = _feature_matrix(df) Z = (X - mu) / (sd + 1e-12) Z[np.isnan(Z)] = 0.0 k = D.shape[1] codes = np.zeros((len(df), k), dtype=float) for i in range(len(df)): codes[i] = _ista(D, Z[i], lam, code_iter) atoms_lab = _auto_label_atoms(D, feats) pred = atoms_lab[np.argmax(np.abs(codes), axis=1)] out = df.copy() for j in range(k): out[f"a{j}"] = codes[:, j] out["dim_pred"] = pred return maybe_wrap_frame( out, api=api, name="dimensionality_encoding", kind="emtools.dimensionality.encoding", source=sites, description="Dictionary-coded dimensionality labels and atom weights.", )
[docs] def mask_by_dictionary( sites: Any, model: dict[str, Any], *, keep: tuple[int, ...] = (0, 1), lam: float = 0.05, code_iter: int = 50, inplace: bool = False, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ): S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) tbl = encode_dimensionality( S, model, lam=lam, code_iter=code_iter, recursive=False, on_dup=on_dup, strict=False, verbose=verbose, ) if tbl.empty: return S def _one(Si): ed = next(_iter_items(Si)) st = getattr(ed, "station", None) or getattr(ed, "name", None) Z, z, fr = _get_z_block(ed) T, t, ft = _get_t_block(ed) if Z is None: return Si sdf = tbl[tbl["station"] == st] if sdf.empty: return Si per = 1.0 / fr p_ref = sdf["period"].to_numpy() idx = np.searchsorted(p_ref, per) idx = np.clip(idx, 0, len(p_ref) - 1) dsel = sdf["dim_pred"].to_numpy()[idx] mkeep = np.isin(dsel, keep) z2 = z.copy() z2[~mkeep] = np.nan Z.z = z2 if T is not None and t is not None: t2 = t.copy() t2[~mkeep] = np.nan T.tipper = t2 return Si return _apply_each(S, _one, inplace=inplace, verbose=verbose)
# ---- novelty dimensionality plots (max 3) ------------------------------- # def _dim_table_with_conf( sites: Any, *, skew_th: float, ellipt_th: float, recursive: bool, on_dup: str, strict: bool, verbose: int, ) -> pd.DataFrame: df = classify_dimensionality( sites, skew_th=skew_th, ellipt_th=ellipt_th, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) if df.empty: return df b = df["beta_abs"].to_numpy(dtype=float) e = df["ellipt_abs"].to_numpy(dtype=float) d = df["dim"].to_numpy(dtype=int) c = np.zeros_like(b, dtype=float) # raw margins (bigger → more confident) m0 = np.minimum(skew_th - b, ellipt_th - e) m1 = np.minimum(skew_th - b, e - ellipt_th) m2 = b - skew_th c[d == 0] = np.maximum(m0[d == 0], 0.0) c[d == 1] = np.maximum(m1[d == 1], 0.0) c[d == 2] = np.maximum(m2[d == 2], 0.0) # robust 0..1 normalization v = c[np.isfinite(c)] v0 = np.nanpercentile(v, 5) if v.size else 0.0 v1 = np.nanpercentile(v, 95) if v.size else 1.0 cn = (c - v0) / (v1 - v0 + 1e-12) cn = np.clip(cn, 0.0, 1.0) out = df.copy() out["conf"] = cn return out
[docs] def plot_atom_psection( sites: Any, model: dict[str, Any], *, energy: str = "l2", # l2|l1|max figsize: tuple[float, float] = (9.0, 4.8), recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ax: plt.Axes | None = None, ) -> plt.Axes: S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) df = encode_dimensionality( S, model, recursive=False, on_dup=on_dup, strict=False, verbose=verbose, ) if df.empty: if ax is None: _, ax = plt.subplots(figsize=figsize) ax.text(0.5, 0.5, "no codes", ha="center", va="center") return ax # gather code columns acols = [c for c in df.columns if c.startswith("a")] if not acols: if ax is None: _, ax = plt.subplots(figsize=figsize) ax.text(0.5, 0.5, "no atom cols", ha="center", va="center") return ax A = df[acols].to_numpy(dtype=float) P = df["period"].to_numpy(dtype=float) st = df["station"].astype(str).to_numpy() # dominant atom id and energy scalar Am = np.abs(A) aid = np.argmax(Am, axis=1) if energy == "l1": eng = np.sum(Am, axis=1) elif energy == "max": eng = np.max(Am, axis=1) else: eng = np.sqrt(np.sum(Am**2, axis=1)) v = eng[np.isfinite(eng)] v0 = np.nanpercentile(v, 5) if v.size else 0.0 v1 = np.nanpercentile(v, 95) if v.size else 1.0 alp = np.clip((eng - v0) / (v1 - v0 + 1e-12), 0.0, 1.0) # station index and shared log-period grid sts = list(pd.unique(st)) sidx = {s: i for i, s in enumerate(sts)} x = np.array([sidx[s] for s in st], dtype=int) lp = np.log10(np.maximum(P, 1e-9)) ygrid = np.unique(lp) ny, nx = ygrid.size, len(sts) # color palette per atom k = A.shape[1] base = plt.get_cmap("tab20").colors rep = int(np.ceil(k / len(base))) cols = (list(base) * rep)[:k] # build RGBA image img = np.zeros((ny, nx, 4), dtype=float) for xi, yi, ai, al in zip(x, lp, aid, alp): yi = int(np.clip(np.searchsorted(ygrid, yi), 0, ny - 1)) r, g, b = cols[int(ai)] # keep strongest alpha per cell if al >= img[yi, xi, 3]: img[yi, xi, :] = (r, g, b, al) if ax is None: _, ax = plt.subplots(figsize=figsize) ax.imshow( img, aspect="auto", origin="lower", interpolation="nearest", ) ax.set_ylabel(LOG10_PERIOD_LABEL) PYCSAMT_STATION_RENDERING.apply( ax, np.arange(nx, dtype=float), sts, preset="pseudosection", xlim=(-0.5, nx - 0.5), ) yt = np.linspace(0, ny - 1, num=min(8, ny)) yv = np.linspace(ygrid.min(), ygrid.max(), num=yt.size) ax.set_yticks(yt) ax.set_yticklabels([f"{v:.2g}" for v in yv]) if not ax.yaxis_inverted(): ax.invert_yaxis() # legend: colored squares for a few atoms from matplotlib.lines import Line2D max_lab = min(k, 10) h = [ Line2D([0], [0], marker="s", ls="", color=cols[j], label=f"a{j}") for j in range(max_lab) ] ax.legend( handles=h, ncol=min(max_lab, 10), fontsize=7, loc="upper right" ) return ax
[docs] def plot_dim_confidence_grid( sites: Any, *, skew_th: float = 3.0, ellipt_th: float = 0.2, figsize: tuple[float, float] = (8.8, 4.2), recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ax: plt.Axes | None = None, ) -> plt.Axes: S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) df = _dim_table_with_conf( S, skew_th=skew_th, ellipt_th=ellipt_th, recursive=False, on_dup=on_dup, strict=False, verbose=verbose, ) if df.empty: if ax is None: _, ax = plt.subplots(figsize=figsize) ax.text(0.5, 0.5, "no data", ha="center", va="center") return ax df = df.copy() df["logp"] = np.log10(df["period"].to_numpy()) piv_d = df.pivot_table( index="logp", columns="station", values="dim", aggfunc="median", ).sort_index() piv_c = df.pivot_table( index="logp", columns="station", values="conf", aggfunc="median", ).reindex(index=piv_d.index, columns=piv_d.columns) # piv_d / piv_c have shape (n_logp, n_stations); no transpose so that # imshow maps x → stations, y → log-periods. D = piv_d.to_numpy(dtype=float) C = piv_c.to_numpy(dtype=float) # map classes to colors; alpha from confidence pal = { 0: (0.20, 0.60, 0.20), 1: (0.20, 0.30, 0.85), 2: (0.85, 0.25, 0.20), } rgb = np.zeros((D.shape[0], D.shape[1], 3)) a = np.zeros((D.shape[0], D.shape[1])) for k, col in pal.items(): m = np.round(D) == k for j in range(3): rgb[:, :, j][m] = col[j] a[:, :] = np.nan_to_num(C, nan=0.0) rgba = np.dstack([rgb, a]) if ax is None: _, ax = plt.subplots(figsize=figsize) ax.imshow( rgba, aspect="auto", origin="lower", interpolation="nearest", ) ax.set_ylabel(LOG10_PERIOD_LABEL) PYCSAMT_STATION_RENDERING.apply( ax, np.arange(D.shape[1], dtype=float), list(piv_d.columns), preset="pseudosection", xlim=(-0.5, D.shape[1] - 0.5), ) yt = np.linspace( 0, D.shape[0] - 1, num=min(8, D.shape[0]) ) # shape[0] = n_logp yv = np.linspace( piv_d.index.min(), piv_d.index.max(), num=min(8, len(piv_d.index)) ) ax.set_yticks(yt) ax.set_yticklabels([f"{v:.2g}" for v in yv]) if not ax.yaxis_inverted(): ax.invert_yaxis() # legend handles = [ Line2D([0], [0], marker="s", ls="", color=pal[0], label="1D"), Line2D([0], [0], marker="s", ls="", color=pal[1], label="2D"), Line2D([0], [0], marker="s", ls="", color=pal[2], label="3D"), ] ax.legend(handles=handles, ncol=3, fontsize=8, loc="upper right") return ax
[docs] def plot_dim_occupancy_area( sites: Any, *, skew_th: float = 3.0, ellipt_th: float = 0.2, n_bands: int = 24, figsize: tuple[float, float] = (8.8, 3.6), recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ax: plt.Axes | None = None, ) -> plt.Axes: df = _dim_table_with_conf( sites, skew_th=skew_th, ellipt_th=ellipt_th, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) if df.empty: if ax is None: _, ax = plt.subplots(figsize=figsize) ax.text(0.5, 0.5, "no data", ha="center", va="center") return ax p = df["period"].to_numpy(dtype=float) lo = max(float(np.nanmin(p)), 1e-6) hi = float(np.nanmax(p)) edges = np.logspace(np.log10(lo), np.log10(hi), n_bands + 1) centers = np.sqrt(edges[:-1] * edges[1:]) frac = np.zeros((3, n_bands), dtype=float) for i in range(n_bands): m = (p >= edges[i]) & (p < edges[i + 1]) if not np.any(m): continue c = np.bincount( df.loc[m, "dim"].to_numpy(dtype=int), minlength=3, ).astype(float) frac[:, i] = c / (np.sum(c) + 1e-12) if ax is None: _, ax = plt.subplots(figsize=figsize) x = centers ax.set_xscale("log") y0 = frac[0] y1 = y0 + frac[1] y2 = y1 + frac[2] ax.fill_between(x, 0.0, y0, color=(0.20, 0.60, 0.20)) ax.fill_between(x, y0, y1, color=(0.20, 0.30, 0.85)) ax.fill_between(x, y1, y2, color=(0.85, 0.25, 0.20)) ax.set_ylim(0.0, 1.0) ax.set_xlabel("Period (s)") ax.set_ylabel("fraction") ax.grid(True, alpha=0.2, which="both") return ax
[docs] def plot_dim_map( sites: Any, *, period: float = 10.0, skew_th: float = 3.0, ellipt_th: float = 0.2, figsize: tuple[float, float] = (8.0, 6.0), recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ax: plt.Axes | None = None, ) -> plt.Axes: S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) df = _dim_table_with_conf( S, skew_th=skew_th, ellipt_th=ellipt_th, recursive=False, on_dup=on_dup, strict=False, verbose=verbose, ) if ax is None: _, ax = plt.subplots(figsize=figsize) if df.empty: ax.text(0.5, 0.5, "no data", ha="center", va="center") return ax # coords coords = {} for i, ed in enumerate(_iter_items(S)): st = _name(ed, i) try: lat, lon, _elev = ed.coords except Exception: continue if lat is None or lon is None: continue lat, lon = float(lat), float(lon) if not (np.isfinite(lat) and np.isfinite(lon)): continue coords[st] = (lat, lon) if not coords: ax.text(0.5, 0.5, "no coords", ha="center", va="center") return ax # nearest row to target period per station rows = [] for st, sdf in df.groupby("station"): if st not in coords: continue p = sdf["period"].to_numpy(dtype=float) i = int(np.nanargmin(np.abs(p - period))) rows.append(sdf.iloc[i]) if not rows: ax.text(0.5, 0.5, "no match @period", ha="center", va="center") return ax pal = { 0: ((0.20, 0.60, 0.20), "o"), 1: ((0.20, 0.30, 0.85), "s"), 2: ((0.85, 0.25, 0.20), "^"), } for row in rows: st = row["station"] lat, lon = coords[st] d = int(row["dim"]) c, m = pal.get(d, ((0.5, 0.5, 0.5), "o")) s = 30.0 + 120.0 * float(row["conf"]) ax.scatter([lon], [lat], s=s, c=[c], marker=m) ax.set_xlabel("Lon") ax.set_ylabel("Lat") # legend handles = [ Line2D([0], [0], marker="o", ls="", color=pal[0][0], label="1D"), Line2D([0], [0], marker="s", ls="", color=pal[1][0], label="2D"), Line2D([0], [0], marker="^", ls="", color=pal[2][0], label="3D"), ] ax.legend(handles=handles, ncol=3, fontsize=8, loc="best") return ax