"""
pycsamt.api.style
=================
Central visual-style system for the entire pyCSAMT package.
All plot functions that respect the style system read from the
module-level singleton :data:`PYCSAMT_STYLE`. Users configure it once
and every subsequent figure inherits those choices.
Quick start
-----------
**Apply a named preset**::
from pycsamt.api.style import use_style
use_style("publication") # grayscale publication look
use_style("dark") # dark-background figures
**Override specific attributes**::
from pycsamt.api.style import configure_style
configure_style(
rose__compass_labels = "degrees", # dotted path: section__attr
multiline__mode = "gradient",
multiline__base_color = "red",
mt__xy__color = "#003f88",
)
**Temporary changes with a context manager**::
from pycsamt.api.style import PYCSAMT_STYLE
with PYCSAMT_STYLE.context("publication"):
fig = plot_phase_tensor_rose(sites)
# reverts to previous state automatically
**Build a fully custom style and share it**::
from pycsamt.api.style import PyCSAMTStyle, MultilineStyle
s = PyCSAMTStyle()
s.multiline = MultilineStyle(mode="gradient", base_color="teal")
s.mt.xy.color = "#004e89"
# pass to functions that accept style=
plot_phase_tensor_rose(sites, style=s.rose)
Style sections
--------------
* :class:`RoseStyle` — rose diagram visuals (bars, rings, compass, mean line)
* :class:`MultilineStyle` — gradient or cycle coloring for multi-line plots
* :class:`MTComponentStyle` — consistent per-component colors (XY/YX/XX/YY/TE/TM)
* :class:`CorrectionStyle` — before/after pair for any 1-D correction workflow
* :class:`RawDataStyle` — black diagnostic traces for unprocessed observations
"""
from __future__ import annotations
import copy
from collections.abc import Generator
from contextlib import contextmanager
from dataclasses import dataclass, field
from dataclasses import fields as dc_fields
from typing import Any
import numpy as np
# ── RoseStyle lives here in api — no cross-package import needed ──────────────
from ._rose_style import (
RoseStyle,
resolve_rose_style,
)
# ═══════════════════════════════════════════════════════════════════════════════
# MultilineStyle
# ═══════════════════════════════════════════════════════════════════════════════
# Maps common color names → sequential matplotlib cmaps (dark-first variant)
# Map base_color → sequential cmap where HIGH fraction = DARK shade.
# We use non-reversed cmaps so linspace(dark, light) = dark-first.
_COLOR_TO_CMAP: dict[str, str] = {
"blue": "Blues",
"b": "Blues",
"red": "Reds",
"r": "Reds",
"green": "Greens",
"g": "Greens",
"orange": "Oranges",
"purple": "Purples",
"grey": "Greys",
"gray": "Greys",
"teal": "GnBu",
"brown": "YlOrBr",
"navy": "Blues",
"crimson": "Reds",
}
[docs]
@dataclass
class MultilineStyle:
"""Gradient-first coloring for multi-line (multi-station / multi-profile) plots.
When *mode* is ``"gradient"``, the *n* lines returned by :meth:`colors`
are shades of the same hue arranged from dark (line 0) to light (line
n-1) — or reversed. This preserves visual coherence while still making
individual lines distinguishable.
Parameters
----------
mode : {"gradient", "cycle", "matplotlib"}
``"gradient"`` — sequential shades from *base_color* / *cmap*.
``"cycle"`` — rotate through a fixed palette (default: ``tab10``).
``"matplotlib"`` — fall back to matplotlib's default ``"C0"``,
``"C1"``, … colour cycle.
base_color : str
Base hue for ``mode="gradient"``. If *cmap* is ``None``, a
sequential colormap is auto-selected from this colour name (e.g.
``"blue"`` → ``"Blues_r"``). Any matplotlib colour spec works;
unrecognised names fall back to *cmap* if set, else ``"Blues_r"``.
cmap : str or None
Explicit colormap override. Takes precedence over *base_color* for
cmap selection.
dark : float
Fraction (0–1) of the cmap for the darkest (first) line.
Default ``0.85`` avoids pure black.
light : float
Fraction for the lightest (last) line. Default ``0.25`` avoids
near-white.
reverse : bool
If ``True``, light lines come first and dark lines last.
lw : float
Default line width for multi-line plots.
alpha : float
Default line alpha.
cycle_palette : list of str
Colour list used when *mode* is ``"cycle"``.
"""
mode: str = "gradient"
base_color: str = "blue"
cmap: str | None = None
dark: float = 0.85
light: float = 0.25
reverse: bool = False
lw: float = 1.5
alpha: float = 0.85
cycle_palette: list[str] = field(
default_factory=lambda: [
"#1f77b4",
"#d62728",
"#2ca02c",
"#ff7f0e",
"#9467bd",
"#8c564b",
"#e377c2",
"#7f7f7f",
"#bcbd22",
"#17becf",
]
)
# ── public API ────────────────────────────────────────────────────────────
[docs]
def colors(self, n: int) -> list[Any]:
"""Return *n* colours for a multi-line plot.
Parameters
----------
n : int
Number of lines / profiles.
Returns
-------
list
RGBA tuples (for ``"gradient"``) or colour strings
(for ``"cycle"`` / ``"matplotlib"``).
Examples
--------
>>> ms = MultilineStyle(mode="gradient", base_color="red")
>>> colors = ms.colors(5) # 5 shades of red, dark → light
"""
if n <= 0:
return []
if self.mode == "matplotlib":
return [f"C{i}" for i in range(n)]
if self.mode == "cycle":
pal = self.cycle_palette
return [pal[i % len(pal)] for i in range(n)]
# gradient
import matplotlib.pyplot as plt
cm_name = self.cmap or _COLOR_TO_CMAP.get(
self.base_color.lower(), "Blues_r"
)
try:
cm = plt.get_cmap(cm_name)
except ValueError:
cm = plt.get_cmap("Blues_r")
fracs = np.linspace(self.dark, self.light, max(n, 2))[:n]
if self.reverse:
fracs = fracs[::-1]
return [cm(float(f)) for f in fracs]
[docs]
def line_kwargs(
self, idx: int, n: int, **overrides: Any
) -> dict[str, Any]:
"""Return ``ax.plot`` keyword arguments for line *idx* of *n*.
Parameters
----------
idx : int
Zero-based line index.
n : int
Total number of lines.
**overrides
Keyword arguments that win over the style defaults.
Examples
--------
>>> kw = ms.line_kwargs(0, 5)
>>> ax.plot(x, y, **kw)
"""
c = self.colors(n)[idx]
d: dict[str, Any] = dict(color=c, lw=self.lw, alpha=self.alpha)
d.update(overrides)
return d
[docs]
def copy(self, **kw: Any) -> MultilineStyle:
"""Return a modified copy."""
new = copy.copy(self)
for k, v in kw.items():
setattr(new, k, v)
return new
# ═══════════════════════════════════════════════════════════════════════════════
# MTComponentStyle
# ═══════════════════════════════════════════════════════════════════════════════
@dataclass
class _MTComp:
"""Visual properties for a single MT impedance component or mode.
Attributes
----------
color : str
Line / marker colour.
ls : str
Line style (``"-"``, ``"--"``, ``":"``).
lw : float
Line width.
marker : str
Marker style.
ms : float
Marker size.
mfc : str
Marker face colour. ``"white"`` gives open (hollow) markers.
mew : float
Marker edge width.
alpha : float
Overall opacity.
label : str
Default legend label. Empty string → no label.
elinewidth : float
Error-bar cap line width.
capsize : float
Error-bar cap size in points.
"""
color: str = "#1f77b4"
ls: str = "-"
lw: float = 1.5
marker: str = "o"
ms: float = 4.0
mfc: str = "white"
mew: float = 1.2
alpha: float = 0.90
label: str = ""
elinewidth: float = 0.8
capsize: float = 2.5
predicted_color: str = "" # empty → fall back to `color`
predicted_ls: str = ":" # line style for model-predicted overlay
def plot_kwargs(self, **overrides: Any) -> dict[str, Any]:
"""Keyword arguments for :func:`matplotlib.axes.Axes.plot`.
Examples
--------
>>> ax.plot(period, rho_xy, **style.mt.xy.plot_kwargs())
"""
d: dict[str, Any] = dict(
color=self.color,
ls=self.ls,
lw=self.lw,
marker=self.marker,
ms=self.ms,
mfc=self.mfc,
mew=self.mew,
alpha=self.alpha,
)
if self.label:
d["label"] = self.label
d.update(overrides)
return d
def errorbar_kwargs(self, **overrides: Any) -> dict[str, Any]:
"""Keyword arguments for :func:`matplotlib.axes.Axes.errorbar`.
The error bars are drawn in a 50 %-transparent version of the
component colour.
Examples
--------
>>> ax.errorbar(period, rho_xy, rho_err, **style.mt.xy.errorbar_kwargs())
"""
import matplotlib.colors as mc
rgba = list(mc.to_rgba(self.color))
rgba[3] = 0.50 # semi-transparent error bars
d = self.plot_kwargs()
d.update(
ecolor=tuple(rgba),
elinewidth=self.elinewidth,
capsize=self.capsize,
)
d.update(overrides)
return d
def copy(self, **kw: Any) -> _MTComp:
"""Return a modified copy."""
new = copy.copy(self)
for k, v in kw.items():
setattr(new, k, v)
return new
def __repr__(self) -> str: # noqa: D105
return (
f"_MTComp(color={self.color!r}, ls={self.ls!r}, "
f"lw={self.lw}, marker={self.marker!r}, label={self.label!r})"
)
[docs]
@dataclass
class MTComponentStyle:
"""Consistent colours and markers for MT impedance components and modes.
Attributes
----------
xy : _MTComp
Off-diagonal XY component (TE-like). Default: solid blue circles.
yx : _MTComp
Off-diagonal YX component (TM-like). Default: solid red squares.
xx : _MTComp
Diagonal XX component. Default: dashed green upward triangles.
yy : _MTComp
Diagonal YY component. Default: dashed purple downward triangles.
te : _MTComp
TE mode (when TE/TM decomposition is used). Default: blue circles.
tm : _MTComp
TM mode. Default: red squares.
det : _MTComp
Determinant / rotationally invariant quantity. Default: grey circles.
Examples
--------
Spread kwargs into a matplotlib call::
ax.errorbar(per, rho, drho, **style.mt.xy.errorbar_kwargs())
Override for one call only::
kw = style.mt.yx.plot_kwargs(lw=2.5, alpha=1.0)
Change the XY colour package-wide::
PYCSAMT_STYLE.mt.xy.color = "#003f88"
"""
xy: _MTComp = field(
default_factory=lambda: _MTComp(
color="#1f77b4",
ls="-",
lw=1.5,
marker="o",
ms=4.0,
mfc="white",
label="XY",
)
)
yx: _MTComp = field(
default_factory=lambda: _MTComp(
color="#d62728",
ls="-",
lw=1.5,
marker="s",
ms=4.0,
mfc="white",
label="YX",
)
)
xx: _MTComp = field(
default_factory=lambda: _MTComp(
color="#2ca02c",
ls="--",
lw=1.2,
marker="^",
ms=3.5,
mfc="white",
label="XX",
)
)
yy: _MTComp = field(
default_factory=lambda: _MTComp(
color="#9467bd",
ls="--",
lw=1.2,
marker="v",
ms=3.5,
mfc="white",
label="YY",
)
)
te: _MTComp = field(
default_factory=lambda: _MTComp(
color="#1f77b4",
ls="-",
lw=1.5,
marker="o",
ms=4.0,
mfc="white",
label="TE",
)
)
tm: _MTComp = field(
default_factory=lambda: _MTComp(
color="#d62728",
ls="-",
lw=1.5,
marker="s",
ms=4.0,
mfc="white",
label="TM",
)
)
det: _MTComp = field(
default_factory=lambda: _MTComp(
color="#7f7f7f",
ls="-",
lw=1.3,
marker="D",
ms=3.5,
mfc="white",
label="det",
)
)
# ── convenience ───────────────────────────────────────────────────────────
[docs]
def component(self, key: str) -> _MTComp:
"""Return the :class:`_MTComp` for *key* (case-insensitive).
Parameters
----------
key : str
One of ``"xy"``, ``"yx"``, ``"xx"``, ``"yy"``,
``"te"``, ``"tm"``, ``"det"``.
Raises
------
KeyError
If *key* is not a recognised component name.
"""
try:
return getattr(self, key.lower())
except AttributeError:
valid = [f.name for f in dc_fields(self)]
raise KeyError(
f"{key!r} is not a recognised MT component. "
f"Valid keys: {valid}"
) from None
[docs]
def copy(self) -> MTComponentStyle:
"""Return a deep copy."""
return copy.deepcopy(self)
# ═══════════════════════════════════════════════════════════════════════════════
# CorrectionStyle
# ═══════════════════════════════════════════════════════════════════════════════
[docs]
@dataclass
class CorrectionStyle:
"""Consistent before/after visual pair for any 1-D correction workflow.
Every correction plot in pyCSAMT (static-shift, near-field, noise
removal, …) uses the same two-curve convention so that users can read
results at a glance across all figures:
* **before** — blue dashed line with hollow markers
* **after** — red solid line with hollow markers
The two colours deliberately mirror :attr:`MTComponentStyle.xy` (blue)
and :attr:`MTComponentStyle.yx` (red) so the whole package stays visually
coherent.
Attributes
----------
before : _MTComp
Style for the uncorrected / original curve.
after : _MTComp
Style for the corrected curve.
Examples
--------
Use directly in a plot function::
cs = PYCSAMT_STYLE.correction
ax.plot(period, rho_before, **cs.before.plot_kwargs())
ax.plot(period, rho_after, **cs.after.plot_kwargs())
Override just the line-width for one call::
kw_before = cs.before.plot_kwargs(lw=2.0)
Change the before colour package-wide::
PYCSAMT_STYLE.correction.before.color = "#005a9e"
Use dotted-path configure::
configure_style(
correction__before__color = "#005a9e",
correction__after__color = "#9b0000",
)
"""
before: _MTComp = field(
default_factory=lambda: _MTComp(
color="#1f77b4", # matplotlib blue — same as mt.xy
ls="--", # dashed → visually "unfinished / before"
lw=1.5,
marker="o",
ms=4.0,
mfc="white", # hollow markers for both curves
mew=1.2,
alpha=0.85,
label="before",
)
)
after: _MTComp = field(
default_factory=lambda: _MTComp(
color="#d62728", # matplotlib red — same as mt.yx
ls="-", # solid → visually "settled / after"
lw=1.5,
marker="o",
ms=4.0,
mfc="white",
mew=1.2,
alpha=0.90,
label="after",
)
)
[docs]
def copy(self) -> CorrectionStyle:
"""Return a deep copy."""
return copy.deepcopy(self)
def __repr__(self) -> str: # noqa: D105
return (
f"CorrectionStyle(\n"
f" before={self.before!r},\n"
f" after={self.after!r}\n"
f")"
)
# ═══════════════════════════════════════════════════════════════════════════════
# RawDataStyle
# ═══════════════════════════════════════════════════════════════════════════════
[docs]
@dataclass
class RawDataStyle:
"""Visual style for raw, unprocessed EM observations.
Raw data should be visually distinct from processed or modelled data.
The package convention is therefore a restrained black trace with small
markers and thin connecting lines. Plot functions may use this style
automatically when called with ``raw=True`` unless users explicitly pass
colors or force a processed-data style.
"""
color: str = "black"
ls: str = ":"
lw: float = 0.8
marker: str = "."
ms: float = 3.0
mfc: str = "black"
mew: float = 0.5
alpha: float = 0.85
capsize: float = 1.5
elinewidth: float = 0.6
label: str = "raw"
[docs]
def plot_kwargs(self, **overrides: Any) -> dict[str, Any]:
"""Return keyword arguments for raw-data line plots."""
kwargs: dict[str, Any] = dict(
color=self.color,
ls=self.ls,
lw=self.lw,
marker=self.marker,
ms=self.ms,
mfc=self.mfc,
mew=self.mew,
alpha=self.alpha,
)
if self.label:
kwargs["label"] = self.label
kwargs.update(overrides)
return kwargs
[docs]
def errorbar_kwargs(self, **overrides: Any) -> dict[str, Any]:
"""Return keyword arguments for raw-data error bars."""
kwargs = self.plot_kwargs()
kwargs.update(
ecolor=self.color,
capsize=self.capsize,
elinewidth=self.elinewidth,
)
kwargs.update(overrides)
return kwargs
[docs]
def copy(self, **kw: Any) -> RawDataStyle:
"""Return a modified copy."""
new = copy.copy(self)
for k, v in kw.items():
setattr(new, k, v)
return new
# ═══════════════════════════════════════════════════════════════════════════════
# PhaseTensorEllipseStyle
# ═══════════════════════════════════════════════════════════════════════════════
# Recognised c_by values and their natural cmaps / symmetry flag
_PT_CMAP_DEFAULTS: dict[str, tuple[str, bool]] = {
"skew": ("RdBu_r", True), # diverging — symmetric around 0
"beta": ("RdBu_r", True),
"|skew|": ("Reds", False), # absolute → sequential
"|beta|": ("Reds", False),
"theta": ("hsv", False), # circular — 0–180°
"|theta|": ("plasma", False),
"ellipt": ("viridis", False), # 0–1 sequential
"phi_mean": ("viridis", False),
"phi_max": ("plasma", False),
"phi_min": ("plasma", False),
"s1": ("plasma", False),
"s2": ("plasma", False),
"alpha": ("coolwarm", True),
}
[docs]
@dataclass
class PhaseTensorEllipseStyle:
"""Visual style for phase-tensor ellipse plots (psection, map, summary).
Each plotted ellipse simultaneously encodes five physical quantities:
1. **Width** ∝ φ_max (s1 eigenvalue — mean phase level)
2. **Height** ∝ φ_min (s2 eigenvalue — minimum phase, ≤ φ_max)
3. **Aspect ratio** = φ_min/φ_max — ellipticity (1 = isotropic / 1-D)
4. **Rotation angle** = θ (CCW from E) — geoelectric-strike indicator
5. **Fill colour** — any scalar mapped through *cmap* (default: skewness β)
The style groups these into five sections:
* **Sizing** (*scale*, *normalise_by*, *s1_ref*) — how big each ellipse is
* **Colour/fill** (*c_by*, *cmap*, *clim*, …) — what drives the fill
* **Edge/border** (*edgecolor*, *linewidth*, *alpha*) — ellipse outline
* **3-D cell marker** (*skew_threshold*, *mark_3d*, *lw_3d_factor*) — highlight 3-D cells
* **Reference circle** (*show_ref*, *ref_edgecolor*, *ref_lw*, *ref_fontsize*)
Parameters
----------
normalise_by : ``"cell"`` | ``"unity"`` | ``"abs"``
Size normalisation strategy:
``"cell"``
The 90th-percentile s1 fills *scale* of its grid cell.
Relative sizes are preserved across stations/periods.
``"unity"``
s1_ref = tan 45° = 1.0 (1-D half-space reference).
A perfectly 1-D tensor plots as a circle of radius *scale*.
``"abs"``
Raw data units: width = scale × s1 (legacy behaviour).
scale : float, default ``0.85``
Fraction of each grid cell occupied by the normalised ellipse.
Values above 1 produce overlap between adjacent cells.
s1_ref : float or None
Manual reference s1. Overrides the automatic reference
computed from the data under ``"cell"`` and ``"unity"`` modes.
min_aspect : float, default ``0.18``
Minimum height/width ratio enforced on every drawn ellipse.
Real (noisy) EDI data frequently has φ_min ≈ 0 at some
stations/periods, which collapses the ellipse to a near-invisible
sliver. Flooring the height at ``min_aspect × width`` keeps every
cell visible as a proper oval while leaving φ_max, θ and fill
colour untouched. Set to ``0`` to recover the raw (possibly
degenerate) ellipticity.
c_by : str, default ``"skew"``
Quantity mapped to fill colour. Supported values:
``"skew"``, ``"beta"``, ``"|skew|"``, ``"|beta|"``,
``"theta"``, ``"|theta|"``, ``"ellipt"``, ``"phi_mean"``,
``"phi_max"``, ``"phi_min"``, ``"s1"``, ``"s2"``, ``"alpha"``.
cmap : str, default ``"RdBu_r"``
Matplotlib colormap. When ``None``, a sensible default is
chosen automatically from *c_by* (e.g. ``"RdBu_r"`` for skewness,
``"viridis"`` for ellipticity, ``"hsv"`` for strike angle).
clim : (vmin, vmax) or None
Explicit colour axis limits. ``None`` derives limits from
*clim_pct* / *skew_threshold*.
clim_pct : (lo, hi), default ``(5.0, 95.0)``
Percentile limits used for automatic colour scaling.
symmetric_clim : bool, default ``True``
Force vmin = −vmax for diverging quantities (skewness, etc.).
Automatically disabled when ``None`` is the appropriate default.
alpha : float, default ``0.92``
Ellipse fill opacity.
edgecolor : str, default ``"k"``
Ellipse border colour. Set to ``"none"`` to suppress borders.
linewidth : float, default ``0.2``
Normal (non-3-D) ellipse border width in points.
skew_threshold : float, default ``3.0``
|β| threshold (degrees) above which a cell is flagged as 3-D.
3-D cells receive a thicker border when *mark_3d* is ``True``.
Set to ``None`` to disable 3-D flagging.
mark_3d : bool, default ``True``
Draw a thicker border on ellipses where |β| > *skew_threshold*.
lw_3d_factor : float, default ``3.0``
Multiplier applied to *linewidth* for 3-D flagged cells.
show_ref : bool, default ``True``
Draw a reference circle (s1 = s1_ref) in the corner of the
pseudo-section as a scale indicator.
ref_edgecolor : str, default ``"k"``
Border colour of the reference circle.
ref_lw : float, default ``0.8``
Border line width of the reference circle.
ref_fontsize : float, default ``6.5``
Font size of the reference-circle label.
Examples
--------
Use the style in a plot call::
from pycsamt.api.style import PYCSAMT_STYLE
es = PYCSAMT_STYLE.pt_ellipse
plot_phase_tensor_psection(sites, **es.to_kwargs())
Switch colour quantity and cmap in one call::
plot_phase_tensor_psection(
sites,
**es.copy(c_by="theta", cmap=None).to_kwargs(),
)
Configure package-wide::
configure_style(
pt_ellipse__c_by = "ellipt",
pt_ellipse__cmap = "viridis",
pt_ellipse__edgecolor = "none",
pt_ellipse__scale = 0.80,
)
"""
# ── sizing ────────────────────────────────────────────────────────────────
normalise_by: str = "cell"
scale: float = 0.85
s1_ref: float | None = None
min_aspect: float = 0.18
# ── fill colour ───────────────────────────────────────────────────────────
c_by: str = "skew"
cmap: str | None = "RdBu_r"
clim: tuple[float, float] | None = None
clim_pct: tuple[float, float] = (5.0, 95.0)
symmetric_clim: bool = True
alpha: float = 0.92
# ── edge / border ─────────────────────────────────────────────────────────
edgecolor: str = "k"
linewidth: float = 0.2
# ── 3-D cell marker ───────────────────────────────────────────────────────
skew_threshold: float | None = 3.0
mark_3d: bool = True
lw_3d_factor: float = 3.0 # applied when plot functions support it
# ── reference circle ──────────────────────────────────────────────────────
show_ref: bool = True
ref_edgecolor: str = "k"
ref_lw: float = 0.8
ref_fontsize: float = 6.5
# ── public API ────────────────────────────────────────────────────────────
[docs]
def resolve_cmap(self) -> str:
"""Return the effective colormap name, auto-selecting from *c_by* when *cmap* is ``None``.
Examples
--------
>>> es = PhaseTensorEllipseStyle(c_by="theta", cmap=None)
>>> es.resolve_cmap()
'hsv'
"""
if self.cmap is not None:
return self.cmap
return _PT_CMAP_DEFAULTS.get(self.c_by, ("RdBu_r", True))[0]
[docs]
def resolve_symmetric_clim(self) -> bool:
"""Return effective *symmetric_clim* flag, honouring *c_by* natural symmetry."""
_, natural_sym = _PT_CMAP_DEFAULTS.get(self.c_by, ("RdBu_r", True))
return self.symmetric_clim and natural_sym
[docs]
def to_kwargs(self) -> dict[str, Any]:
"""Return a dict of kwargs ready to spread into any phase-tensor plot function.
The dict covers every parameter currently accepted by
:func:`~pycsamt.emtools.tensor.plot_phase_tensor_psection` and
:func:`~pycsamt.emtools.tensor.plot_phase_tensor_summary`.
Parameters that are style-only (e.g. *lw_3d_factor*, *ref_edgecolor*,
*ref_lw*, *ref_fontsize*) are **not** included here because the plot
functions do not yet accept them; they are preserved in the style for
forward compatibility and will be added to the returned dict once the
plot functions are updated.
Examples
--------
>>> ax = plot_phase_tensor_psection(sites, **PYCSAMT_STYLE.pt_ellipse.to_kwargs())
"""
return dict(
normalise_by=self.normalise_by,
scale=self.scale,
s1_ref=self.s1_ref,
min_aspect=self.min_aspect,
c_by=self.c_by,
cmap=self.resolve_cmap(),
clim=self.clim,
clim_pct=self.clim_pct,
symmetric_clim=self.resolve_symmetric_clim(),
alpha=self.alpha,
edgecolor=self.edgecolor,
linewidth=self.linewidth,
skew_threshold=self.skew_threshold,
mark_3d=self.mark_3d,
ref_ellipse=self.show_ref,
)
[docs]
def copy(self, **kw: Any) -> PhaseTensorEllipseStyle:
"""Return a modified shallow copy.
Examples
--------
>>> strike_style = PYCSAMT_STYLE.pt_ellipse.copy(c_by="theta", cmap=None)
"""
new = copy.copy(self)
for k, v in kw.items():
setattr(new, k, v)
return new
def __repr__(self) -> str: # noqa: D105
return (
f"PhaseTensorEllipseStyle("
f"normalise_by={self.normalise_by!r}, scale={self.scale}, "
f"c_by={self.c_by!r}, cmap={self.resolve_cmap()!r}, "
f"edgecolor={self.edgecolor!r}, lw={self.linewidth}, "
f"alpha={self.alpha}, mark_3d={self.mark_3d})"
)
# ═══════════════════════════════════════════════════════════════════════════════
# PyCSAMTStyle — main container
# ═══════════════════════════════════════════════════════════════════════════════
[docs]
class PyCSAMTStyle:
"""Global visual-style container for pyCSAMT.
Holds five style sub-objects:
* :attr:`rose` — :class:`~pycsamt.api._rose_style.RoseStyle`
* :attr:`multiline` — :class:`MultilineStyle`
* :attr:`mt` — :class:`MTComponentStyle`
* :attr:`correction` — :class:`CorrectionStyle`
* :attr:`raw` — :class:`RawDataStyle`
* :attr:`pt_ellipse` — :class:`PhaseTensorEllipseStyle`
The module-level singleton :data:`PYCSAMT_STYLE` is the recommended
entry point; import it and mutate it in your script or notebook before
creating figures.
Parameters
----------
preset : str or None
Named preset to initialise from. ``None`` → ``"pycsamt"``.
Examples
--------
Inspect the current style::
from pycsamt.api.style import PYCSAMT_STYLE
print(PYCSAMT_STYLE)
Apply a named preset::
PYCSAMT_STYLE.use("publication")
Configure individual attributes with dotted paths::
PYCSAMT_STYLE.configure(
rose__compass_labels="degrees",
multiline__base_color="red",
mt__xy__color="#003f88",
)
Temporary style change (context manager)::
with PYCSAMT_STYLE.context("dark"):
fig = plot_phase_tensor_rose(sites)
"""
#: Named full-package presets registered at import time.
_PRESETS: dict[str, dict[str, Any]] = {}
def __init__(self, preset: str | None = None) -> None:
self._init_defaults()
if preset is not None:
self.use(preset)
def _init_defaults(self) -> None:
self.rose = RoseStyle()
self.multiline = MultilineStyle()
self.mt = MTComponentStyle()
self.correction = CorrectionStyle()
self.raw = RawDataStyle()
self.pt_ellipse = PhaseTensorEllipseStyle()
# ── named presets ─────────────────────────────────────────────────────────
[docs]
def use(self, preset: str) -> None:
"""Apply a named full-package style preset.
Parameters
----------
preset : str
``"pycsamt"`` — default: gradient bars, NESW compass,
blue/red MT, blue-dashed / red-solid correction pair.
``"publication"`` — grayscale, degree compass, open markers.
``"dark"`` — dark-background optimised colours.
Raises
------
ValueError
If *preset* is not registered.
"""
key = preset.lower().strip()
if key not in self._PRESETS:
avail = ", ".join(f"{k!r}" for k in self._PRESETS)
raise ValueError(
f"Unknown style preset {preset!r}. Available: {avail}"
)
spec = self._PRESETS[key]
# rose
if "rose" in spec:
self.rose = resolve_rose_style(spec["rose"])
# multiline
if "multiline" in spec:
self.multiline = MultilineStyle(**spec["multiline"])
# mt
if "mt" in spec:
self._apply_mt(spec["mt"])
# correction
if "correction" in spec:
self._apply_correction(spec["correction"])
# raw
if "raw" in spec:
self._apply_raw(spec["raw"])
# pt_ellipse
if "pt_ellipse" in spec:
self._apply_pt_ellipse(spec["pt_ellipse"])
def _apply_mt(self, mt_spec: dict[str, Any]) -> None:
for comp_name, attrs in mt_spec.items():
comp = getattr(self.mt, comp_name, None)
if comp is None:
continue
for k, v in attrs.items():
setattr(comp, k, v)
def _apply_correction(self, corr_spec: dict[str, Any]) -> None:
for curve_name, attrs in corr_spec.items(): # "before" / "after"
comp = getattr(self.correction, curve_name, None)
if comp is None:
continue
for k, v in attrs.items():
setattr(comp, k, v)
def _apply_pt_ellipse(self, spec: dict[str, Any]) -> None:
for k, v in spec.items():
setattr(self.pt_ellipse, k, v)
def _apply_raw(self, spec: dict[str, Any]) -> None:
for k, v in spec.items():
setattr(self.raw, k, v)
# ── configure with dotted paths ───────────────────────────────────────────
# ── context manager ───────────────────────────────────────────────────────
[docs]
@contextmanager
def context(
self,
preset: str | None = None,
**kw: Any,
) -> Generator[PyCSAMTStyle, None, None]:
"""Temporarily apply a preset and/or overrides, then restore.
Parameters
----------
preset : str or None
Named preset to activate inside the context.
**kw
Additional dotted-path overrides (same syntax as
:meth:`configure`).
Yields
------
PyCSAMTStyle
The (temporarily modified) style object.
Examples
--------
>>> with PYCSAMT_STYLE.context("publication", mt__xy__lw=2.0):
... fig = plot_phase_tensor_rose(sites)
"""
saved_rose = copy.deepcopy(self.rose)
saved_multiline = copy.deepcopy(self.multiline)
saved_mt = copy.deepcopy(self.mt)
saved_correction = copy.deepcopy(self.correction)
saved_raw = copy.deepcopy(self.raw)
saved_pt_ellipse = copy.deepcopy(self.pt_ellipse)
try:
if preset is not None:
self.use(preset)
if kw:
self.configure(**kw)
yield self
finally:
self.rose = saved_rose
self.multiline = saved_multiline
self.mt = saved_mt
self.correction = saved_correction
self.raw = saved_raw
self.pt_ellipse = saved_pt_ellipse
# ── reset ─────────────────────────────────────────────────────────────────
[docs]
def reset(self) -> None:
"""Reset all style sections to package defaults (``"pycsamt"`` preset)."""
self._init_defaults()
# ── display ───────────────────────────────────────────────────────────────
[docs]
def summary(self) -> str:
"""Return a human-readable summary of the current style."""
lines = ["PyCSAMTStyle"]
lines.append(f" rose.bar_style = {self.rose.bar_style!r}")
lines.append(f" rose.cmap = {self.rose.cmap!r}")
lines.append(
f" rose.compass_labels = {self.rose.compass_labels!r}"
)
lines.append(f" rose.show_mean = {self.rose.show_mean}")
lines.append(
f" rose.show_secondary = {self.rose.show_secondary}"
)
lines.append(f" multiline.mode = {self.multiline.mode!r}")
lines.append(
f" multiline.base_color = {self.multiline.base_color!r}"
)
lines.append(
f" multiline.dark/light = {self.multiline.dark}/{self.multiline.light}"
)
lines.append(
f" mt.xy color={self.mt.xy.color!r} marker={self.mt.xy.marker!r}"
)
lines.append(
f" mt.yx color={self.mt.yx.color!r} marker={self.mt.yx.marker!r}"
)
lines.append(
f" mt.te color={self.mt.te.color!r} ls={self.mt.te.ls!r}"
)
lines.append(
f" mt.tm color={self.mt.tm.color!r} ls={self.mt.tm.ls!r}"
)
b = self.correction.before
a = self.correction.after
lines.append(
f" correction.before color={b.color!r} ls={b.ls!r} label={b.label!r}"
)
lines.append(
f" correction.after color={a.color!r} ls={a.ls!r} label={a.label!r}"
)
lines.append(
f" raw color={self.raw.color!r} marker={self.raw.marker!r}"
f" ls={self.raw.ls!r} lw={self.raw.lw}"
)
e = self.pt_ellipse
lines.append(
f" pt_ellipse normalise_by={e.normalise_by!r} scale={e.scale}"
f" c_by={e.c_by!r} cmap={e.resolve_cmap()!r}"
)
lines.append(
f" pt_ellipse edgecolor={e.edgecolor!r} lw={e.linewidth}"
f" alpha={e.alpha} mark_3d={e.mark_3d}"
f" skew_thresh={e.skew_threshold}"
)
return "\n".join(lines)
def __repr__(self) -> str: # noqa: D105
return self.summary()
# ═══════════════════════════════════════════════════════════════════════════════
# Full-package presets
# ═══════════════════════════════════════════════════════════════════════════════
def _register_preset(name: str, spec: dict[str, Any]) -> None:
PyCSAMTStyle._PRESETS[name.lower()] = spec
# "pycsamt" — default package look
_register_preset(
"pycsamt",
{
"rose": "pycsamt", # string → resolved via _ROSE_PRESETS
"multiline": dict(
mode="gradient",
base_color="blue",
cmap=None,
dark=0.85,
light=0.25,
reverse=False,
lw=1.5,
alpha=0.85,
),
"mt": {
"xy": dict(
color="#1f77b4",
ls="-",
lw=1.5,
marker="o",
ms=4.0,
mfc="white",
label="XY",
),
"yx": dict(
color="#d62728",
ls="-",
lw=1.5,
marker="s",
ms=4.0,
mfc="white",
label="YX",
),
"xx": dict(
color="#2ca02c",
ls="--",
lw=1.2,
marker="^",
ms=3.5,
mfc="white",
label="XX",
),
"yy": dict(
color="#9467bd",
ls="--",
lw=1.2,
marker="v",
ms=3.5,
mfc="white",
label="YY",
),
"te": dict(
color="#1f77b4",
ls="-",
lw=1.5,
marker="o",
ms=4.0,
mfc="white",
label="TE",
),
"tm": dict(
color="#d62728",
ls="-",
lw=1.5,
marker="s",
ms=4.0,
mfc="white",
label="TM",
),
"det": dict(
color="#7f7f7f",
ls="-",
lw=1.3,
marker="D",
ms=3.5,
mfc="white",
label="det",
),
},
# blue-dashed / red-solid — matches fig5b_static_shift_1d convention
"correction": {
"before": dict(
color="#1f77b4",
ls="--",
lw=1.5,
marker="o",
ms=4.0,
mfc="white",
mew=1.2,
alpha=0.85,
label="before",
),
"after": dict(
color="#d62728",
ls="-",
lw=1.5,
marker="o",
ms=4.0,
mfc="white",
mew=1.2,
alpha=0.90,
label="after",
),
},
"raw": dict(
color="black",
ls=":",
lw=0.8,
marker=".",
ms=3.0,
mfc="black",
mew=0.5,
alpha=0.85,
capsize=1.5,
elinewidth=0.6,
label="raw",
),
# phase-tensor ellipse defaults — Caldwell 2004 / pycsamt house style
"pt_ellipse": dict(
normalise_by="cell",
scale=0.85,
s1_ref=None,
c_by="skew",
cmap="RdBu_r",
clim=None,
clim_pct=(5.0, 95.0),
symmetric_clim=True,
alpha=0.92,
edgecolor="k",
linewidth=0.2,
skew_threshold=3.0,
mark_3d=True,
lw_3d_factor=3.0,
show_ref=True,
ref_edgecolor="k",
ref_lw=0.8,
ref_fontsize=6.5,
),
},
)
# "publication" — grayscale / BW-friendly for journals
_register_preset(
"publication",
{
"rose": "publication",
"multiline": dict(
mode="gradient",
base_color="grey",
cmap="Greys",
dark=0.80,
light=0.20,
reverse=False,
lw=1.5,
alpha=0.90,
),
"mt": {
"xy": dict(
color="black",
ls="-",
lw=1.5,
marker="o",
ms=4.0,
mfc="white",
label="XY",
),
"yx": dict(
color="black",
ls="-",
lw=1.5,
marker="s",
ms=4.0,
mfc="black",
label="YX",
),
"xx": dict(
color="#555555",
ls="--",
lw=1.2,
marker="^",
ms=3.5,
mfc="white",
label="XX",
),
"yy": dict(
color="#555555",
ls="--",
lw=1.2,
marker="v",
ms=3.5,
mfc="#555555",
label="YY",
),
"te": dict(
color="black",
ls="-",
lw=1.5,
marker="o",
ms=4.0,
mfc="white",
label="TE",
),
"tm": dict(
color="black",
ls="--",
lw=1.5,
marker="s",
ms=4.0,
mfc="black",
label="TM",
),
"det": dict(
color="#777777",
ls=":",
lw=1.3,
marker="D",
ms=3.5,
mfc="white",
label="det",
),
},
# BW-friendly: distinguish before/after by linestyle only
"correction": {
"before": dict(
color="#444444",
ls="--",
lw=1.5,
marker="o",
ms=4.0,
mfc="white",
mew=1.2,
alpha=0.85,
label="before",
),
"after": dict(
color="#000000",
ls="-",
lw=1.5,
marker="o",
ms=4.0,
mfc="black",
mew=1.2,
alpha=0.92,
label="after",
),
},
"raw": dict(
color="black",
ls=":",
lw=0.75,
marker=".",
ms=2.8,
mfc="black",
mew=0.4,
alpha=0.88,
capsize=1.2,
elinewidth=0.55,
label="raw",
),
# grayscale ellipses: no fill colour, edge-only on white paper
"pt_ellipse": dict(
normalise_by="cell",
scale=0.82,
s1_ref=None,
c_by="skew",
cmap="RdBu_r", # keep colour for information
clim=None,
clim_pct=(5.0, 95.0),
symmetric_clim=True,
alpha=0.85,
edgecolor="k",
linewidth=0.35, # slightly thicker for print
skew_threshold=3.0,
mark_3d=True,
lw_3d_factor=2.5,
show_ref=True,
ref_edgecolor="k",
ref_lw=1.0,
ref_fontsize=6.0,
),
},
)
# "dark" — optimised for dark-background Jupyter / presentation slides
_register_preset(
"dark",
{
"rose": "pycsamt", # keep the default rose preset
"multiline": dict(
mode="gradient",
base_color="blue",
cmap="cool",
dark=0.95,
light=0.45,
reverse=False,
lw=2.0,
alpha=0.90,
),
"mt": {
"xy": dict(
color="#74b9ff",
ls="-",
lw=2.0,
marker="o",
ms=4.5,
mfc="#74b9ff",
label="XY",
),
"yx": dict(
color="#ff7675",
ls="-",
lw=2.0,
marker="s",
ms=4.5,
mfc="#ff7675",
label="YX",
),
"xx": dict(
color="#55efc4",
ls="--",
lw=1.6,
marker="^",
ms=4.0,
mfc="#55efc4",
label="XX",
),
"yy": dict(
color="#a29bfe",
ls="--",
lw=1.6,
marker="v",
ms=4.0,
mfc="#a29bfe",
label="YY",
),
"te": dict(
color="#74b9ff",
ls="-",
lw=2.0,
marker="o",
ms=4.5,
mfc="#74b9ff",
label="TE",
),
"tm": dict(
color="#ff7675",
ls="-",
lw=2.0,
marker="s",
ms=4.5,
mfc="#ff7675",
label="TM",
),
"det": dict(
color="#dfe6e9",
ls="-",
lw=1.5,
marker="D",
ms=4.0,
mfc="#dfe6e9",
label="det",
),
},
# light blue / light red — readable on dark background
"correction": {
"before": dict(
color="#74b9ff",
ls="--",
lw=2.0,
marker="o",
ms=4.5,
mfc="#74b9ff",
mew=1.2,
alpha=0.85,
label="before",
),
"after": dict(
color="#ff7675",
ls="-",
lw=2.0,
marker="o",
ms=4.5,
mfc="#ff7675",
mew=1.2,
alpha=0.92,
label="after",
),
},
"raw": dict(
color="#f2f2f2",
ls=":",
lw=0.9,
marker=".",
ms=3.2,
mfc="#f2f2f2",
mew=0.5,
alpha=0.88,
capsize=1.5,
elinewidth=0.6,
label="raw",
),
# dark-mode ellipses: lighter edge, slightly higher alpha
"pt_ellipse": dict(
normalise_by="cell",
scale=0.85,
s1_ref=None,
c_by="skew",
cmap="coolwarm", # coolwarm reads better on dark bg
clim=None,
clim_pct=(5.0, 95.0),
symmetric_clim=True,
alpha=0.88,
edgecolor="#dddddd",
linewidth=0.25, # light grey edge on dark canvas
skew_threshold=3.0,
mark_3d=True,
lw_3d_factor=3.0,
show_ref=True,
ref_edgecolor="#aaaaaa",
ref_lw=0.8,
ref_fontsize=6.5,
),
},
)
# "modem" — colour convention for ModEM inversion response / pseudo-section
# ─────────────────────────────────────────────────────────────────────────
# Observed Z_x* components : pure blue #0000FF (hollow circle marker)
# Observed Z_y* components : bright red #FF2C2C (hollow square marker)
# Predicted Z_x* model line : green #00FF00 dotted (:)
# Predicted Z_y* model line : magenta #CC338B dotted (:)
#
# Only the "mt" section is overridden; all other style sections
# (rose, multiline, correction …) stay at the package default.
_register_preset(
"modem",
{
"mt": {
"xx": dict(
color="#0000FF",
ls="-",
lw=1.5,
marker="o",
ms=4.0,
mfc="white",
label=r"$Z_{xx}$",
predicted_color="#00FF00",
predicted_ls=":",
),
"xy": dict(
color="#0000FF",
ls="-",
lw=1.5,
marker="o",
ms=4.0,
mfc="white",
label=r"$Z_{xy}$",
predicted_color="#00FF00",
predicted_ls=":",
),
"yx": dict(
color="#FF2C2C",
ls="-",
lw=1.5,
marker="s",
ms=4.0,
mfc="white",
label=r"$Z_{yx}$",
predicted_color="#CC338B",
predicted_ls=":",
),
"yy": dict(
color="#FF2C2C",
ls="-",
lw=1.5,
marker="s",
ms=4.0,
mfc="white",
label=r"$Z_{yy}$",
predicted_color="#CC338B",
predicted_ls=":",
),
"te": dict(
color="#0000FF",
ls="-",
lw=1.5,
marker="o",
ms=4.0,
mfc="white",
label="TE",
predicted_color="#00FF00",
predicted_ls=":",
),
"tm": dict(
color="#FF2C2C",
ls="-",
lw=1.5,
marker="s",
ms=4.0,
mfc="white",
label="TM",
predicted_color="#CC338B",
predicted_ls=":",
),
"det": dict(
color="#7f7f7f",
ls="-",
lw=1.3,
marker="D",
ms=3.5,
mfc="white",
label="det",
predicted_color="#aaaaaa",
predicted_ls=":",
),
},
},
)
# ═══════════════════════════════════════════════════════════════════════════════
# Module-level singleton + convenience functions
# ═══════════════════════════════════════════════════════════════════════════════
#: Package-level singleton. Import and mutate this object to configure
#: all pycsamt figures globally.
PYCSAMT_STYLE: PyCSAMTStyle = PyCSAMTStyle()
[docs]
def use_style(preset: str) -> None:
"""Apply a named full-package style preset to :data:`PYCSAMT_STYLE`.
Parameters
----------
preset : str
One of ``"pycsamt"``, ``"publication"``, ``"dark"``.
Examples
--------
>>> from pycsamt.api.style import use_style
>>> use_style("publication")
"""
PYCSAMT_STYLE.use(preset)
[docs]
def reset_style() -> None:
"""Reset :data:`PYCSAMT_STYLE` to package defaults.
Examples
--------
>>> from pycsamt.api.style import reset_style
>>> reset_style()
"""
PYCSAMT_STYLE.reset()
__all__ = [
# main objects
"PyCSAMTStyle",
"MultilineStyle",
"MTComponentStyle",
"_MTComp",
"RoseStyle",
"CorrectionStyle",
"PhaseTensorEllipseStyle",
"RawDataStyle",
# singleton + helpers
"PYCSAMT_STYLE",
"use_style",
"reset_style",
"configure_style",
# re-export for convenience
"resolve_rose_style",
# c_by → cmap lookup table (useful for plot functions)
"_PT_CMAP_DEFAULTS",
]