Source code for materforge.visualization.plotters

import logging
from typing import Optional, Union

import matplotlib.pyplot as plt
import numpy as np
import sympy as sp
from matplotlib.gridspec import GridSpec
from datetime import datetime

from materforge.core.materials import Material
from materforge.algorithms.regression_processor import RegressionProcessor
from materforge.parsing.config.yaml_keys import CONSTANT_KEY, PRE_KEY, POST_KEY, NAME_KEY, MATERIAL_TYPE_KEY
from materforge.data.constants import PhysicalConstants, 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 yaml_dir = self.parser.base_dir self.plot_directory = yaml_dir / "materforge_plots" self.visualized_properties = set() self.is_enabled = True self.setup_style() logger.debug("PropertyVisualizer initialized for parser: %s", parser.config_path)
[docs] @staticmethod def setup_style() -> None: 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 })
[docs] def is_visualization_enabled(self) -> bool: """Check if visualization is currently enabled.""" return self.is_enabled and self.fig is not None
# --- Public API Methods ---
[docs] def initialize_plots(self) -> None: """Initialize plots only if visualization is enabled.""" if not self.is_enabled: logger.debug("Visualization disabled, skipping plot initialization") return if self.parser.categorized_properties is None: logger.error("categorized_properties is None - cannot initialize plots") 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 created: %s", self.plot_directory)
[docs] def reset_visualization_tracking(self) -> None: 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, T: 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: """Visualize a single property.""" if prop_name in self.visualized_properties: logger.debug("Property '%s' already visualized, skipping", prop_name) return if not hasattr(self, 'fig') or self.fig is None: logger.warning("No figure available for property '%s' - visualization skipped", prop_name) return if not isinstance(T, sp.Symbol): logger.debug("Temperature is not symbolic for property '%s' - visualization skipped", prop_name) return logger.info("Visualizing property: %s (type: %s) for material: %s", prop_name, prop_type, material.name) try: # Create subplot ax = self.fig.add_subplot(self.gs[self.current_subplot]) self.current_subplot += 1 ax.set_aspect('auto') # Grid and border styling ax.grid(True, linestyle='--', alpha=0.3) ax.set_axisbelow(True) # Border styling for spine in ax.spines.values(): spine.set_color('#CCCCCC') spine.set_linewidth(1.2) # Get property and prepare temperature array current_prop = getattr(material, prop_name) if x_data is not None and len(x_data) > 0: # Use property's own temperature range data_lower, data_upper = np.min(x_data), np.max(x_data) temp_range = data_upper - data_lower step = temp_range / 1000 # Create 1000 points for smooth visualization else: # Fallback for properties without explicit temperature data data_lower, data_upper = (ProcessingConstants.DEFAULT_TEMP_LOWER, ProcessingConstants.DEFAULT_TEMP_UPPER) step = (data_upper - data_lower) / 1000 logger.debug("Using data temperature range: %.1f - %.1f K", data_lower, data_upper) # Set bounds with property-specific defaults if lower_bound is None: lower_bound = data_lower if upper_bound is None: upper_bound = data_upper logger.debug("Using default temperature range: %.1f - %.1f K", data_lower, data_upper) # Create extended temperature range for visualization padding = (upper_bound - lower_bound) * ProcessingConstants.TEMPERATURE_PADDING_FACTOR ABSOLUTE_ZERO = PhysicalConstants.ABSOLUTE_ZERO padded_lower = max(lower_bound - padding, ABSOLUTE_ZERO) padded_upper = upper_bound + padding num_points = int(np.ceil((padded_upper - padded_lower) / step)) + 1 extended_temp = np.linspace(padded_lower, padded_upper, num_points) # Title and labels ax.set_title(f"{prop_name} ({prop_type})", fontsize=14, fontweight='bold', pad=15) ax.set_xlabel("Temperature", fontsize=12, fontweight='bold') ax.set_ylabel(f"{prop_name}", fontsize=12, fontweight='bold') # Color scheme colors = { 'constant': '#1f77b4', # blue 'raw': '#ff7f0e', # orange 'regression_pre': '#2ca02c', # green 'regression_post': '#d62728', # red 'bounds': '#9467bd', # purple 'extended': '#8c564b', # brown } # Initialize y_value for annotations _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) # Annotation 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) # Add small offset to avoid overlap with horizontal line y_range = ax.get_ylim() offset = (y_range[1] - y_range[0]) * 0.1 _y_value = value + offset logger.debug("Plotted constant property '%s' with value: %g", prop_name, value) elif prop_type == 'STEP_FUNCTION': try: f_current = sp.lambdify(T, current_prop, 'numpy') # Always plot the extended behavior first (background) y_extended = f_current(extended_temp) ax.plot(extended_temp, y_extended, color=colors['extended'], linestyle='-', linewidth=2.5, label='extended behavior', zorder=1, alpha=0.6) # Overlay data points if available (foreground) 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) # Add vertical line at transition point transition_idx = len(x_data) // 2 transition_temp = x_data[transition_idx] ax.axvline(x=transition_temp, color='red', linestyle='--', alpha=0.7, linewidth=2, label='transition point') # Annotations ax.text(transition_temp, 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_temp, 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 = np.mean(y_data) logger.debug("Plotted step function '%s' with transition at %.1f K", prop_name, transition_temp) else: # No data points available, use function evaluation _y_value = f_current(lower_bound) except Exception as e: logger.warning("Could not evaluate step function '%s': %s", prop_name, e) _y_value = 0.0 else: # Handle all other property types (FILE_IMPORT, TABULAR_DATA, PIECEWISE_EQUATION, COMPUTED_PROPERTY) try: f_current = sp.lambdify(T, current_prop, 'numpy') # Determine the appropriate label and color based on regression status 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: # Plot the main function over extended range y_extended = f_current(extended_temp) ax.plot(extended_temp, y_extended, color=main_color, linestyle='-', linewidth=2.5, label=main_label, zorder=2, alpha=0.8) logger.debug("Plotted extended range for property '%s'", prop_name) except Exception as e: logger.warning("Could not evaluate function over extended range for '%s': %s", prop_name, e) # Fallback to data range if available 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) # Plot data points if available (for FILE, KEY_VAL properties) if x_data is not None and y_data is not None and prop_type in ['FILE_IMPORT', 'TABULAR_DATA',]: # marker_size = 6 if prop_type == 'KEY_VAL' else 3 # ax.scatter(x_data, y_data, color=colors['raw'], marker='o', s=marker_size**2, # alpha=0.7, label='data points', zorder=3) pass # Set y_value for boundary annotations to avoid overlap if y_data is not None and len(y_data) > 0: # Use 25th percentile instead of max to avoid high regions _y_value = np.percentile(y_data, 25) else: try: # Use midpoint instead of upper_bound midpoint = (lower_bound + upper_bound) / 2 _y_value = f_current(midpoint) except (ValueError, TypeError, AttributeError) as e: logger.error(f"Could not evaluate function at midpoint for '%s': %s", prop_name, e) _y_value = 0.0 # Post-regression overlay 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( temp_array=x_data, prop_array=y_data, T=T, 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(T, preview_pw, 'numpy') y_preview = f_preview(extended_temp) ax.plot(extended_temp, y_preview, color=colors['regression_post'], linestyle='--', linewidth=2.5, label='regression (post)', zorder=4, alpha=0.8) logger.debug("Added post-regression preview for property '%s'", prop_name) except Exception as e: logger.warning("Could not generate post-regression preview for '%s': %s", prop_name, e) except Exception as e: logger.error("Error creating function for property '%s': %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)) _y_value = 0.0 # Add boundary lines and 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_') # Ensure _y_value is valid for annotations 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(T, lower_bound).evalf()) else: _y_value = float(current_prop) if hasattr(current_prop, '__float__') else 0.0 except (ValueError, TypeError, AttributeError): _y_value = 0.0 logger.warning("Could not determine y_value for annotations for property '%s'", prop_name) # Add boundary type annotations 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'])) # Add regression info 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')) # Add legend only if there are artists with labels handles, labels = ax.get_legend_handles_labels() if handles and labels: legend = ax.legend(handles, labels, loc='best', framealpha=0.9, fancybox=True, shadow=True, edgecolor='gray') legend.get_frame().set_linewidth(1.2) else: logger.debug("No artists with labels found for property '%s' - skipping legend", prop_name) # Add property to visualized set self.visualized_properties.add(prop_name) logger.info("Successfully visualized property: %s", prop_name) except Exception as e: logger.error("Unexpected error visualizing property '%s': %s", prop_name, e, exc_info=True) raise ValueError(f"Unexpected error in property {prop_name}: {e}")
[docs] def save_property_plots(self) -> None: """Save plots only if visualization is enabled and plots exist.""" if not self.is_enabled or not hasattr(self, 'fig') or self.fig is None: logger.debug("No plots to save - visualization disabled or no plots created") return try: if hasattr(self, 'fig') and self.fig is not None: material_type = self.parser.config[MATERIAL_TYPE_KEY] title = f"Material Properties: {self.parser.config[NAME_KEY]} ({material_type})" self.fig.suptitle(title, 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 as fallback", e) plt.subplots_adjust( left=0.08, # Left margin bottom=0.08, # Bottom margin right=0.92, # Right margin top=0.88, # Top margin (leave space for subtitle) hspace=0.8 # Height spacing between subplots ) material_name = self.parser.config[NAME_KEY].replace(' ', '_') timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"{material_name}_properties_{timestamp}.png" filepath = self.plot_directory / filename # Save settings self.fig.savefig( str(filepath), dpi=300, # High resolution bbox_inches="tight", # Cropping facecolor='white', # Clean background edgecolor='none', # No border pad_inches=0.4 # Padding ) total_properties = sum(len(props) for props in self.parser.categorized_properties.values()) visualized_count = len(self.visualized_properties) if visualized_count != total_properties: logger.warning( f"Not all properties visualized! " f"Visualized: {visualized_count}, " f"Total: {total_properties}" ) else: logger.info(f"All properties ({total_properties}) visualized successfully.") logger.info(f"All property plots saved as {filepath}") finally: # Always close the figure to prevent memory leaks if hasattr(self, 'fig') and self.fig is not None: plt.close(self.fig) self.fig = None logger.debug("Figure closed and memory cleaned up")