Source code for materforge.visualization.plotters

# SPDX-FileCopyrightText: 2025 - 2026 Rahil Miten Doshi, Friedrich-Alexander-Universität Erlangen-Nürnberg
# SPDX-FileCopyrightText: 2026 Matthias Markl, Friedrich-Alexander-Universität Erlangen-Nürnberg
# SPDX-License-Identifier: BSD-3-Clause

import logging
from datetime import datetime
from typing import Optional, Union
import matplotlib.pyplot as plt
import numpy as np
import sympy as sp
from matplotlib.gridspec import GridSpec
from materforge.core.materials import Material
from materforge.algorithms.regression_processor import RegressionProcessor
from materforge.parsing.config.yaml_keys import CONSTANT_KEY, NAME_KEY, POST_KEY, PRE_KEY
from materforge.data.constants import ProcessingConstants

logger = logging.getLogger(__name__)


[docs] class PropertyVisualizer: """Handles visualization of material properties.""" # --- Constructor --- def __init__(self, parser) -> None: self.parser = parser self.fig = None self.gs = None self.current_subplot = 0 self.plot_directory = self.parser.base_dir / "materforge_plots" self.visualized_properties = set() self.is_enabled = True self.setup_style() logger.debug("PropertyVisualizer initialized for: %s", parser.config_path)
[docs] @staticmethod def setup_style() -> None: """Applies global matplotlib style settings.""" plt.rcParams.update({ 'font.size': 10, 'font.family': 'sans-serif', 'font.sans-serif': ['DejaVu Sans', 'Arial', 'Helvetica', 'Liberation Sans'], 'axes.titlesize': 12, 'axes.labelsize': 10, 'xtick.labelsize': 9, 'ytick.labelsize': 9, 'legend.fontsize': 9, 'figure.titlesize': 14, 'axes.grid': True, 'grid.alpha': 0.3, 'grid.linestyle': '--', 'axes.axisbelow': True, 'figure.facecolor': 'white', 'axes.facecolor': 'white', 'savefig.facecolor': 'white', 'savefig.edgecolor': 'none', 'savefig.dpi': 300, 'figure.autolayout': True, })
# --- Public API ---
[docs] def is_visualization_enabled(self) -> bool: """Returns True if visualization is active and a figure exists.""" return self.is_enabled and self.fig is not None
[docs] def initialize_plots(self) -> None: """Initialises the figure and grid layout for all properties.""" if not self.is_enabled: logger.debug("Visualization disabled - skipping plot initialization") return if self.parser.categorized_properties is None: raise ValueError("No properties to plot.") property_count = sum(len(props) for props in self.parser.categorized_properties.values()) logger.info("Initializing visualization for %d properties", property_count) fig_width = 12 fig_height = max(4 * property_count, 4) # Minimum height for readability self.fig = plt.figure(figsize=(fig_width, fig_height)) self.gs = GridSpec(property_count, 1, figure=self.fig, ) self.current_subplot = 0 self.plot_directory.mkdir(exist_ok=True) logger.debug("Plot directory ready: %s", self.plot_directory)
[docs] def reset_visualization_tracking(self) -> None: """Clears the set of already-visualized properties.""" logger.debug("Resetting visualization tracking - clearing %d tracked properties", len(self.visualized_properties)) self.visualized_properties = set()
[docs] def visualize_property( self, material: Material, prop_name: str, dependency: Union[float, sp.Symbol], prop_type: str, x_data: Optional[np.ndarray] = None, y_data: Optional[np.ndarray] = None, has_regression: bool = False, simplify_type: Optional[str] = None, degree: int = 1, segments: int = 1, lower_bound: Optional[float] = None, upper_bound: Optional[float] = None, lower_bound_type: str = CONSTANT_KEY, upper_bound_type: str = CONSTANT_KEY,) -> None: """Visualizes a single material property on the next available subplot.""" if prop_name in self.visualized_properties: logger.debug("Property %r already visualized - skipping", prop_name) return if self.fig is None: logger.warning("No figure available for %r - visualization skipped", prop_name) return if not isinstance(dependency, sp.Symbol): logger.debug("Numeric dependency for %r - visualization skipped", prop_name) return logger.info("Visualizing %r (%s) for material %r", prop_name, prop_type, material.name) try: ax = self.fig.add_subplot(self.gs[self.current_subplot]) self.current_subplot += 1 ax.set_aspect('auto') ax.grid(True, linestyle='--', alpha=0.3) ax.set_axisbelow(True) for spine in ax.spines.values(): spine.set_color('#CCCCCC') spine.set_linewidth(1.2) current_prop = getattr(material, prop_name) # Build dependency evaluation range if x_data is not None and len(x_data) > 0: data_lower, data_upper = np.min(x_data), np.max(x_data) step = (data_upper - data_lower) / 1000 else: data_lower = ProcessingConstants.DEFAULT_DEPENDENCY_LOWER data_upper = ProcessingConstants.DEFAULT_DEPENDENCY_UPPER step = (data_upper - data_lower) / 1000 logger.debug("Using default dependency range: %.1f-%.1f", data_lower, data_upper) if lower_bound is None: lower_bound = data_lower if upper_bound is None: upper_bound = data_upper padding = (upper_bound - lower_bound) * ProcessingConstants.DEPENDENCY_PADDING_FACTOR padded_lower = lower_bound - padding padded_upper = upper_bound + padding num_points = int(np.ceil((padded_upper - padded_lower) / step)) + 1 extended_dep = np.linspace(padded_lower, padded_upper, num_points) ax.set_title(f"{prop_name} ({prop_type})", fontsize=14, fontweight="bold", pad=15) ax.set_xlabel(str(dependency), fontsize=12, fontweight="bold") ax.set_ylabel(prop_name, fontsize=12, fontweight="bold") colors = { 'constant': '#1f77b4', # blue 'raw': '#ff7f0e', # orange 'regression_pre': '#2ca02c', # green 'regression_post': '#d62728', # red 'bounds': '#9467bd', # purple 'extended': '#8c564b', # brown } _y_value = 0.0 if prop_type == 'CONSTANT_VALUE': value = float(current_prop) ax.axhline(y=value, color=colors['constant'], linestyle='-', linewidth=2.5, label='constant', alpha=0.8) ax.text(0.5, 0.9, f"Value: {value:.3e}", transform=ax.transAxes, horizontalalignment="center", fontweight="bold", bbox=dict(facecolor='white', alpha=0.8, boxstyle='round,pad=0.5', edgecolor=colors['constant'])) ax.set_ylim(value * 0.9, value * 1.1) y_range = ax.get_ylim() offset = (y_range[1] - y_range[0]) * 0.1 _y_value = value + offset logger.debug("Plotted constant %r: %g", prop_name, value) elif prop_type == 'STEP_FUNCTION': try: f_current = sp.lambdify(dependency, current_prop, 'numpy') y_extended = f_current(extended_dep) ax.plot(extended_dep, y_extended, color=colors['extended'], linestyle='-', linewidth=2.5, label='extended behavior', zorder=1, alpha=0.6) if x_data is not None and y_data is not None: ax.plot(x_data, y_data, color=colors['raw'], linestyle='-', linewidth=2.5, marker="o", markersize=6, label='step function', zorder=3, alpha=0.8) transition_idx = len(x_data) // 2 transition_point = x_data[transition_idx] ax.axvline(x=transition_point, color='red', linestyle='--', alpha=0.7, linewidth=2, label='transition point') ax.text(transition_point, y_data[0], f" Before: {y_data[0]:.2e}", verticalalignment="bottom", horizontalalignment="left", fontweight="bold", bbox=dict(facecolor='white', alpha=0.8, boxstyle='round,pad=0.3')) ax.text(transition_point, y_data[-1], f" After: {y_data[-1]:.2e}", verticalalignment="top", horizontalalignment="left", fontweight="bold", bbox=dict(facecolor='white', alpha=0.8, boxstyle='round,pad=0.3')) _y_value = float(np.mean(y_data)) logger.debug("Step %r - transition at %.1f", prop_name, transition_point) else: _y_value = float(f_current(lower_bound)) except Exception as e: logger.warning("Could not evaluate step function %r: %s", prop_name, e) else: try: f_current = sp.lambdify(dependency, current_prop, 'numpy') if has_regression and simplify_type == PRE_KEY: main_color = colors['regression_pre'] main_label = 'regression (pre)' else: main_color = colors['extended'] main_label = 'raw (extended)' try: y_extended = f_current(extended_dep) ax.plot(extended_dep, y_extended, color=main_color, linestyle='-', linewidth=2.5, label=main_label, zorder=2, alpha=0.8) except Exception as e: logger.warning("Could not evaluate extended range for %r: %s", prop_name, e) if x_data is not None and y_data is not None: ax.plot(x_data, y_data, color=colors['raw'], linestyle='-', linewidth=2, label='data points', zorder=2) if y_data is not None and len(y_data) > 0: _y_value = float(np.percentile(y_data, 25)) else: try: midpoint = (lower_bound + upper_bound) / 2 _y_value = float(f_current(midpoint)) except (ValueError, TypeError, AttributeError) as e: logger.error("Could not evaluate midpoint for %r: %s", prop_name, e) _y_value = 0.0 if has_regression and simplify_type == POST_KEY and x_data is not None and y_data is not None: try: preview_pw = RegressionProcessor.process_regression( dep_array=x_data, prop_array=y_data, dependency=dependency, lower_bound_type=lower_bound_type, upper_bound_type=upper_bound_type, degree=degree, segments=segments, seed=ProcessingConstants.DEFAULT_REGRESSION_SEED ) f_preview = sp.lambdify(dependency, preview_pw, 'numpy') ax.plot(extended_dep, f_preview(extended_dep), color=colors['regression_post'], linestyle='--', linewidth=2.5, label='regression (post)', zorder=4, alpha=0.8) except Exception as e: logger.warning("Post-regression preview failed for %r: %s", prop_name, e) except Exception as e: logger.error("Error creating function for %r: %s", prop_name, e) ax.text(0.5, 0.5, f"Error: {str(e)}", transform=ax.transAxes, horizontalalignment="center", fontweight="bold", bbox=dict(facecolor='red', alpha=0.2)) # --- Boundary annotations --- ax.axvline(x=lower_bound, color=colors['bounds'], linestyle='--', alpha=0.6, linewidth=1.5, label="_nolegend_") ax.axvline(x=upper_bound, color=colors['bounds'], linestyle='--', alpha=0.6, linewidth=1.5, label="_nolegend_") if _y_value is None or not np.isfinite(_y_value): try: if hasattr(current_prop, 'subs') and hasattr(current_prop, 'evalf'): _y_value = float(current_prop.subs(dependency, lower_bound).evalf()) elif hasattr(current_prop, '__float__'): _y_value = float(current_prop) else: _y_value = 0.0 except (ValueError, TypeError, AttributeError): _y_value = 0.0 logger.warning("Could not determine y_value for annotations of %r", prop_name) ax.text(lower_bound, _y_value, f" {lower_bound_type}", verticalalignment="top", horizontalalignment="right", fontweight="bold", bbox=dict(facecolor='white', alpha=0.8, boxstyle='round,pad=0.3', edgecolor=colors['bounds'])) ax.text(upper_bound, _y_value, f" {upper_bound_type}", verticalalignment="top", horizontalalignment="left", fontweight="bold", bbox=dict(facecolor='white', alpha=0.8, boxstyle='round,pad=0.3', edgecolor=colors['bounds'])) if has_regression and degree is not None: ax.text(0.5, 0.98, f"Simplify: {simplify_type} | Degree: {degree} | Segments: {segments}", transform=ax.transAxes, horizontalalignment="center", fontweight="bold", bbox=dict(facecolor='lightblue', alpha=0.8, boxstyle='round,pad=0.3')) handles, labels = ax.get_legend_handles_labels() if handles: legend = ax.legend(handles, labels, loc='best', framealpha=0.9, fancybox=True, shadow=True, edgecolor="gray") legend.get_frame().set_linewidth(1.2) self.visualized_properties.add(prop_name) logger.info("Successfully visualized %r", prop_name) except Exception as e: logger.error("Unexpected error visualizing %r: %s", prop_name, e, exc_info=True) raise ValueError(f"Unexpected error visualizing {prop_name!r}: {e}") from e
[docs] def save_property_plots(self) -> None: """Saves the composed property figure to disk and closes it.""" if not self.is_enabled or self.fig is None: logger.debug("No plots to save - visualization disabled or no figure") return try: material_name = self.parser.config[NAME_KEY] self.fig.suptitle(f"Material Properties: {material_name}", fontsize=16, fontweight="bold", y=0.98) try: plt.tight_layout(rect=[0, 0.01, 1, 0.98], pad=1.0) except Exception as e: logger.warning("tight_layout failed: %s - using subplots_adjust", e) plt.subplots_adjust(left=0.08, bottom=0.08, right=0.92, top=0.88, hspace=0.8) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"{material_name.replace(' ', '_')}_properties_{timestamp}.png" filepath = self.plot_directory / filename self.fig.savefig(str(filepath), dpi=300, bbox_inches='tight', facecolor='white', edgecolor='none', pad_inches=0.4) total = sum(len(p) for p in self.parser.categorized_properties.values()) visualized = len(self.visualized_properties) if visualized != total: logger.warning("Not all properties visualized - %d/%d", visualized, total) else: logger.info("All %d properties visualized successfully", total) logger.info("Property plots saved: %s", filepath) finally: if self.fig is not None: plt.close(self.fig) self.fig = None logger.debug("Figure closed and memory freed")