"""WellView class for interactive well log display."""
from __future__ import annotations
import warnings
from pathlib import Path
from typing import TYPE_CHECKING
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.collections import PolyCollection
from matplotlib.colors import LogNorm, Normalize
from matplotlib.patches import Rectangle
from . import DEFAULT_COLORS, _downsample_for_plotting
from .template import Template
if TYPE_CHECKING:
from ..core.well import Well
[docs]
class WellView:
"""
Interactive well log display for Jupyter Lab.
Creates matplotlib-based well log plots with multiple tracks showing
continuous logs, discrete properties, fills, and formation tops.
Parameters
----------
well : Well
Well object containing log data
depth_range : tuple[float, float], optional
Depth interval to display [start_depth, end_depth]. If None, shows full depth range.
template : Union[Template, dict, str], optional
Display template. Can be:
- Template object
- Dictionary with template configuration
- String name of template stored in well's parent manager
If None, creates a simple default view.
figsize : tuple[float, float], optional
Figure size (width, height) in inches. If None, calculated from number of tracks.
dpi : int, default 100
Figure resolution
Class Attributes
----------------
HEADER_BOX_TOP : float
Fixed top position of header boxes for alignment across tracks
HEADER_TITLE_SPACING : float
Vertical space between log name and its scale line in continuous tracks
HEADER_LOG_SPACING : float
Vertical space allocated per log in continuous tracks
HEADER_TOP_PADDING : float
Padding inside header box above content
HEADER_BOTTOM_PADDING : float
Padding inside header box below content
Attributes
----------
well : Well
Source well object
depth_range : tuple[float, float]
Displayed depth range
template : Template
Display template configuration
fig : matplotlib.figure.Figure
Matplotlib figure object
axes : list[matplotlib.axes.Axes]
List of axes for each track
Examples
--------
>>> from logsuite import WellDataManager
>>> from logsuite.visualization import WellView, Template
>>>
>>> # Load data
>>> manager = WellDataManager()
>>> manager.load_las("well.las")
>>> well = manager.well_36_7_5_A
>>>
>>> # Create template
>>> template = Template("basic")
>>> template.add_track(
... track_type="continuous",
... logs=[{"name": "GR", "x_range": [0, 150], "color": "green"}],
... title="Gamma Ray"
... )
>>> template.add_track(track_type="depth")
>>>
>>> # Display well log with depth range
>>> view = WellView(well, depth_range=[2800, 3000], template=template)
>>> view.show()
>>>
>>> # Or auto-calculate from formation tops
>>> template.add_tops(property_name='Zone')
>>> view2 = WellView(well, tops=['Top_Brent', 'Top_Statfjord'], template=template)
>>> view2.show()
>>>
>>> # Or use template from manager
>>> manager.set_template("reservoir", template)
>>> view3 = WellView(well, depth_range=[3000, 3200], template="reservoir")
>>> view3.show()
>>>
>>> # Save figure
>>> view.save("well_log.png", dpi=300)
"""
# Class-level configuration for header styling (easily customizable)
HEADER_BOX_TOP = 1.1 # Fixed top position of header boxes
HEADER_TITLE_SPACING = 0.0015 # Space between log name and scale line
HEADER_LOG_SPACING = 0.025 # Vertical space per log
HEADER_TOP_PADDING = 0.01 # Padding above content in header box
HEADER_BOTTOM_PADDING = 0.01 # Padding below content in header box
def __init__(
self,
well: Well,
depth_range: tuple[float, float] | None = None,
tops: list[str] | None = None,
template: Template | dict | str | None = None,
figsize: tuple[float, float] | None = None,
dpi: int = 100,
header_config: dict | None = None,
):
"""
Initialize WellView.
Parameters
----------
well : Well
Well object containing log data
depth_range : tuple[float, float], optional
Depth interval to display [top, bottom].
Mutually exclusive with `tops` parameter.
tops : list[str], optional
List of formation top names to display. The depth range will be calculated
automatically from the minimum and maximum depths of these tops, with 5%
padding added (minimum range of 50m).
Mutually exclusive with `depth_range` parameter.
Requires that formation tops have been loaded in the well or added to the template.
template : Union[Template, dict, str], optional
Display template configuration
figsize : tuple[float, float], optional
Figure size in inches
dpi : int, default 100
Figure resolution
header_config : dict, optional
Header styling configuration. Supported keys:
- header_box_top (float): Fixed top position of header boxes
- header_title_spacing (float): Vertical space between log name and scale line
- header_log_spacing (float): Vertical space allocated per log
- header_top_padding (float): Padding above content in header box
- header_bottom_padding (float): Padding below content in header box
If None or keys omitted, uses class defaults.
Examples
--------
>>> # Use depth range
>>> view1 = WellView(well, depth_range=[2800, 3000], template=template)
>>>
>>> # Use formation tops to auto-calculate range
>>> view2 = WellView(well, tops=['Top_Brent', 'Top_Statfjord'], template=template)
>>>
>>> # Customize header spacing
>>> view3 = WellView(
... well,
... template=template,
... header_config={"header_log_spacing": 0.04, "header_title_spacing": 0.005}
... )
"""
# Validate mutually exclusive parameters
if depth_range is not None and tops is not None:
raise ValueError(
"Parameters 'depth_range' and 'tops' are mutually exclusive. "
"Provide one or the other, not both."
)
self.well = well
self.dpi = dpi
# Header configuration (use provided values or fall back to class defaults)
if header_config is None:
header_config = {}
self.header_box_top = header_config.get("header_box_top", self.HEADER_BOX_TOP)
self.header_title_spacing = header_config.get(
"header_title_spacing", self.HEADER_TITLE_SPACING
)
self.header_log_spacing = header_config.get("header_log_spacing", self.HEADER_LOG_SPACING)
self.header_top_padding = header_config.get("header_top_padding", self.HEADER_TOP_PADDING)
self.header_bottom_padding = header_config.get(
"header_bottom_padding", self.HEADER_BOTTOM_PADDING
)
# Handle template parameter
if isinstance(template, str):
# Get template from manager
if well.parent_manager is None:
raise ValueError(
f"Cannot use template name '{template}': well has no parent manager. "
"Pass a Template object or dict instead."
)
if not hasattr(well.parent_manager, "_templates"):
raise ValueError(
f"Template '{template}' not found in manager. "
f"Use manager.set_template('{template}', template_obj) first."
)
if template not in well.parent_manager._templates:
available = list(well.parent_manager._templates.keys())
raise ValueError(
f"Template '{template}' not found. Available templates: {available}"
)
self.template = well.parent_manager._templates[template]
elif isinstance(template, dict):
self.template = Template.from_dict(template)
elif isinstance(template, Template):
self.template = template
elif template is None:
# Create default template
self.template = self._create_default_template()
else:
raise TypeError(
f"template must be Template, dict, or str, got {type(template).__name__}"
)
# Initialize tops list (will be populated from template later)
self.tops = []
self.temp_tracks = []
# Load tops from template (needed for tops-based depth range calculation)
for tops_config in self.template.tops:
self._add_tops_from_config(tops_config)
# Determine depth range
if tops is not None:
# Calculate depth range from specified tops
self.depth_range = self._calculate_depth_range_from_tops(tops)
elif depth_range is None:
# Use full depth range from first property
if not well.properties:
raise ValueError("Well has no properties to display")
first_prop = well.get_property(well.properties[0].split(".")[0])
self.depth_range = (float(first_prop.depth.min()), float(first_prop.depth.max()))
else:
self.depth_range = depth_range
# Calculate figure size if not provided
if figsize is None:
total_width = sum(track.get("width", 1.0) for track in self.template.tracks)
figsize = (max(2 * total_width, 8), 10)
self.figsize = figsize
self.fig = None
self.axes = []
def _create_default_template(self) -> Template:
"""Create a simple default template with all continuous properties."""
template = Template("default")
# Add first 3 continuous properties
continuous_props = []
for prop_name in self.well.properties:
try:
# Handle source.property format
if "." in prop_name:
prop = self.well.get_property(prop_name.split(".")[1])
else:
prop = self.well.get_property(prop_name)
if prop.type == "continuous":
continuous_props.append(prop.name)
if len(continuous_props) >= 3:
break
except Exception:
continue
# Add tracks for found properties
for prop_name in continuous_props:
template.add_track(
track_type="continuous",
logs=[{"name": prop_name, "color": "blue"}],
title=prop_name,
)
# Add depth track
template.add_track(track_type="depth", width=0.3, title="Depth")
return template
def _calculate_depth_range_from_tops(self, tops_list: list[str]) -> tuple[float, float]:
"""
Calculate depth range from a list of formation top names.
The depth range is calculated as the min/max depths of the specified tops
with 5% padding added. Minimum range is 50m.
Parameters
----------
tops_list : list[str]
List of formation top names to include in depth range
Returns
-------
tuple[float, float]
Calculated depth range (top, bottom)
Raises
------
ValueError
If no tops have been loaded or if specified tops are not found
"""
# Check if any tops have been loaded
if not self.tops:
raise ValueError(
"No formation tops have been loaded. Cannot calculate depth range from tops. "
"Load tops using template.add_tops() or view.add_tops() before using the 'tops' parameter."
)
# Collect all tops data from all tops groups
all_tops_list = []
for tops_group in self.tops:
entries = tops_group.get("entries", [])
for entry in entries:
all_tops_list.append((entry["depth"], entry["name"]))
# Find depths for specified tops
tops_depths = []
not_found = []
for top_name in tops_list:
found = False
for depth, name in all_tops_list:
if name == top_name:
tops_depths.append(depth)
found = True
break
if not found:
not_found.append(top_name)
# Only raise error if NONE of the tops were found
if not tops_depths:
available_tops = list({name for _, name in all_tops_list})
raise ValueError(
f"None of the specified formation tops were found: {tops_list}. "
f"Available tops: {available_tops}"
)
# Calculate min and max depths
min_depth = min(tops_depths)
max_depth = max(tops_depths)
# Calculate range and padding
depth_range = max_depth - min_depth
# Use 5% of 50m as padding if range is less than 50m, otherwise 5% of actual range
if depth_range < 50.0:
padding = 50.0 * 0.05 # 2.5m
range_top = min_depth - padding
range_bottom = range_top + 50.0 # Ensure total range is 50m
else:
padding = depth_range * 0.05
range_top = min_depth - padding
range_bottom = max_depth + padding
return (float(range_top), float(range_bottom))
[docs]
def add_track(
self,
track_type: str = "continuous",
logs: list[dict] | None = None,
fill: dict | list[dict] | None = None,
width: float = 1.0,
title: str | None = None,
log_scale: bool = False,
) -> WellView:
"""
Add a temporary track to this view (not saved to template).
This allows adding tracks to a specific view without modifying the
underlying template. Temporary tracks are appended after template tracks.
Parameters
----------
track_type : {"continuous", "discrete", "depth"}, default "continuous"
Type of track
logs : list[dict], optional
List of log configurations (same format as Template.add_track)
fill : Union[dict, list[dict]], optional
Fill configuration or list of fills
width : float, default 1.0
Relative width of track
title : str, optional
Track title
log_scale : bool, default False
Use logarithmic scale for the track
Returns
-------
WellView
Self for method chaining
Examples
--------
>>> # Create view with template, then add temporary track
>>> view = WellView(well, template=template)
>>> view.add_track(
... track_type="continuous",
... logs=[{"name": "TEMP_LOG", "x_range": [0, 100], "color": "orange"}],
... title="Temporary"
... )
>>> view.show()
Notes
-----
Temporary tracks are not saved to the template and only exist for this view.
If you want to reuse tracks across multiple views, add them to the template instead.
"""
# Normalize fill to list format
fill_list = None
if fill is not None:
if isinstance(fill, dict):
fill_list = [fill]
else:
fill_list = fill
track = {
"type": track_type,
"logs": logs or [],
"fill": fill_list,
"tops": None,
"width": width,
"title": title,
"log_scale": log_scale,
}
self.temp_tracks.append(track)
return self
[docs]
def add_tops(
self,
property_name: str | None = None,
tops_dict: dict[float, str] | None = None,
colors: dict[float, str] | None = None,
styles: dict[float, str] | None = None,
thicknesses: dict[float, float] | None = None,
source: str | None = None,
) -> WellView:
"""
Add temporary well tops to this view (not saved to template).
Tops can be specified either from a discrete property in the well or
as a dictionary mapping depths to formation names.
Parameters
----------
property_name : str, optional
Name of discrete property in well containing tops data.
The property should have depth values where formations start.
tops_dict : dict[float, str], optional
Dictionary mapping depth values to formation names.
Example: {2850.0: 'Formation A', 2920.5: 'Formation B'}
colors : dict[float, str], optional
Optional color mapping for each depth. If provided, must have same keys
as tops_dict or match values in the discrete property.
Colors can be matplotlib color names, hex codes, or RGB tuples.
If not provided and using a discrete property with color mapping,
those colors will be used.
styles : dict[float, str], optional
Optional line style mapping for each depth or discrete value.
Valid styles: 'solid', 'dashed', 'dotted', 'dashdot'
If not provided and using a discrete property with style mapping,
those styles will be used.
thicknesses : dict[float, float], optional
Optional line thickness mapping for each depth or discrete value.
If not provided and using a discrete property with thickness mapping,
those thicknesses will be used.
source : str, optional
Source name to get property from (if property_name is specified).
Only needed if property exists in multiple sources.
Returns
-------
WellView
Self for method chaining
Examples
--------
>>> # Add tops from discrete property
>>> view = WellView(well, template=template)
>>> view.add_tops(property_name='Zone')
>>> view.show()
>>>
>>> # Add tops manually with custom colors
>>> view.add_tops(
... tops_dict={2850.0: 'Reservoir', 2920.5: 'Seal'},
... colors={2850.0: 'yellow', 2920.5: 'gray'}
... )
>>>
>>> # Add tops from discrete property, overriding colors
>>> view.add_tops(
... property_name='Formation',
... colors={0: 'red', 1: 'green', 2: 'blue'} # Map discrete values to colors
... )
Notes
-----
Tops are drawn as horizontal lines spanning all tracks (except depth track).
Formation names are displayed at the right end of each line, floating above it.
Temporary tops are not saved to the template.
"""
tops_config = {
"property_name": property_name,
"tops_dict": tops_dict,
"colors": colors,
"styles": styles,
"thicknesses": thicknesses,
"source": source,
}
self._add_tops_from_config(tops_config)
return self
def _add_tops_from_config(self, tops_config: dict) -> None:
"""
Internal method to add tops from a configuration dict.
This is used both for loading tops from templates and for adding temporary tops.
"""
property_name = tops_config.get("property_name")
tops_dict = tops_config.get("tops_dict")
colors = tops_config.get("colors")
styles = tops_config.get("styles")
thicknesses = tops_config.get("thicknesses")
source = tops_config.get("source")
if property_name is None and tops_dict is None:
raise ValueError("Must provide either 'property_name' or 'tops_dict'")
if property_name is not None and tops_dict is not None:
raise ValueError("Cannot specify both 'property_name' and 'tops_dict'")
# Get tops data as list of entries (supports multiple tops at same depth)
tops_entries = [] # List of {'depth': d, 'name': n, 'color': c, 'style': s, 'thickness': t}
if property_name is not None:
# Load from discrete property
try:
prop = self.well.get_property(property_name, source=source)
except KeyError:
available = ", ".join(self.well.properties)
raise ValueError(
f"Property '{property_name}' not found in well. "
f"Available properties: {available}"
) from None
if prop.type != "discrete":
raise ValueError(
f"Property '{property_name}' must be discrete type, got '{prop.type}'"
)
# Extract unique depths and their values
valid_mask = ~np.isnan(prop.values)
if not np.any(valid_mask):
raise ValueError(f"Property '{property_name}' has no valid data")
# Find where values change (formation boundaries)
valid_depth = prop.depth[valid_mask]
valid_values = prop.values[valid_mask]
# Get boundaries where value changes
boundaries = [0] # Start with first point
for i in range(1, len(valid_values)):
if valid_values[i] != valid_values[i - 1]:
boundaries.append(i)
# Build tops entries list
for idx in boundaries:
depth = float(valid_depth[idx])
value = int(valid_values[idx])
# Get label if available
if prop.labels and value in prop.labels:
formation_name = prop.labels[value]
else:
formation_name = f"Zone {value}"
entry = {"depth": depth, "name": formation_name}
# Get color if available (colors parameter overrides property colors)
if colors is not None and value in colors:
entry["color"] = colors[value]
elif prop.colors and value in prop.colors:
entry["color"] = prop.colors[value]
# Get style if available (styles parameter overrides property styles)
if styles is not None and value in styles:
entry["style"] = styles[value]
elif prop.styles and value in prop.styles:
entry["style"] = prop.styles[value]
# Get thickness if available (thicknesses parameter overrides property thicknesses)
if thicknesses is not None and value in thicknesses:
entry["thickness"] = thicknesses[value]
elif prop.thicknesses and value in prop.thicknesses:
entry["thickness"] = prop.thicknesses[value]
tops_entries.append(entry)
else:
# Use provided dictionary - convert to list format
for depth, name in tops_dict.items():
entry = {"depth": depth, "name": name}
if colors is not None and depth in colors:
entry["color"] = colors[depth]
if styles is not None and depth in styles:
entry["style"] = styles[depth]
if thicknesses is not None and depth in thicknesses:
entry["thickness"] = thicknesses[depth]
tops_entries.append(entry)
# Store tops for rendering
self.tops.append({"entries": tops_entries})
def _get_depth_mask(self, depth: np.ndarray) -> np.ndarray:
"""Get boolean mask for depth range."""
return (depth >= self.depth_range[0]) & (depth <= self.depth_range[1])
def _draw_cross_track_tops(self, all_tracks: list[dict]) -> None:
"""
Draw well tops that span across all tracks (except depth track).
Tops are drawn as horizontal lines with formation names displayed
at the right end, floating above the line.
Parameters
----------
all_tracks : list[dict]
Combined list of template tracks and temporary tracks
"""
# Identify which tracks are depth tracks (skip those)
non_depth_axes = []
for ax, track in zip(self.axes, all_tracks, strict=False):
if track.get("type", "continuous") != "depth":
non_depth_axes.append(ax)
if not non_depth_axes:
return # No tracks to draw tops on
# For each tops group
for tops_group in self.tops:
entries = tops_group.get("entries", [])
# Draw each top
for entry in entries:
depth = entry["depth"]
formation_name = entry["name"]
# Skip tops outside depth range
if depth < self.depth_range[0] or depth > self.depth_range[1]:
continue
# Get color, style, thickness from entry (with defaults)
color = entry.get("color", "black")
linestyle = entry.get("style", "solid")
linewidth = entry.get("thickness", 1.5)
# Draw line across all non-depth tracks
for ax in non_depth_axes:
ax.axhline(
y=depth, color=color, linestyle=linestyle, linewidth=linewidth, zorder=10
)
# Add label at the right end (on the rightmost non-depth track)
rightmost_ax = non_depth_axes[-1]
rightmost_ax.text(
1.0,
depth, # x=1.0 is at the right edge of the axes
formation_name,
transform=rightmost_ax.get_yaxis_transform(), # x in axes coords, y in data coords
ha="right",
va="bottom",
fontsize=8,
color="#272E39", # Dark grey text color
bbox={
"facecolor": "white",
"edgecolor": color,
"boxstyle": "round,pad=0.3",
"alpha": 0.9,
},
zorder=11,
clip_on=False, # Allow label to extend beyond axes
)
def _plot_continuous_track(
self, ax: plt.Axes, track: dict, depth: np.ndarray, mask: np.ndarray
) -> None:
"""
Plot continuous log track with standard well log format.
All curves are normalized to 0-1 scale for plotting, with original
scale ranges shown in the track header.
"""
logs = track.get("logs", [])
fill = track.get("fill")
track_log_scale = track.get("log_scale", False) # Track-level log scale setting
# Cache masked depth array (computed once, shared by all logs in this track)
depth_masked = depth[mask]
# Plot each log (all normalized to 0-1 scale)
plotted_curves = {}
scale_info = [] # Track scale information for header
for log_config in logs:
prop_name = log_config.get("name")
if not prop_name:
continue
try:
prop = self.well.get_property(prop_name)
except Exception as e:
warnings.warn(f"Could not get property '{prop_name}': {e}", stacklevel=2)
continue
# Check if property has its own depth array (different from reference depth)
if prop.depth is not None and len(prop.depth) != len(depth):
# Property has its own depth grid (e.g., formation tops)
# Create mask for this property's depth array
prop_mask = self._get_depth_mask(prop.depth)
values = prop.values[prop_mask]
# Use property's depth for plotting this curve
curve_depth = prop.depth[prop_mask]
else:
# Property shares the same depth grid as reference
values = prop.values[mask]
curve_depth = depth_masked
# Downsample for plotting performance (preserves curve shape)
curve_depth, values = _downsample_for_plotting(curve_depth, values, max_points=2000)
# Get x_range for normalization
if "x_range" in log_config:
x_range = log_config["x_range"]
x_min, x_max = x_range[0], x_range[1]
# Determine scale for this log: use track setting unless overridden
log_scale_override = log_config.get("scale")
if log_scale_override == "log":
log_scale = True
elif log_scale_override == "linear":
log_scale = False
else:
# Use track-level setting
log_scale = track_log_scale
# Normalize values to 0-1 based on x_range
# x_range[0] always maps to 0 (left), x_range[1] always maps to 1 (right)
if log_scale:
# Log scale normalization
# Clip values to avoid log(0) or log(negative)
values_clipped = np.clip(values, max(x_min, 1e-10), x_max)
normalized_values = (np.log10(values_clipped) - np.log10(x_min)) / (
np.log10(x_max) - np.log10(x_min)
)
else:
# Linear scale normalization (default)
# This works for both normal [20, 150] and reversed [3.95, 1.95] scales
normalized_values = (values - x_range[0]) / (x_range[1] - x_range[0])
scale_info.append(
{
"name": prop_name,
"min": x_min,
"max": x_max,
"color": log_config.get("color", "blue"),
"log_scale": log_scale,
}
)
else:
# No x_range specified, use values as-is
normalized_values = values
scale_info.append(
{
"name": prop_name,
"min": float(np.nanmin(values)),
"max": float(np.nanmax(values)),
"color": log_config.get("color", "blue"),
"log_scale": False,
}
)
# Plot styling
color = log_config.get("color", "blue")
style_raw = log_config.get("style", "-")
# Support both matplotlib codes and friendly names
style_map = {"solid": "-", "dashed": "--", "dashdot": "-.", "dotted": ":", "none": ""}
style = style_map.get(
style_raw.lower() if isinstance(style_raw, str) else style_raw, style_raw
)
thickness = log_config.get("thickness", 1.0)
alpha = log_config.get("alpha", 1.0)
# Marker configuration
marker = log_config.get("marker", None)
marker_size = log_config.get("marker_size", 6)
marker_outline_color = log_config.get("marker_outline_color", color)
marker_fill = log_config.get("marker_fill", None)
marker_interval = log_config.get("marker_interval", 1)
# Convert friendly marker names to matplotlib codes
marker_map = {
"circle": "o",
"square": "s",
"diamond": "D",
"triangle_up": "^",
"triangle_down": "v",
"triangle_left": "<",
"triangle_right": ">",
"plus": "+",
"cross": "x",
"star": "*",
"pentagon": "p",
"hexagon": "h",
"point": ".",
"pixel": ",",
"vline": "|",
"hline": "_",
}
if marker:
marker = marker_map.get(
marker.lower() if isinstance(marker, str) else marker, marker
)
# Plot normalized line (skip if style is "none" or empty)
if style and style != "":
ax.plot(
normalized_values,
curve_depth,
color=color,
linestyle=style,
linewidth=thickness,
alpha=alpha,
label=prop_name,
rasterized=True,
)
# Plot markers if specified
if marker:
# Apply marker interval (only plot every nth marker)
marker_mask = np.zeros(len(normalized_values), dtype=bool)
marker_mask[::marker_interval] = True
# Determine marker face color
if marker_fill is not None:
markerfacecolor = marker_fill
else:
markerfacecolor = "none" # Unfilled markers
ax.plot(
normalized_values[marker_mask],
curve_depth[marker_mask],
marker=marker,
markersize=marker_size,
markeredgecolor=marker_outline_color,
markerfacecolor=markerfacecolor,
linestyle="", # No connecting line for markers
alpha=alpha,
)
# Store both original and normalized values
plotted_curves[prop_name] = (values, curve_depth)
# Set x-axis to 0-1 for normalized plotting
ax.set_xlim([0, 1])
# Remove x-axis tick labels (normalized values are not meaningful to display)
ax.tick_params(axis="x", labelbottom=False)
# Handle fills (uses original values for boundary detection)
# fill is now always a list (or None)
if fill and plotted_curves:
# Apply each fill in order
for fill_config in fill:
self._add_fill_normalized(
ax, fill_config, plotted_curves, depth_masked, logs, track_log_scale
)
# Grid setup
if track_log_scale and scale_info:
# For log scale: disable standard x-grid, add custom log grid lines, keep y-grid
ax.grid(True, alpha=0.3, axis="y")
self._add_log_scale_grid(ax, scale_info[0]["min"], scale_info[0]["max"])
else:
# For linear scale: show standard grid (both x and y)
ax.grid(True, alpha=0.3)
# Add visual line indicators with outline box and track title for each curve in the header area
title_text = track.get("title", "")
self._add_curve_indicators(ax, scale_info, logs, title_text)
def _add_log_scale_grid(self, ax: plt.Axes, x_min: float, x_max: float) -> None:
"""
Add vertical grid lines for log scale.
For a range like 1-100, shows: 1,2,3,4,5,6,7,8,9,10,20,30,40,50,60,70,80,90,100
"""
# Generate log scale grid positions
grid_values = []
# Determine the order of magnitude range
log_min = np.floor(np.log10(x_min))
log_max = np.ceil(np.log10(x_max))
# For each decade, add 1,2,3,4,5,6,7,8,9 * 10^n
for decade_exp in range(int(log_min), int(log_max) + 1):
decade = 10**decade_exp
for multiplier in range(1, 10):
value = multiplier * decade
if x_min <= value <= x_max:
grid_values.append(value)
# Also add the next power of 10 if it's within range
next_decade = 10 ** (decade_exp + 1)
if x_min <= next_decade <= x_max and next_decade not in grid_values:
grid_values.append(next_decade)
# Normalize grid values to 0-1 using log scale
for value in grid_values:
normalized_x = (np.log10(value) - np.log10(x_min)) / (np.log10(x_max) - np.log10(x_min))
ax.axvline(x=normalized_x, color="gray", linestyle="-", linewidth=0.5, alpha=0.4)
def _add_curve_indicators(
self, ax: plt.Axes, scale_info: list[dict], logs: list[dict], title: str
) -> None:
"""
Add visual line indicators with scale values in an outlined box.
Format for each curve:
LogName
3.95 ---------------- 1.95
All curves are enclosed in a box with outline, with track title above.
Curves stack from bottom up; if they don't fit, they clip outside the box.
"""
if not scale_info:
return
# Limit to first 4 logs for simplicity
scale_info = scale_info[:4]
# Use instance configuration for spacing
box_top = self.header_box_top
title_spacing = self.header_title_spacing
log_spacing = self.header_log_spacing
bottom_padding = self.header_bottom_padding
# Box dimensions (fixed height between top and 1.0)
box_bottom = 1.0 # Bottom aligns with plot area top
box_height = box_top - box_bottom
# Draw outline box around all indicators (full width to match plot area)
box = Rectangle(
(0, box_bottom),
1.0,
box_height,
transform=ax.get_xaxis_transform(),
fill=False,
edgecolor="black",
linewidth=1.0,
clip_on=False,
zorder=9,
)
ax.add_patch(box)
# Add track title above the box
if title:
title_y = box_top + 0.005 # Small gap above box
ax.text(
0.5,
title_y,
title,
transform=ax.get_xaxis_transform(),
ha="center",
va="bottom",
fontsize=10,
fontweight="bold",
clip_on=False,
zorder=11,
)
# Draw each curve indicator (stacked from bottom up)
for idx, info in enumerate(scale_info):
# Find matching log config to get style
log_config = next((log for log in logs if log.get("name") == info["name"]), None)
if log_config:
color = log_config.get("color", "blue")
style_raw = log_config.get("style", "-")
# Support both matplotlib codes and friendly names
style_map = {
"solid": "-",
"dashed": "--",
"dashdot": "-.",
"dotted": ":",
"none": "",
}
style = style_map.get(
style_raw.lower() if isinstance(style_raw, str) else style_raw, style_raw
)
thickness = log_config.get("thickness", 1.0)
# Marker configuration (for legend display)
marker = log_config.get("marker", None)
marker_size = log_config.get("marker_size", 6)
marker_outline_color = log_config.get("marker_outline_color", color)
marker_fill = log_config.get("marker_fill", None)
# Convert friendly marker names to matplotlib codes
marker_map = {
"circle": "o",
"square": "s",
"diamond": "D",
"triangle_up": "^",
"triangle_down": "v",
"triangle_left": "<",
"triangle_right": ">",
"plus": "+",
"cross": "x",
"star": "*",
"pentagon": "p",
"hexagon": "h",
"point": ".",
"pixel": ",",
"vline": "|",
"hline": "_",
}
if marker:
marker = marker_map.get(
marker.lower() if isinstance(marker, str) else marker, marker
)
# Calculate y positions for this curve (stack from bottom up)
# First curve (idx=0) starts from bottom of box + padding
# Subsequent curves stack above
base_y = box_bottom + bottom_padding + (idx * log_spacing)
# Position for scale line (at base)
scale_y = base_y
# Position for log name (above the scale line)
name_y = base_y + title_spacing
# Add log name text centered
ax.text(
0.5,
name_y,
info["name"],
transform=ax.get_xaxis_transform(),
ha="center",
va="bottom",
fontsize=8,
fontweight="bold",
clip_on=False,
zorder=11,
)
# Draw horizontal line between 0.15 and 0.85 (leaving room for scale values)
# Only draw line if style is not "none" or empty
if style and style != "":
ax.plot(
[0.15, 0.85],
[scale_y, scale_y],
color=color,
linestyle=style,
linewidth=thickness,
transform=ax.get_xaxis_transform(),
clip_on=False,
zorder=10,
)
# Draw markers in legend if specified
# Place two markers: one halfway between edge and center, one halfway between center and other edge
if marker:
# Determine marker face color
if marker_fill is not None:
markerfacecolor = marker_fill
else:
markerfacecolor = "none" # Unfilled markers
# First marker position: halfway between left edge (0.15) and center (0.5)
marker_x1 = 0.15 + (0.5 - 0.15) / 2 # = 0.325
# Second marker position: halfway between center (0.5) and right edge (0.85)
marker_x2 = 0.5 + (0.85 - 0.5) / 2 # = 0.675
ax.plot(
[marker_x1, marker_x2],
[scale_y, scale_y],
marker=marker,
markersize=marker_size,
markeredgecolor=marker_outline_color,
markerfacecolor=markerfacecolor,
linestyle="", # No connecting line
transform=ax.get_xaxis_transform(),
clip_on=False,
zorder=11,
)
# Get scale values
min_val = info["min"]
max_val = info["max"]
# Add min value text on left side of line (with white background)
ax.text(
0.05,
scale_y,
f"{min_val:.2f}",
transform=ax.get_xaxis_transform(),
ha="left",
va="center",
fontsize=7,
color=color,
clip_on=False,
zorder=11,
bbox={
"facecolor": "white",
"edgecolor": "none",
"boxstyle": "round,pad=0.3",
"alpha": 1.0,
},
)
# Add max value text on right side of line (with white background)
ax.text(
0.95,
scale_y,
f"{max_val:.2f}",
transform=ax.get_xaxis_transform(),
ha="right",
va="center",
fontsize=7,
color=color,
clip_on=False,
zorder=11,
bbox={
"facecolor": "white",
"edgecolor": "none",
"boxstyle": "round,pad=0.3",
"alpha": 1.0,
},
)
def _add_discrete_legend(self, ax: plt.Axes, legend_info: list[dict], title: str) -> None:
"""
Add discrete legend in an outlined box above the plot area.
Parameters
----------
ax : plt.Axes
The axes to add the legend to
legend_info : list[dict]
List of dicts with 'label' and 'color' keys
title : str
Track title
"""
if not legend_info:
return
# Limit to first 4 items for simplicity
legend_info = legend_info[:4]
# Use instance configuration (aligned with continuous tracks)
box_top = self.header_box_top
item_height = 0.022 # Height for each legend item (compact)
bottom_padding = self.header_bottom_padding
# Box dimensions (fixed height between top and 1.0)
box_bottom = 1.0 # Bottom aligns with plot area top
box_height = box_top - box_bottom
# Draw outline box (full width to match plot area)
box = Rectangle(
(0, box_bottom),
1.0,
box_height,
transform=ax.get_xaxis_transform(),
fill=False,
edgecolor="black",
linewidth=1.0,
clip_on=False,
zorder=9,
)
ax.add_patch(box)
# Add track title above the box
if title:
title_y = box_top + 0.005 # Small gap above box
ax.text(
0.5,
title_y,
title,
transform=ax.get_xaxis_transform(),
ha="center",
va="bottom",
fontsize=10,
fontweight="bold",
clip_on=False,
zorder=11,
)
# Draw each legend item (stacked from bottom up)
for idx, item in enumerate(legend_info):
# Calculate y position (stack from bottom up)
# First item (idx=0) starts from bottom of box + padding
item_y = box_bottom + bottom_padding + (idx * item_height) + (item_height / 2)
# Draw colored rectangle as background (full width)
color_rect = Rectangle(
(0.05, item_y - item_height / 2),
0.9,
item_height * 0.85,
transform=ax.get_xaxis_transform(),
facecolor=item["color"],
edgecolor="none",
alpha=0.7,
clip_on=False,
zorder=10,
)
ax.add_patch(color_rect)
# Add label text (centered on colored background, black font)
ax.text(
0.5,
item_y,
item["label"],
transform=ax.get_xaxis_transform(),
ha="center",
va="center",
fontsize=8,
fontweight="bold",
color="black",
clip_on=False,
zorder=11,
)
@staticmethod
def _normalize_boundary_spec(spec, side: str) -> dict:
"""Convert simple string/number fill boundary spec to dict format."""
if isinstance(spec, dict):
return spec
elif isinstance(spec, str):
if spec == "track_edge":
return {"track_edge": side}
else:
return {"curve": spec}
elif isinstance(spec, (int, float)):
return {"value": spec}
else:
return {}
@staticmethod
def _normalize_value(value, x_range, log_scale=False):
"""Normalize a value to 0-1 based on x_range and scale."""
if x_range is None:
return value
x_min, x_max = x_range[0], x_range[1]
if log_scale:
value_clipped = np.clip(value, max(x_min, 1e-10), x_max)
return (np.log10(value_clipped) - np.log10(x_min)) / (np.log10(x_max) - np.log10(x_min))
else:
if x_min < x_max:
return (value - x_min) / (x_max - x_min)
else:
return (value - x_max) / (x_min - x_max)
@staticmethod
def _get_curve_info(curve_name: str, logs: list[dict], track_log_scale: bool):
"""Get x_range and effective log_scale for a named curve."""
for log in logs:
if log.get("name") == curve_name and "x_range" in log:
scale_override = log.get("scale")
if scale_override == "log":
log_scale = True
elif scale_override == "linear":
log_scale = False
else:
log_scale = track_log_scale
return log["x_range"], log_scale
return None, False
def _resolve_fill_boundary(
self,
spec: dict,
plotted_curves: dict,
logs: list[dict],
track_log_scale: bool,
n_points: int,
side: str,
) -> np.ndarray | None:
"""
Resolve a fill boundary specification to normalized values.
Returns
-------
np.ndarray or None
Normalized boundary values (0-1 scale), or None if spec is invalid.
"""
if "curve" in spec:
curve_name = spec["curve"]
if curve_name not in plotted_curves:
warnings.warn(f"Fill {side} curve '{curve_name}' not found", stacklevel=3)
return None
values, _ = plotted_curves[curve_name]
x_range, log_scale = self._get_curve_info(curve_name, logs, track_log_scale)
if x_range:
if log_scale:
values_clipped = np.clip(values, max(x_range[0], 1e-10), x_range[1])
return (np.log10(values_clipped) - np.log10(x_range[0])) / (
np.log10(x_range[1]) - np.log10(x_range[0])
)
else:
return (values - x_range[0]) / (x_range[1] - x_range[0])
return values
elif "value" in spec:
fixed_val = spec["value"]
if logs and "x_range" in logs[0]:
scale_override = logs[0].get("scale")
if scale_override == "log":
log_scale = True
elif scale_override == "linear":
log_scale = False
else:
log_scale = track_log_scale
return np.full(
n_points, self._normalize_value(fixed_val, logs[0]["x_range"], log_scale)
)
return np.full(n_points, fixed_val)
elif "track_edge" in spec:
edge_val = 0.0 if spec["track_edge"] == "left" else 1.0
return np.full(n_points, edge_val)
else:
warnings.warn(f"Fill {side} boundary not properly specified", stacklevel=3)
return None
def _render_colormap_fill(
self,
ax: plt.Axes,
left_values: np.ndarray,
right_values: np.ndarray,
depth_for_fill: np.ndarray,
fill: dict,
plotted_curves: dict,
left_spec: dict,
right_spec: dict,
boundary_valid_mask: np.ndarray,
track_log_scale: bool,
fill_alpha: float,
) -> None:
"""Render colormap-based fill using adaptive binning and PolyCollection."""
n_points = len(depth_for_fill)
cmap_name = fill["colormap"]
# Determine which curve drives the colormap
colormap_curve_name = fill.get("colormap_curve")
colormap_values = None
if colormap_curve_name and colormap_curve_name in plotted_curves:
colormap_values, _ = plotted_curves[colormap_curve_name]
elif colormap_curve_name:
warnings.warn(
f"Colormap curve '{colormap_curve_name}' not found, using boundary curves",
stacklevel=2,
)
if colormap_values is None:
if "curve" in left_spec and left_spec["curve"] in plotted_curves:
colormap_values, _ = plotted_curves[left_spec["curve"]]
elif "curve" in right_spec and right_spec["curve"] in plotted_curves:
colormap_values, _ = plotted_curves[right_spec["curve"]]
else:
warnings.warn(
"Cannot determine colormap values (no curve specified for left or right)",
stacklevel=2,
)
return
# Apply same boundary mask to colormap values
if not np.all(boundary_valid_mask):
colormap_values = colormap_values[boundary_valid_mask]
valid_mask = ~np.isnan(colormap_values)
if not np.any(valid_mask):
warnings.warn(
"Colormap curve has no valid (non-NaN) values in the current depth range. "
"Skipping fill.",
stacklevel=2,
)
return
color_range = fill.get(
"color_range", [np.nanmin(colormap_values), np.nanmax(colormap_values)]
)
color_log = fill.get("color_log", track_log_scale)
if color_log:
vmin = max(color_range[0], 1e-10)
vmax = max(color_range[1], vmin * 10)
norm = LogNorm(vmin=vmin, vmax=vmax)
else:
norm = Normalize(vmin=color_range[0], vmax=color_range[1])
cmap = plt.get_cmap(cmap_name)
n_intervals = n_points - 1
color_values = (colormap_values[:-1] + colormap_values[1:]) / 2
colors = cmap(norm(color_values))
target_polygons = 300
binned_verts = []
binned_colors = []
if n_intervals > 0 and n_intervals > target_polygons:
color_diffs = np.sqrt(np.sum((colors[1:, :3] - colors[:-1, :3]) ** 2, axis=1))
target_percentile = 100 * (1 - target_polygons / n_intervals)
color_threshold = max(np.percentile(color_diffs, target_percentile), 0.01)
bin_start_idx = 0
bin_color = colors[0]
for i in range(1, n_intervals):
if color_diffs[i - 1] > color_threshold:
poly_verts = []
for j in range(bin_start_idx, i + 1):
poly_verts.append((left_values[j], depth_for_fill[j]))
for j in range(i, bin_start_idx - 1, -1):
poly_verts.append((right_values[j], depth_for_fill[j]))
binned_verts.append(poly_verts)
binned_colors.append(bin_color)
bin_start_idx = i
bin_color = colors[i]
# Close final bin
poly_verts = []
for j in range(bin_start_idx, n_intervals + 1):
poly_verts.append((left_values[j], depth_for_fill[j]))
for j in range(n_intervals, bin_start_idx - 1, -1):
poly_verts.append((right_values[j], depth_for_fill[j]))
binned_verts.append(poly_verts)
binned_colors.append(bin_color)
else:
for i in range(n_intervals):
binned_verts.append(
[
(left_values[i], depth_for_fill[i]),
(right_values[i], depth_for_fill[i]),
(right_values[i + 1], depth_for_fill[i + 1]),
(left_values[i + 1], depth_for_fill[i + 1]),
]
)
binned_colors.append(colors[i])
poly_collection = PolyCollection(
binned_verts,
facecolors=binned_colors,
alpha=fill_alpha,
edgecolors="none",
linewidths=0,
)
ax.add_collection(poly_collection)
def _add_fill_normalized(
self,
ax: plt.Axes,
fill: dict,
plotted_curves: dict,
depth: np.ndarray,
logs: list[dict],
track_log_scale: bool,
) -> None:
"""
Add fill between curves with normalized coordinates.
This version handles fills when curves are normalized to 0-1 scale.
Boundary specifications support simple string/number values or dict format:
- "track_edge": Use track edge on that side (left=0.0, right=1.0)
- "<curve_name>": Use curve values
- <number>: Use fixed value
- {"curve": "<name>"}: Use curve (dict format)
- {"value": <num>}: Use fixed value (dict format)
- {"track_edge": "left"|"right"}: Use track edge (dict format)
"""
if not plotted_curves:
return
first_curve_data = next(iter(plotted_curves.values()))
_, depth_for_fill = first_curve_data
n_points = len(depth_for_fill)
left_spec = self._normalize_boundary_spec(fill.get("left", {}), "left")
right_spec = self._normalize_boundary_spec(fill.get("right", {}), "right")
left_values = self._resolve_fill_boundary(
left_spec, plotted_curves, logs, track_log_scale, n_points, "left"
)
if left_values is None:
return
right_values = self._resolve_fill_boundary(
right_spec, plotted_curves, logs, track_log_scale, n_points, "right"
)
if right_values is None:
return
# Handle crossover - collapse fill where left is to the right of right
crossover_mask = left_values > right_values
left_values = np.where(crossover_mask, right_values, left_values)
# Create valid mask - skip points where boundary values are NaN
boundary_valid_mask = ~(np.isnan(left_values) | np.isnan(right_values))
if not np.all(boundary_valid_mask):
left_values = left_values[boundary_valid_mask]
right_values = right_values[boundary_valid_mask]
depth_for_fill = depth_for_fill[boundary_valid_mask]
n_points = len(depth_for_fill)
if n_points < 2:
return
fill_color = fill.get("color", "lightblue")
fill_alpha = fill.get("alpha", 0.3)
if "colormap" in fill:
self._render_colormap_fill(
ax,
left_values,
right_values,
depth_for_fill,
fill,
plotted_curves,
left_spec,
right_spec,
boundary_valid_mask,
track_log_scale,
fill_alpha,
)
else:
ax.fill_betweenx(
depth_for_fill,
left_values,
right_values,
color=fill_color,
alpha=fill_alpha,
rasterized=True,
)
def _add_fill(
self,
ax: plt.Axes,
fill: dict,
plotted_curves: dict,
depth: np.ndarray,
track_log_scale: bool = False,
) -> None:
"""
Add fill between curves or values.
For colormap fills, creates horizontal bands where each depth interval
is colored based on a curve value (e.g., GR from 20-150 maps to colormap).
Boundary specifications support simple string/number values or dict format:
- "track_edge": Use track edge on that side
- "<curve_name>": Use curve values
- <number>: Use fixed value
- {"curve": "<name>"}: Use curve (dict format)
- {"value": <num>}: Use fixed value (dict format)
- {"track_edge": "left"|"right"}: Use track edge (dict format)
"""
# Helper to normalize boundary spec to dict format
def normalize_boundary_spec(spec, side):
"""Convert simple string/number spec to dict format."""
if isinstance(spec, dict):
return spec
elif isinstance(spec, str):
if spec == "track_edge":
return {"track_edge": side}
else:
# Assume it's a curve name
return {"curve": spec}
elif isinstance(spec, (int, float)):
return {"value": spec}
else:
return {}
# Normalize boundary specs (support both simple and dict formats)
left_raw = fill.get("left", {})
right_raw = fill.get("right", {})
left_spec = normalize_boundary_spec(left_raw, "left")
right_spec = normalize_boundary_spec(right_raw, "right")
# Get left boundary
if "curve" in left_spec:
curve_name = left_spec["curve"]
if curve_name in plotted_curves:
left_values, _ = plotted_curves[curve_name]
else:
warnings.warn(f"Fill left curve '{curve_name}' not found", stacklevel=2)
return
elif "value" in left_spec:
left_values = np.full_like(depth, left_spec["value"])
elif "track_edge" in left_spec:
left_values = np.full_like(depth, ax.get_xlim()[0])
else:
warnings.warn("Fill left boundary not properly specified", stacklevel=2)
return
# Get right boundary
if "curve" in right_spec:
curve_name = right_spec["curve"]
if curve_name in plotted_curves:
right_values, _ = plotted_curves[curve_name]
else:
warnings.warn(f"Fill right curve '{curve_name}' not found", stacklevel=2)
return
elif "value" in right_spec:
right_values = np.full_like(depth, right_spec["value"])
elif "track_edge" in right_spec:
right_values = np.full_like(depth, ax.get_xlim()[1])
else:
warnings.warn("Fill right boundary not properly specified", stacklevel=2)
return
# Handle crossover - collapse fill where left is to the right of right
# This prevents fill from appearing when curves cross over
crossover_mask = left_values > right_values
left_values = np.where(crossover_mask, right_values, left_values)
# Create valid mask - skip points where boundary values are NaN
boundary_valid_mask = ~(np.isnan(left_values) | np.isnan(right_values))
# Filter arrays to only valid points
depth_for_fill = depth # Use local variable for consistency
if not np.all(boundary_valid_mask):
left_values = left_values[boundary_valid_mask]
right_values = right_values[boundary_valid_mask]
depth_for_fill = depth[boundary_valid_mask]
if len(depth_for_fill) < 2:
# Not enough valid points to draw fill
return
# Apply fill
fill_color = fill.get("color", "lightblue")
fill_alpha = fill.get("alpha", 0.3)
if "colormap" in fill:
# Use colormap for fill - creates horizontal bands colored by curve value
cmap_name = fill["colormap"]
# Determine which curve drives the colormap
# Can be explicitly specified, or defaults to boundary curves
colormap_curve_name = fill.get("colormap_curve")
if colormap_curve_name:
if colormap_curve_name in plotted_curves:
colormap_values, _ = plotted_curves[colormap_curve_name]
else:
warnings.warn(
f"Colormap curve '{colormap_curve_name}' not found, using boundary curves",
stacklevel=2,
)
# Try left boundary curve first, then right boundary curve
if "curve" in left_spec and left_spec["curve"] in plotted_curves:
colormap_values, _ = plotted_curves[left_spec["curve"]]
elif "curve" in right_spec and right_spec["curve"] in plotted_curves:
colormap_values, _ = plotted_curves[right_spec["curve"]]
else:
warnings.warn("Cannot determine colormap values", stacklevel=2)
return
else:
# Default: use left boundary curve if available, otherwise right boundary curve
if "curve" in left_spec and left_spec["curve"] in plotted_curves:
colormap_values, _ = plotted_curves[left_spec["curve"]]
elif "curve" in right_spec and right_spec["curve"] in plotted_curves:
colormap_values, _ = plotted_curves[right_spec["curve"]]
else:
warnings.warn(
"Cannot determine colormap values (no curve specified for left or right)",
stacklevel=2,
)
return
# Apply same boundary mask to colormap values
if not np.all(boundary_valid_mask):
colormap_values = colormap_values[boundary_valid_mask]
# Get color range for normalization
# Check if we have valid values
valid_mask = ~np.isnan(colormap_values)
if not np.any(valid_mask):
warnings.warn(
"Colormap curve has no valid (non-NaN) values in the current depth range. Skipping fill.",
stacklevel=2,
)
return
color_range = fill.get(
"color_range", [np.nanmin(colormap_values), np.nanmax(colormap_values)]
)
# Default color_log to track's log_scale setting
color_log = fill.get("color_log", track_log_scale)
# Use LogNorm for log scale colormap, Normalize for linear
if color_log:
# Ensure positive values for log scale
vmin = max(color_range[0], 1e-10)
vmax = max(color_range[1], vmin * 10)
norm = LogNorm(vmin=vmin, vmax=vmax)
else:
norm = Normalize(vmin=color_range[0], vmax=color_range[1])
cmap = plt.get_cmap(cmap_name)
# Create horizontal bands - each depth interval gets a color based on the curve value
# Use PolyCollection for performance (1000x faster than loop with fill_betweenx)
n_intervals = len(depth_for_fill) - 1
# Compute color values for each interval (average of adjacent points)
color_values = (colormap_values[:-1] + colormap_values[1:]) / 2
colors = cmap(norm(color_values))
# Create polygon vertices for each depth interval
# Each polygon is a quad: [(left, depth_i), (right, depth_i), (right, depth_i+1), (left, depth_i+1)]
verts = []
for i in range(n_intervals):
verts.append(
[
(left_values[i], depth_for_fill[i]),
(right_values[i], depth_for_fill[i]),
(right_values[i + 1], depth_for_fill[i + 1]),
(left_values[i + 1], depth_for_fill[i + 1]),
]
)
# Create PolyCollection with all polygons at once
poly_collection = PolyCollection(
verts, facecolors=colors, alpha=fill_alpha, edgecolors="none", linewidths=0
)
ax.add_collection(poly_collection)
else:
# Simple solid color fill
ax.fill_betweenx(
depth_for_fill,
left_values,
right_values,
color=fill_color,
alpha=fill_alpha,
rasterized=True,
)
def _plot_discrete_track(
self, ax: plt.Axes, track: dict, depth: np.ndarray, mask: np.ndarray
) -> None:
"""Plot discrete/categorical track."""
logs = track.get("logs", [])
if not logs:
return
# Get property (only first one for discrete tracks)
prop_name = logs[0].get("name")
if not prop_name:
return
try:
prop = self.well.get_property(prop_name)
except Exception as e:
warnings.warn(f"Could not get property '{prop_name}': {e}", stacklevel=2)
return
# For discrete data, we need ALL depth/value pairs (not masked) to properly
# determine which zone is active at the depth range boundaries
if prop.depth is not None and len(prop.depth) != len(depth):
# Property has its own depth grid (e.g., formation tops)
all_depths = prop.depth
all_values = prop.values
else:
# Property shares the same depth grid as reference
all_depths = depth
all_values = prop.values
# Get depth range for clipping
depth_range_top = self.depth_range[0]
depth_range_bottom = self.depth_range[1]
# Round to integers (discrete values must be integers)
all_values = np.where(np.isnan(all_values), np.nan, np.round(all_values))
# Sort by depth
sorted_indices = np.argsort(all_depths)
sorted_depths = all_depths[sorted_indices]
sorted_values = all_values[sorted_indices]
# Get unique values for color mapping (NaN values filtered out)
unique_vals = np.unique(sorted_values[~np.isnan(sorted_values)]).astype(int)
# Create color mapping - check property colors first, then fall back to defaults
if prop.colors:
# Use property's custom colors for values that have them
color_map = {}
for val in unique_vals:
if val in prop.colors:
color_map[val] = prop.colors[val]
else:
# Fall back to default color if this value doesn't have a custom color
default_idx = list(unique_vals).index(val) % len(DEFAULT_COLORS)
color_map[val] = DEFAULT_COLORS[default_idx]
else:
# No custom colors defined, use defaults
colors = DEFAULT_COLORS[: len(unique_vals)]
color_map = dict(zip(unique_vals, colors, strict=False))
# For discrete data, the value at depth[i] represents the zone from depth[i] to depth[i+1]
# Build segments, then clip to depth range
if len(sorted_depths) > 0:
segments = [] # List of (depth_start, depth_end, value) tuples
current_val = None
segment_start = None
for i in range(len(sorted_depths)):
val = sorted_values[i]
if np.isnan(val):
# End current segment if we hit NaN
if segment_start is not None:
segments.append((segment_start, sorted_depths[i], current_val))
segment_start = None
current_val = None
else:
val = int(val)
if val != current_val:
# Value changed - end current segment and start new one
if segment_start is not None:
segments.append((segment_start, sorted_depths[i], current_val))
segment_start = sorted_depths[i]
current_val = val
# else: value same as current, keep extending segment
# Close the last segment (extends beyond the data to bottom of well)
if segment_start is not None:
# Last segment extends to infinity - we'll use a very large depth
segments.append((segment_start, np.inf, current_val))
# Draw segments, clipping to depth range
for depth_start, depth_end, val in segments:
# Check if segment overlaps with depth range
if depth_end < depth_range_top or depth_start > depth_range_bottom:
continue # Segment outside range
# Clip segment to depth range
clipped_start = max(depth_start, depth_range_top)
clipped_end = min(depth_end, depth_range_bottom)
ax.fill_betweenx(
[clipped_start, clipped_end],
0,
1,
color=color_map.get(val, DEFAULT_COLORS[0]),
alpha=0.7,
rasterized=True,
)
# Configure axes
ax.set_xlim([0, 1])
ax.set_xticks([])
ax.grid(True, alpha=0.3)
# Add legend header above plot area (similar to continuous tracks)
title_text = track.get("title", "")
legend_info = []
for val in unique_vals:
label = prop.labels.get(int(val), str(int(val))) if prop.labels else str(int(val))
legend_info.append({"label": label, "color": color_map[val]})
self._add_discrete_legend(ax, legend_info, title_text)
def _plot_depth_track(
self, ax: plt.Axes, track: dict, depth: np.ndarray, mask: np.ndarray
) -> None:
"""Plot depth axis track."""
depth_masked = depth[mask]
# Plot depth as vertical line
ax.plot([0.5, 0.5], [depth_masked.min(), depth_masked.max()], "k-", linewidth=0.5)
# Configure
ax.set_xlim([0, 1])
ax.set_xticks([])
ax.set_ylabel("Depth (m)", fontsize=10, fontweight="bold")
ax.grid(True, alpha=0.3, axis="y")
if track.get("title"):
ax.set_title(track["title"], fontsize=10, fontweight="bold")
def _add_tops(
self, ax: plt.Axes, tops_config: dict, depth: np.ndarray, mask: np.ndarray
) -> None:
"""Add formation tops markers to track."""
prop_name = tops_config.get("name")
if not prop_name:
return
try:
tops_prop = self.well.get_property(prop_name)
except Exception as e:
warnings.warn(f"Could not get tops property '{prop_name}': {e}", stacklevel=2)
return
# Cache masked depth array
tops_depth = depth[mask]
# Get tops values
tops_values = tops_prop.values[mask]
# Find discrete boundaries (changes in value)
changes = np.where(np.diff(tops_values) != 0)[0]
# Plot styling
line_style = tops_config.get("line_style", "--")
line_width = tops_config.get("line_width", 1.0)
title_size = tops_config.get("title_size", 8)
title_weight = tops_config.get("title_weight", "normal")
title_orientation = tops_config.get("title_orientation", "right")
line_offset = tops_config.get("line_offset", 0.0)
# Draw lines and labels at changes
xlim = ax.get_xlim()
x_range = xlim[1] - xlim[0]
for idx in changes:
depth_val = tops_depth[idx]
# Draw horizontal line
ax.axhline(
y=depth_val, color="black", linestyle=line_style, linewidth=line_width, alpha=0.7
)
# Add label if tops have labels
if tops_prop.labels:
next_val = tops_values[idx + 1] if idx + 1 < len(tops_values) else tops_values[idx]
label = tops_prop.labels.get(int(next_val), "")
if label:
# Determine text position
if title_orientation == "left":
x_pos = xlim[0] + 0.05 * x_range + line_offset
ha = "left"
elif title_orientation == "center":
x_pos = (xlim[0] + xlim[1]) / 2 + line_offset
ha = "center"
else: # right
x_pos = xlim[1] - 0.05 * x_range + line_offset
ha = "right"
ax.text(
x_pos,
depth_val,
label,
fontsize=title_size,
fontweight=title_weight,
ha=ha,
va="bottom",
)
[docs]
def plot(self) -> None:
"""
Create the well log plot.
This method generates the matplotlib figure with all configured tracks.
Call show() or save() after this to display or save the figure.
Examples
--------
>>> view = WellView(well, template=template)
>>> view.plot()
>>> view.show()
"""
# Get reference depth grid
first_prop_name = self.well.properties[0].split(".")[0]
first_prop = self.well.get_property(first_prop_name)
depth = first_prop.depth
mask = self._get_depth_mask(depth)
# Combine template tracks with temporary tracks
all_tracks = self.template.tracks + self.temp_tracks
# Create figure with subplots
n_tracks = len(all_tracks)
widths = [track.get("width", 1.0) for track in all_tracks]
self.fig, self.axes = plt.subplots(
1,
n_tracks,
figsize=self.figsize,
dpi=self.dpi,
gridspec_kw={"width_ratios": widths, "wspace": 0},
sharey=True,
)
# Handle single track case
if n_tracks == 1:
self.axes = [self.axes]
# Plot each track
for ax, track in zip(self.axes, all_tracks, strict=False):
track_type = track.get("type", "continuous")
if track_type == "continuous":
self._plot_continuous_track(ax, track, depth, mask)
elif track_type == "discrete":
self._plot_discrete_track(ax, track, depth, mask)
elif track_type == "depth":
self._plot_depth_track(ax, track, depth, mask)
# Add tops if configured
tops_config = track.get("tops")
if tops_config:
self._add_tops(ax, tops_config, depth, mask)
# Remove y-labels for all but first track
if ax != self.axes[0]:
ax.set_ylabel("")
# Draw cross-track tops (span all tracks except depth track)
if self.tops:
self._draw_cross_track_tops(all_tracks)
# Invert y-axis once for all tracks (depth increases downward)
# Since sharey=True, this applies to all axes
self.axes[0].invert_yaxis()
# Set exact y-axis limits to match depth_range without padding
# Since sharey=True, this applies to all axes
self.axes[0].set_ylim(self.depth_range[1], self.depth_range[0])
self.axes[0].margins(y=0)
# Set main title
self.fig.suptitle(f"Well: {self.well.name}", fontsize=12, fontweight="bold", y=0.995)
# Apply tight layout (suppress warnings from PolyCollection incompatibility)
try:
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore", message=".*not compatible with tight_layout.*", category=UserWarning
)
plt.tight_layout()
except Exception:
# If tight_layout fails, continue without it
pass
[docs]
def show(self) -> None:
"""
Display the well log plot in Jupyter notebook.
This will render the plot inline in Jupyter Lab/Notebook.
Examples
--------
>>> view = WellView(well, template=template)
>>> view.show()
"""
if self.fig is None:
self.plot()
plt.show()
[docs]
def save(
self, filepath: str | Path, dpi: int | None = None, bbox_inches: str = "tight"
) -> None:
"""
Save the well log plot to file.
Parameters
----------
filepath : Union[str, Path]
Output file path (format determined by extension: .png, .pdf, .svg, etc.).
dpi : int, optional
Resolution for raster formats. If None, uses the figure's dpi.
bbox_inches : str, default 'tight'
Bounding box specification for the saved figure.
Examples
--------
>>> view = WellView(well, template=template)
>>> view.save("well_log.png", dpi=300)
>>> view.save("well_log.pdf")
"""
if self.fig is None:
self.plot()
if dpi is None:
dpi = self.dpi
self.fig.savefig(filepath, dpi=dpi, bbox_inches=bbox_inches)
[docs]
def close(self) -> None:
"""
Close the matplotlib figure and free memory.
Examples
--------
>>> view = WellView(well, template=template)
>>> view.show()
>>> view.close()
"""
if self.fig is not None:
plt.close(self.fig)
self.fig = None
self.axes = []
def __repr__(self) -> str:
"""String representation."""
total_tracks = len(self.template.tracks) + len(self.temp_tracks)
track_info = f"tracks={total_tracks}"
if self.temp_tracks:
track_info += f" ({len(self.template.tracks)} template + {len(self.temp_tracks)} temp)"
return (
f"WellView(well='{self.well.name}', "
f"depth_range={self.depth_range}, "
f"{track_info})"
)