from __future__ import annotations
from typing import Any, Literal, Sequence
from matplotlib.figure import Figure
from matplotlib.axes import Axes
import matplotlib.pyplot as plt
import numpy as np
from ....models.wells import VerticalWell, _validate_well_sequence
from ....grids.sampling._vertices import _resolve_xy_vertices
from .layers.boundary import _add_boundary_polygon
from ....models.boundary import BoundaryPolygon
from .theme import Matplotlib2DViewerTheme
from .layers.surface import _add_surface
from ....models.horizon import Horizon
from .._core.base import Base2DViewer
from .layers.wells import _add_well
from ....models.zone import Zone
[docs]
class Matplotlib2DViewer(Base2DViewer):
"""Render 2D reservoir objects with Matplotlib.
This viewer manages a Matplotlib figure/axes pair, applies a reusable visual
theme, and provides convenience methods to plot horizons, zones, and boundary
polygons on a common 2D canvas.
Parameters
----------
fig : Figure or None, default=None
Figure instance to use. If provided without ``ax``, a new subplot is
created on this figure.
ax : Axes or None, default=None
Axes instance to use. If provided without ``fig``, the corresponding
``ax.figure`` is used automatically.
theme : Matplotlib2DViewerTheme or None, default=None
Theme configuration controlling layout and styling. When ``None``, the
default ``Matplotlib2DViewerTheme`` is used.
window_title : str, default='Petres 2D Viewer'
Window title shown in the figure manager (if supported by the Matplotlib backend).
Raises
------
ValueError
If both ``fig`` and ``ax`` are provided but ``ax`` does not belong to
``fig``.
"""
[docs]
def __init__(
self,
fig: Figure | None = None,
ax: Axes | None = None,
theme: Matplotlib2DViewerTheme | None = None,
window_title: str = "Petres 2D Viewer",
) -> None:
"""Initialize a Matplotlib 2D viewer."""
self.set_theme(theme or Matplotlib2DViewerTheme())
self.window_title = window_title
if fig is not None and ax is not None:
if ax.figure is not fig:
raise ValueError("`ax` does not belong to the provided `fig`.")
self.fig = fig
self.ax = ax
elif ax is not None:
self.ax = ax
self.fig = ax.figure
elif fig is not None:
self.fig = fig
self.ax = fig.add_subplot(111)
else:
self.fig, self.ax = plt.subplots(
figsize=self.theme.figure_size,
dpi=self.theme.dpi,
constrained_layout=self.theme.constrained_layout,
)
[docs]
def set_theme(self, theme: Matplotlib2DViewerTheme) -> None:
"""Set the active viewer theme.
Parameters
----------
theme : Matplotlib2DViewerTheme
Theme object containing visual settings such as figure size, axis
labels, and grid behavior.
"""
self.theme = theme
[docs]
def apply_theme(self) -> None:
"""Apply the active theme values to the current axes.
"""
ax = self.ax
theme = self.theme
ax.set_facecolor(theme.background)
ax.set_aspect(theme.aspect, adjustable="box")
if theme.grid:
ax.grid(
True,
alpha=theme.grid_alpha,
linestyle=theme.grid_linestyle,
linewidth=theme.grid_linewidth,
)
else:
ax.grid(False)
if theme.show_labels:
ax.set_xlabel(theme.xlabel)
ax.set_ylabel(theme.ylabel)
ax.tick_params(labelsize=theme.tick_labelsize)
if theme.hide_top_right_spines:
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
[docs]
def set_window_title(self, title: str) -> None:
"""Set the window title shown in the figure manager (if supported by the Matplotlib backend).
Parameters
----------
title : str
Title text to set for the figure window.
"""
self.window_title = title
manager = getattr(self.fig.canvas, "manager", None)
if manager is not None and hasattr(manager, "set_window_title"):
manager.set_window_title(title)
[docs]
def show(self, *, title: str | None = None) -> None:
"""Autoscale, style, and display the current figure.
Parameters
----------
title : str or None, default=None
Optional title text shown above the axes.
"""
# Avoid relim(): it can miss Collection artists (e.g. scatter/pcolormesh).
self.set_window_title(self.window_title)
self.ax.margins(x=self.theme.margins, y=self.theme.margins)
self.ax.autoscale(enable=True, axis="both", tight=False)
self.ax.autoscale_view()
if title is not None:
self.ax.set_title(str(title), fontsize=self.theme.title_fontsize, pad=10)
self.apply_theme()
plt.show()
[docs]
def add_horizon(
self,
horizon: Horizon,
*,
x: np.ndarray | None = None,
y: np.ndarray | None = None,
xlim: tuple[float, float] | None = None,
ylim: tuple[float, float] | None = None,
ni: int | None = None,
nj: int | None = None,
dx: float | None = None,
dy: float | None = None,
**kwargs: Any,
) -> Matplotlib2DViewer:
"""Add a horizon map to the 2D axes.
The method resolves/derives 1D vertex coordinates, samples the horizon on
the resulting grid, and forwards the scalar map to the surface layer.
Parameters
----------
horizon : Horizon
Horizon instance to render.
x : ndarray or None, default=None
1D x-vertex coordinates. Mutually exclusive with ``xlim``/``ni``/``dx``.
y : ndarray or None, default=None
1D y-vertex coordinates. Mutually exclusive with ``ylim``/``nj``/``dy``.
xlim : tuple[float, float] or None, default=None
Inclusive x-bounds used to generate vertices when ``x`` is not provided.
ylim : tuple[float, float] or None, default=None
Inclusive y-bounds used to generate vertices when ``y`` is not provided.
ni : int or None, default=None
Number of x-direction cells used with ``xlim``.
nj : int or None, default=None
Number of y-direction cells used with ``ylim``.
dx : float or None, default=None
X cell size used with ``xlim`` as an alternative to ``ni``.
dy : float or None, default=None
Y cell size used with ``ylim`` as an alternative to ``nj``.
**kwargs
Additional keyword arguments forwarded to the surface plotting helper.
Returns
-------
Matplotlib2DViewer
The viewer instance (for chaining).
Raises
------
ValueError
If vertex resolution arguments are inconsistent.
Examples
--------
>>> viewer = Matplotlib2DViewer()
>>> viewer.add_horizon(horizon, xlim=(0, 500), ylim=(0, 400), ni=100, nj=80, cmap="viridis").show()
"""
x, y = _resolve_xy_vertices(
x=x,
y=y,
xlim=xlim,
ylim=ylim,
ni=ni,
nj=nj,
dx=dx,
dy=dy,
)
scalars = horizon.to_grid(x, y)
_add_surface(
self.ax,
scalars,
x=x,
y=y,
**kwargs,
)
return self
[docs]
def add_zone(
self,
zone: Zone,
*,
x: np.ndarray | None = None,
y: np.ndarray | None = None,
xlim: tuple[float, float] | None = None,
ylim: tuple[float, float] | None = None,
ni: int | None = None,
nj: int | None = None,
dx: float | None = None,
dy: float | None = None,
mode: Literal["top", "base", "thickness"] = "thickness",
**kwargs: Any,
) -> Matplotlib2DViewer:
"""Add a zone scalar map to the 2D axes.
The method samples the zone top/base surfaces on a resolved grid and plots
either top, base, or absolute thickness values.
Parameters
----------
zone : Zone
Zone instance to render.
x : ndarray or None, default=None
1D x-vertex coordinates. Mutually exclusive with ``xlim``/``ni``/``dx``.
y : ndarray or None, default=None
1D y-vertex coordinates. Mutually exclusive with ``ylim``/``nj``/``dy``.
xlim : tuple[float, float] or None, default=None
Inclusive x-bounds used to generate vertices when ``x`` is not provided.
ylim : tuple[float, float] or None, default=None
Inclusive y-bounds used to generate vertices when ``y`` is not provided.
ni : int or None, default=None
Number of x-direction cells used with ``xlim``.
nj : int or None, default=None
Number of y-direction cells used with ``ylim``.
dx : float or None, default=None
X cell size used with ``xlim`` as an alternative to ``ni``.
dy : float or None, default=None
Y cell size used with ``ylim`` as an alternative to ``nj``.
mode : {'top', 'base', 'thickness'}, default='thickness'
Which scalar field to plot.
**kwargs
Additional keyword arguments forwarded to the surface plotting helper.
Returns
-------
Matplotlib2DViewer
The viewer instance (for chaining).
Raises
------
ValueError
If ``mode`` is not one of ``'top'``, ``'base'``, or ``'thickness'``.
Examples
--------
>>> Matplotlib2DViewer().add_zone(zone, x=[0, 50], y=[0, 50], mode="top", cmap="cividis").show()
"""
x, y = _resolve_xy_vertices(
x=x,
y=y,
xlim=xlim,
ylim=ylim,
ni=ni,
nj=nj,
dx=dx,
dy=dy,
)
top, base = zone.to_grid(x, y)
if mode == "top":
scalars = top
elif mode == "base":
scalars = base
elif mode == "thickness":
scalars = np.abs(base - top)
else:
raise ValueError(f"Invalid mode: {mode!r}. Must be 'top', 'base', or 'thickness'.")
_add_surface(
self.ax,
scalars=scalars,
x=x,
y=y,
**kwargs,
)
return self
[docs]
def add_boundary_polygon(
self,
boundary: BoundaryPolygon,
*,
facecolor: Any = "#7ec8e3",
edgecolor: Any = "#1f2937",
linewidth: float = 1.8,
alpha: float = 0.30,
show_fill: bool = True,
show_vertices: bool = False,
vertex_color: Any | None = None,
vertex_size: float = 24.0,
show_label: bool = False,
label: str | None = None,
label_fontsize: float = 10.0,
label_box: bool = True,
pad_ratio: float | None = None,
**kwargs: Any,
) -> Matplotlib2DViewer:
"""
Add a boundary polygon overlay to the 2D axes.
Parameters
----------
boundary : BoundaryPolygon
Polygon to draw.
facecolor : str or tuple[float, float, float] or tuple[float, float, float, float], default='#7ec8e3'
Fill color for the polygon.
edgecolor : str or tuple[float, float, float] or tuple[float, float, float, float], default='#1f2937'
Edge color.
linewidth : float, default=1.8
Boundary line width.
alpha : float, default=0.30
Fill opacity (0–1).
show_fill : bool, default=True
Whether to fill the polygon.
show_vertices : bool, default=False
Whether to show vertex markers.
vertex_color : str or tuple[float, float, float] or tuple[float, float, float, float] or None, default=None
Color for vertex markers; defaults to `edgecolor`.
vertex_size : float, default=24.0
Marker size for vertices.
show_label : bool, default=True
Whether to render the polygon name/label.
label : str or None, default=None
Custom label text; defaults to `boundary.name`.
label_fontsize : float, default=10.0
Font size for the label.
label_box : bool, default=True
Whether to draw a small background box behind the label.
pad_ratio : float or None, default=None
Padding fraction applied to axis limits; defaults to theme margins.
**kwargs
Additional keyword arguments forwarded to the patch helper.
Returns
-------
Matplotlib2DViewer
The viewer instance (for chaining).
Examples
--------
>>> viewer = Matplotlib2DViewer()
>>> viewer.add_boundary_polygon(boundary, show_vertices=True, vertex_size=30).show()
"""
_add_boundary_polygon(
self.ax,
boundary,
facecolor=facecolor,
edgecolor=edgecolor,
linewidth=linewidth,
alpha=alpha,
show_fill=show_fill,
show_vertices=show_vertices,
vertex_color=edgecolor if vertex_color is None else vertex_color,
vertex_size=vertex_size,
show_label=show_label,
label=label,
label_fontsize=label_fontsize,
label_box=label_box,
pad_ratio=self.theme.margins if pad_ratio is None else pad_ratio,
**kwargs,
)
return self
[docs]
def add_wells(
self,
wells: VerticalWell | Sequence[VerticalWell],
*,
marker: str = "o",
marker_size: float = 56.0,
marker_color: Any = "#b91c1c",
marker_edgecolor: Any = "white",
marker_edgewidth: float = 0.9,
show_label: bool = True,
label: str | None = None,
label_fontsize: float = 9.5,
label_color: Any = "#111827",
label_offset: tuple[float, float] = (6.0, 6.0),
zorder: float = 5.0,
**kwargs: Any,
) -> Matplotlib2DViewer:
"""
Add vertical wells to the 2D axes as markers with optional labels.
Parameters
----------
wells : VerticalWell or Sequence[VerticalWell]
Single well or sequence of wells to plot.
marker : str, default='o'
Marker style used for each well.
marker_size : float, default=56.0
Marker size in points^2.
marker_color : Any, default='#b91c1c'
Marker face color.
marker_edgecolor : Any, default='white'
Marker edge color.
marker_edgewidth : float, default=0.9
Marker edge line width.
show_label : bool, default=True
Whether to render labels for wells.
label : str or None, default=None
Optional fixed label text for all wells; defaults to each well name.
label_fontsize : float, default=9.5
Label text size.
label_color : Any, default='#111827'
Label color.
label_offset : tuple[float, float], default=(6.0, 6.0)
Label offset in points from marker center.
zorder : float, default=5.0
Base drawing order for well marker/label.
**kwargs : Any
Extra keyword arguments forwarded to Matplotlib ``Axes.scatter``.
Returns
-------
Matplotlib2DViewer
The viewer instance (for chaining).
Raises
------
TypeError
If ``wells`` is not a ``VerticalWell`` or a sequence of them.
"""
wells = _validate_well_sequence(wells)
for well in wells:
_add_well(
self.ax,
well.x,
well.y,
well.name,
marker=marker,
marker_size=marker_size,
marker_color=marker_color,
marker_edgecolor=marker_edgecolor,
marker_edgewidth=marker_edgewidth,
show_label=show_label,
label=label,
label_fontsize=label_fontsize,
label_color=label_color,
label_offset=label_offset,
zorder=zorder,
**kwargs,
)
return self