# -*- coding: utf-8 -*-
"""
Created on Sun Sep 25 15:20:43 2022
@author: jpeacock
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import matplotlib.colorbar as mcb
import matplotlib.colors as colors
# =============================================================================
# Imports
# =============================================================================
import numpy as np
from matplotlib import __version__ as matplotlib_version
import mtpy.imaging.mtcolors as mtcl
from . import MTArrows, MTEllipse
if TYPE_CHECKING:
from matplotlib.axes import Axes
# =============================================================================
# ==============================================================================
# Plot settings
# ==============================================================================
[docs]
class PlotSettings(MTArrows, MTEllipse):
"""
Comprehensive plot settings for MT data visualization.
Combines arrow and ellipse settings with figure, marker, line, and
colorbar properties for creating publication-quality MT plots.
Parameters
----------
**kwargs : dict, optional
Keyword arguments to set any class attributes. Valid keys
correspond to the extensive list of attributes below.
Attributes
----------
fig_num : int
Figure number, by default 1
fig_dpi : int
Figure DPI resolution, by default 150
fig_size : tuple | None
Figure size (width, height) in inches, by default None
show_plot : bool
Whether to display plot, by default True
font_size : int
Base font size for plot text, by default 7
font_weight : str
Font weight ('normal', 'bold', etc.), by default 'bold'
marker_size : float
Size of plot markers, by default 2.5
marker_lw : float
Line width of marker edges, by default 0.75
marker_color : str
Default marker color, by default 'b'
marker : str
Default marker style, by default 'v'
lw : float
Default line width, by default 1
plot_title : str | None
Plot title text, by default None
xy_ls, yx_ls, det_ls, skew_ls, strike_ls : str
Line styles for different components, by default ':'
xy_marker, yx_marker, det_marker, skew_marker : str
Marker styles for different components
strike_inv_marker, strike_pt_marker, strike_tip_marker : str
Marker styles for strike indicators
xy_color, yx_color, det_color, skew_color : tuple
RGB colors for different components
strike_inv_color, strike_pt_color, strike_tip_color : tuple
RGB colors for strike indicators
x_limits, y_limits, res_limits, phase_limits : tuple | None
Axis limits for various plot types
tipper_limits, strike_limits, skew_limits, pt_limits : tuple | None
Additional axis limits
plot_z : bool
Whether to plot impedance, by default True
plot_tipper : str
Tipper plotting mode ('n', 'y', 'yri'), by default 'n'
plot_pt : bool
Whether to plot phase tensor, by default False
plot_strike : bool
Whether to plot strike, by default False
plot_skew : bool
Whether to plot skew, by default False
text_size : int
Text annotation size, by default 7
text_weight : str
Text annotation weight, by default 'normal'
text_color : str
Text annotation color, by default 'k'
text_ha : str
Text horizontal alignment, by default 'center'
text_va : str
Text vertical alignment, by default 'baseline'
text_angle : float
Text rotation angle, by default 0
text_x_pad, text_y_pad : float
Text padding in x/y directions, by default 0
text_rotation : float
Additional text rotation, by default 0
subplot_left, subplot_right, subplot_bottom, subplot_top : float
Subplot spacing parameters
subplot_wspace, subplot_hspace : float | None
Width/height spacing between subplots
cb_orientation : str
Colorbar orientation ('vertical' or 'horizontal'), by default 'vertical'
cb_position : tuple | None
Colorbar position [left, bottom, width, height], by default None
"""
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
# figure properties:
self.fig_num = 1
self.fig_dpi = 150
self.fig_size = None
self.show_plot = True
self.font_size = 7
self.font_weight = "bold"
self.marker_size = 2.5
self.marker_lw = 0.75
self.marker_color = "b"
self.marker = "v"
self.lw = 1
self.plot_title = None
# line styles:
self.xy_ls = ":"
self.yx_ls = ":"
self.det_ls = ":"
self.skew_ls = ":"
self.strike_ls = ":"
# marker styles:
self.xy_marker = "s"
self.yx_marker = "o"
self.det_marker = "v"
self.skew_marker = "d"
self.strike_inv_marker = "v"
self.strike_pt_marker = "^"
self.strike_tip_marker = ">"
# marker color styles:
self.xy_color = (0.25, 0.35, 0.75)
self.yx_color = (0.75, 0.25, 0.25)
self.det_color = (0.25, 0.75, 0.25)
self.skew_color = (0.85, 0.35, 0)
self.strike_inv_color = (0.2, 0.2, 0.7)
self.strike_pt_color = (0.7, 0.2, 0.2)
self.strike_tip_color = (0.2, 0.7, 0.2)
# marker face color styles:
self.xy_mfc = (0.25, 0.35, 0.75)
self.yx_mfc = (0.75, 0.25, 0.25)
self.det_mfc = (0.25, 0.75, 0.25)
self.skew_mfc = (0.85, 0.35, 0)
self.strike_inv_mfc = (0.2, 0.2, 0.7)
self.strike_pt_mfc = (0.7, 0.2, 0.2)
self.strike_tip_mfc = (0.2, 0.7, 0.2)
# plot limits
self.x_limits = None
self.y_limits = None
self.res_limits = None
self.phase_limits = None
self.tipper_limits = None
self.strike_limits = None
self.skew_limits = None
self.pt_limits = None
# Show Plot
self.show_plot = True
self.plot_z = True
self.plot_tipper = "n"
self.plot_pt = False
self.plot_strike = False
self.plot_skew = False
self.text_size = 7
self.text_weight = "normal"
self.text_color = "k"
self.text_ha = "center"
self.text_va = "baseline"
self.text_angle = 0
self.text_x_pad = 0
self.text_y_pad = 0
self.text_rotation = 0
self.subplot_left = 0.09
self.subplot_right = 0.9
self.subplot_bottom = 0.09
self.subplot_top = 0.98
self.subplot_wspace = None
self.subplot_hspace = None
self.cb_orientation = "vertical"
self.cb_position = None
# Set class property values from kwargs and pop them
for key, value in kwargs.items():
setattr(self, key, value)
self.cb_label_dict = {
"phiminang": r"$\Phi_{min}$ (deg)",
"phimin": r"$\Phi_{min}$ (deg)",
"phimaxang": r"$\Phi_{max}$ (deg)",
"phimax": r"$\Phi_{max}$ (deg)",
"phidet": r"Det{$\Phi$} (deg)",
"skew": r"Skew (deg)",
"normalized_skew": r"Normalized Skew (deg)",
"ellipticity": r"Ellipticity",
"skew_seg": r"Skew (deg)",
"normalized_skew_seg": r"Normalized Skew (deg)",
"geometric_mean": r"$\sqrt{\Phi_{min} \cdot \Phi_{max}}$",
"strike": r"Azimuth (deg)",
"azimuth": r"Azimuth (deg)",
}
@property
def period_label_dict(self) -> dict[int, str]:
"""
Get LaTeX-formatted period labels for log10 scale.
Returns
-------
dict[int, str]
Dictionary mapping integer exponents to LaTeX strings
in format $10^{exponent}$ for range -20 to 20
"""
return dict([(ii, "$10^{" + str(ii) + "}$") for ii in range(-20, 21)])
[docs]
def set_period_limits(self, period: np.ndarray) -> tuple[float, float]:
"""
Calculate period axis limits as powers of 10.
Parameters
----------
period : np.ndarray
Array of period values
Returns
-------
tuple[float, float]
Minimum and maximum limits as powers of 10 (floor and ceil)
"""
return (
10 ** (np.floor(np.log10(period.min()))),
10 ** (np.ceil(np.log10(period.max()))),
)
def _estimate_resistivity_min(self, res_array: np.ndarray) -> float:
"""
Estimate minimum non-zero resistivity value.
Parameters
----------
res_array : np.ndarray
Array of resistivity values
Returns
-------
float
Minimum non-zero resistivity value
"""
nz = np.nonzero(res_array)
return np.nanmin(res_array[nz])
def _estimate_resistivity_max(self, res_array: np.ndarray) -> float:
"""
Estimate maximum non-zero resistivity value.
Parameters
----------
res_array : np.ndarray
Array of resistivity values
Returns
-------
float
Maximum non-zero resistivity value
"""
nz = np.nonzero(res_array)
return np.nanmax(res_array[nz])
def _compute_power_ten_min(self, value: float) -> float:
"""
Compute nearest power of 10 below value (floor).
Parameters
----------
value : float
Input value
Returns
-------
float
10 raised to floor of log10(value)
"""
return 10 ** (np.floor(np.log10(value)))
def _compute_power_ten_max(self, value: float) -> float:
"""
Compute nearest power of 10 above value (ceil).
Parameters
----------
value : float
Input value
Returns
-------
float
10 raised to ceil of log10(value)
"""
return 10 ** (np.ceil(np.log10(value)))
def _estimate_resistivity_limits_min(self, res_list: list[np.ndarray]) -> float:
"""
Estimate minimum resistivity limit from multiple arrays.
Parameters
----------
res_list : list[np.ndarray]
List of resistivity arrays
Returns
-------
float
Minimum limit as power of 10 (floor)
"""
return self._compute_power_ten_min(
min([self._estimate_resistivity_min(rr) for rr in res_list])
)
def _estimate_resistivity_limits_max(self, res_list: list[np.ndarray]) -> float:
"""
Estimate maximum resistivity limit from multiple arrays.
Parameters
----------
res_list : list[np.ndarray]
List of resistivity arrays
Returns
-------
float
Maximum limit as power of 10 (ceil)
"""
return self._compute_power_ten_max(
max([self._estimate_resistivity_max(rr) for rr in res_list])
)
[docs]
def set_resistivity_limits(
self, resistivity: np.ndarray, mode: str = "od", scale: str = "log"
) -> list[float]:
"""
Calculate appropriate resistivity axis limits.
Parameters
----------
resistivity : np.ndarray
Resistivity array with shape (n_periods, 2, 2) for full tensor
or (n_periods,) for determinant
mode : str, optional
Plotting mode:
- 'od': off-diagonal components (xy, yx)
- 'd': diagonal components (xx, yy)
- 'det' or 'det_only': determinant only
- 'all': all components
by default 'od'
scale : str, optional
Scale type ('log' or 'linear'), by default 'log'
Returns
-------
list[float]
[min_limit, max_limit] as powers of 10
Defaults to [0.1, 10000] if calculation fails
"""
if mode in ["od"]:
res_list = [resistivity[:, 0, 1], resistivity[:, 1, 0]]
elif mode == "d":
res_list = [resistivity[:, 0, 0], resistivity[:, 1, 1]]
elif mode in ["det", "det_only"]:
res_list = [resistivity]
elif mode in ["all"]:
res_list = [
resistivity[:, 0, 0],
resistivity[:, 0, 1],
resistivity[:, 1, 0],
resistivity[:, 1, 1],
]
try:
limits = [
self._estimate_resistivity_limits_min(res_list),
self._estimate_resistivity_limits_max(res_list),
]
except (ValueError, TypeError):
limits = [0.1, 10000]
if scale == "log":
if limits[0] == 0:
limits[0] = 0.1
return limits
[docs]
def set_phase_limits(
self, phase: np.ndarray, mode: str = "od"
) -> tuple[float, float] | list[float]:
"""
Calculate appropriate phase axis limits.
Parameters
----------
phase : np.ndarray
Phase array with shape (n_periods, 2, 2) for full tensor
or (n_periods,) for determinant
mode : str, optional
Plotting mode:
- 'od': off-diagonal components (0-90 range with adjustments)
- 'd': diagonal components (-180 to 180)
- 'det' or 'det_only': determinant phase
by default 'od'
Returns
-------
tuple[float, float] | list[float]
[min_limit, max_limit] in degrees
Defaults to [0, 90] for 'od' or [-180, 180] for others if
calculation fails
"""
if mode in ["od"]:
try:
nz_xy = np.nonzero(phase[:, 0, 1])
nz_yx = np.nonzero(phase[:, 1, 0])
ph_min = min(
[
np.nanmin(phase[nz_xy, 0, 1]),
np.nanmin(phase[nz_yx, 1, 0] + 180),
]
)
if ph_min > 0:
ph_min = 0
else:
ph_min = round(ph_min / 5) * 5
ph_max = max(
[
np.nanmax(phase[nz_xy, 0, 1]),
np.nanmax(phase[nz_yx, 1, 0] + 180),
]
)
if ph_max < 91:
ph_max = 89.9
else:
ph_max = round(ph_max / 5) * 5
return (ph_min, ph_max)
except (ValueError, TypeError):
return [0, 90]
elif mode == "d":
return (-180, 180)
elif mode in ["det", "det_only"]:
try:
phase_det = np.linalg.det(phase)
nz = np.nonzero(phase_det)
phase_limits = [
np.amin(phase_det[nz]),
np.amax(phase_det[nz]),
]
if phase_limits[0] < -180:
phase_limits[0] = -180
if phase_limits[1] > 180:
phase_limits[1] = 180
return phase_limits
except (ValueError, TypeError):
return [-180, 180]
@property
def xy_error_bar_properties(self) -> dict[str, str | float]:
"""
Get matplotlib errorbar properties for xy component.
Returns
-------
dict[str, str | float]
Dictionary of errorbar kwargs including marker style, size,
colors, line styles, and cap properties
"""
return {
"marker": self.xy_marker,
"ms": self.marker_size,
"mew": self.lw,
"mec": self.xy_color,
"color": self.xy_color,
"ecolor": self.xy_color,
"ls": self.xy_ls,
"lw": self.lw,
"capsize": self.marker_size,
"capthick": self.lw,
}
@property
def yx_error_bar_properties(self) -> dict[str, str | float]:
"""
Get matplotlib errorbar properties for yx component.
Returns
-------
dict[str, str | float]
Dictionary of errorbar kwargs including marker style, size,
colors, line styles, and cap properties
"""
return {
"marker": self.yx_marker,
"ms": self.marker_size,
"mew": self.lw,
"mec": self.yx_color,
"color": self.yx_color,
"ecolor": self.yx_color,
"ls": self.yx_ls,
"lw": self.lw,
"capsize": self.marker_size,
"capthick": self.lw,
}
@property
def det_error_bar_properties(self) -> dict[str, str | float]:
"""
Get matplotlib errorbar properties for determinant component.
Returns
-------
dict[str, str | float]
Dictionary of errorbar kwargs including marker style, size,
colors, line styles, and cap properties
"""
return {
"marker": self.det_marker,
"ms": self.marker_size,
"mew": self.lw,
"mec": self.det_color,
"color": self.det_color,
"ecolor": self.det_color,
"ls": self.det_ls,
"lw": self.lw,
"capsize": self.marker_size,
"capthick": self.lw,
}
@property
def font_dict(self) -> dict[str, int | str]:
"""
Get font properties dictionary for matplotlib.
Adjusts key names based on matplotlib version for compatibility.
Returns
-------
dict[str, int | str]
Font properties with 'size'/'fontsize' and 'weight'/'fontweight'
depending on matplotlib version
"""
if int(matplotlib_version.split(".")[1]) < 9:
return {"size": self.font_size + 2, "weight": self.font_weight}
else:
return {
"fontsize": self.font_size + 2,
"fontweight": self.font_weight,
}
[docs]
def make_pt_cb(self, ax: Axes) -> mcb.Colorbar:
"""
Create phase tensor colorbar.
Generates colorbar for phase tensor plots with appropriate color
mapping, normalization, and labeling based on ellipse settings.
Parameters
----------
ax : Axes
Matplotlib axes to attach colorbar to
Returns
-------
mcb.Colorbar
Configured matplotlib colorbar object
"""
cmap = mtcl.cmapdict[self.ellipse_cmap]
if "seg" in self.ellipse_cmap:
# normalize the colors
norms = colors.BoundaryNorm(self.ellipse_cmap_bounds, cmap.N)
# make the colorbar
cb = mcb.Colorbar(
ax,
cmap=cmap,
norm=norms,
orientation=self.cb_orientation,
ticks=self.ellipse_cmap_bounds[1:-1],
)
else:
cb = mcb.Colorbar(
ax,
cmap=cmap,
norm=colors.Normalize(
vmin=self.ellipse_range[0],
vmax=self.ellipse_range[1],
),
orientation=self.cb_orientation,
)
# label the color bar accordingly
cb.set_label(
self.cb_label_dict[self.ellipse_colorby],
fontdict=self.font_dict,
)
# place the label in the correct location
if self.cb_orientation == "horizontal":
cb.ax.xaxis.set_label_position("top")
cb.ax.xaxis.set_label_coords(0.5, 1.3)
elif self.cb_orientation == "vertical":
cb.ax.yaxis.set_label_position("right")
cb.ax.yaxis.set_label_coords(1.25, 0.5)
cb.ax.yaxis.tick_left()
cb.ax.tick_params(axis="y", direction="in")
return cb
@property
def arrow_real_properties(self) -> dict[str, str | float | bool]:
"""
Get matplotlib arrow properties for real tipper component.
Returns
-------
dict[str, str | float | bool]
Dictionary of arrow kwargs including line width, colors,
head dimensions, and length settings
"""
return {
"lw": self.arrow_lw,
"facecolor": self.arrow_color_real,
"edgecolor": self.arrow_color_real,
"head_width": self.arrow_head_width,
"head_length": self.arrow_head_length,
"length_includes_head": False,
}
@property
def arrow_imag_properties(self) -> dict[str, str | float | bool]:
"""
Get matplotlib arrow properties for imaginary tipper component.
Returns
-------
dict[str, str | float | bool]
Dictionary of arrow kwargs including line width, colors,
head dimensions, and length settings
"""
return {
"lw": self.arrow_lw,
"facecolor": self.arrow_color_imag,
"edgecolor": self.arrow_color_imag,
"head_width": self.arrow_head_width,
"head_length": self.arrow_head_length,
"length_includes_head": False,
}
@property
def text_dict(self) -> dict[str, int | str | float]:
"""
Get text annotation properties dictionary.
Returns
-------
dict[str, int | str | float]
Dictionary of text properties including size, weight,
rotation angle, and color
"""
return {
"size": self.text_size,
"weight": self.text_weight,
"rotation": self.text_angle,
"color": self.text_color,
}