Source code for materforge.core.materials

import logging
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple, Union

import numpy as np
import sympy as sp

from materforge.core.elements import (ChemicalElement,
                                    interpolate_atomic_mass,
                                    interpolate_atomic_number)
from materforge.core.exceptions import MaterialCompositionError, MaterialTemperatureError

logger = logging.getLogger(__name__)


[docs] @dataclass class Material: """ Represents a material with its composition and properties. This class supports both pure metals and alloys with comprehensive temperature validation and property calculation. """ # Basic material information name: str material_type: str # 'pure_metal' or 'alloy' elements: List[ChemicalElement] composition: Union[np.ndarray, List[float], Tuple] # List of fractions summing to 1.0 # Temperature properties for pure metals melting_temperature: Optional[sp.Float] = None boiling_temperature: Optional[sp.Float] = None # Temperature properties for alloys solidus_temperature: Optional[sp.Float] = None liquidus_temperature: Optional[sp.Float] = None initial_boiling_temperature: Optional[sp.Float] = None final_boiling_temperature: Optional[sp.Float] = None # Optional properties with default values bulk_modulus: Optional[sp.Float] = None density: sp.Expr = None dynamic_viscosity: sp.Expr = None elastic_modulus: Optional[sp.Expr] = None # Young's modulus electrical_conductivity: Optional[sp.Expr] = None electrical_resistivity: Optional[sp.Expr] = None energy_density: Optional[Union[sp.Piecewise, sp.Expr]] = None energy_density_solidus: sp.Float = None energy_density_liquidus: sp.Float = None fracture_toughness: Optional[sp.Expr] = None hardness: Optional[sp.Expr] = None heat_capacity: sp.Expr = None heat_conductivity: sp.Expr = None kinematic_viscosity: sp.Expr = None latent_heat_of_fusion: sp.Expr = None latent_heat_of_vaporization: sp.Expr = None magnetic_permeability: Optional[sp.Expr] = None melting_point_pressure: Optional[sp.Float] = None poisson_ratio: Optional[sp.Float] = None shear_modulus: Optional[sp.Expr] = None specific_enthalpy: sp.Expr = None surface_tension: sp.Expr = None thermal_diffusivity: sp.Expr = None thermal_expansion_coefficient: sp.Expr = None ultimate_tensile_strength: Optional[sp.Expr] = None viscosity: sp.Expr = None yield_strength: Optional[sp.Expr] = None # Calculated properties (set during initialization) atomic_number: Optional[float] = field(default=None, init=False) atomic_mass: Optional[float] = field(default=None, init=False) # Pure metal ranges (based on periodic table data) MIN_MELTING_TEMP = 302.0 # Cesium (lowest solid metal at room conditions) MAX_MELTING_TEMP = 3695.0 # Tungsten (highest melting point) MIN_BOILING_TEMP = 630.0 # Mercury (lowest, but practical metals ~1000K) MAX_BOILING_TEMP = 6203.0 # Tungsten (highest boiling point) # Alloy ranges (engineering alloys have wider practical ranges) MIN_SOLIDUS_TEMP = 250.0 # Low-temperature solders (Bi-based alloys) MAX_SOLIDUS_TEMP = 2000.0 # High-temperature superalloys MIN_LIQUIDUS_TEMP = 300.0 # Low-melting alloys MAX_LIQUIDUS_TEMP = 2200.0 # Refractory alloys (Mo-Re, W-Re systems) # Extend to include other material properties as needed def __post_init__(self) -> None: """ Initialize and validate the material properties. Called automatically after the dataclass initialization. Validates composition and temperatures, then calculates derived properties. """ logger.info("Initializing material: %s (type: %s)", self.name, self.material_type) self._validate_composition() self._validate_temperatures() self._calculate_properties() def _validate_composition(self) -> None: """Validate the material composition.""" logger.debug("Validating composition for material: %s", self.name) if not self.elements: raise ValueError("Elements list cannot be empty") if len(self.elements) != len(self.composition): raise ValueError( f"Number of elements ({len(self.elements)}) must match composition length ({len(self.composition)})") if not np.isclose(sum(self.composition), 1.0, atol=1e-10): raise MaterialCompositionError(f"The sum of the composition array must be 1.0, got {sum(self.composition)}") if self.material_type == 'pure_metal' and len(self.elements) != 1: raise MaterialCompositionError(f"Pure metals must have exactly 1 element, got {len(self.elements)}") if self.material_type == 'alloy' and len(self.elements) < 2: raise MaterialCompositionError(f"Alloys must have at least 2 elements, got {len(self.elements)}") def _validate_temperatures(self) -> None: """Validate temperature properties based on material type.""" logger.debug(f"Starting temperature validation for {self.material_type}: {self.name}") try: if self.material_type == 'pure_metal': self._validate_pure_metal_temperatures() elif self.material_type == 'alloy': self._validate_alloy_temperatures() else: available_types = ['pure_metal', 'alloy',] # Extend with other types as needed raise MaterialTemperatureError(f"Unknown material type: {self.material_type}. " f"Supported types: {available_types}") logger.debug(f"Temperature validation passed for {self.material_type}: {self.name}") except MaterialTemperatureError as e: logger.error(f"Temperature validation failed for {self.name}: {e}") raise def _validate_pure_metal_temperatures(self) -> None: """Validate temperature properties for pure metals.""" # Check required temperatures are present if self.melting_temperature is None: raise MaterialTemperatureError("Pure metals must specify melting_temperature") if self.boiling_temperature is None: raise MaterialTemperatureError("Pure metals must specify boiling_temperature") # Validate temperature types if not isinstance(self.melting_temperature, sp.Float): raise MaterialTemperatureError( f"melting_temperature must be a SymPy Float, got {type(self.melting_temperature).__name__}" ) if not isinstance(self.boiling_temperature, sp.Float): raise MaterialTemperatureError( f"boiling_temperature must be a SymPy Float, got {type(self.boiling_temperature).__name__}" ) # Validate temperature ranges self._validate_temperature_value( self.melting_temperature, "melting_temperature", self.MIN_MELTING_TEMP, self.MAX_MELTING_TEMP ) self._validate_temperature_value( self.boiling_temperature, "boiling_temperature", self.MIN_BOILING_TEMP, self.MAX_BOILING_TEMP ) # Validate temperature relationships if float(self.melting_temperature) >= float(self.boiling_temperature): raise MaterialTemperatureError( f"melting_temperature ({float(self.melting_temperature)}K) must be less than " f"boiling_temperature ({float(self.boiling_temperature)}K)" ) def _validate_alloy_temperatures(self) -> None: """Validate temperature properties for alloys.""" # Check required temperatures are present required_temps = [ ('solidus_temperature', self.solidus_temperature), ('liquidus_temperature', self.liquidus_temperature), ('initial_boiling_temperature', self.initial_boiling_temperature), ('final_boiling_temperature', self.final_boiling_temperature) ] missing_temps = [name for name, temp in required_temps if temp is None] if missing_temps: raise MaterialTemperatureError( f"Alloys must specify all temperature properties. Missing: {', '.join(missing_temps)}" ) # Validate temperature types temp_checks = [ ('solidus_temperature', self.solidus_temperature), ('liquidus_temperature', self.liquidus_temperature), ('initial_boiling_temperature', self.initial_boiling_temperature), ('final_boiling_temperature', self.final_boiling_temperature) ] for temp_name, temp_val in temp_checks: if temp_val is not None and not isinstance(temp_val, sp.Float): raise MaterialTemperatureError( f"{temp_name} must be a SymPy Float, got {type(temp_val).__name__}" ) # Validate temperature ranges self._validate_temperature_value( self.solidus_temperature, "solidus_temperature", self.MIN_SOLIDUS_TEMP, self.MAX_SOLIDUS_TEMP ) self._validate_temperature_value( self.liquidus_temperature, "liquidus_temperature", self.MIN_LIQUIDUS_TEMP, self.MAX_LIQUIDUS_TEMP ) self._validate_temperature_value( self.initial_boiling_temperature, "initial_boiling_temperature", self.MIN_BOILING_TEMP, self.MAX_BOILING_TEMP ) self._validate_temperature_value( self.final_boiling_temperature, "final_boiling_temperature", self.MIN_BOILING_TEMP, self.MAX_BOILING_TEMP ) # Validate temperature relationships # Solidus <= Liquidus if float(self.solidus_temperature) > float(self.liquidus_temperature): raise MaterialTemperatureError( f"solidus_temperature ({float(self.solidus_temperature)}K) must be less than or equal to " f"liquidus_temperature ({float(self.liquidus_temperature)}K)" ) # Initial boiling <= Final boiling if float(self.initial_boiling_temperature) > float(self.final_boiling_temperature): raise MaterialTemperatureError( f"initial_boiling_temperature ({float(self.initial_boiling_temperature)}K) must be " f"less than or equal to final_boiling_temperature ({float(self.final_boiling_temperature)}K)" ) # Liquidus < Initial boiling if float(self.liquidus_temperature) >= float(self.initial_boiling_temperature): raise MaterialTemperatureError( f"liquidus_temperature ({float(self.liquidus_temperature)}K) must be less than " f"initial_boiling_temperature ({float(self.initial_boiling_temperature)}K)" ) @staticmethod def _validate_temperature_value(temperature: Union[float, sp.Float], temp_name: str, min_temp: Optional[float] = None, max_temp: Optional[float] = None) -> None: """Validate a single temperature value.""" if temperature is None: raise MaterialTemperatureError(f"{temp_name} cannot be None") # Convert to float for validation temp_val = float(temperature) from materforge.data.constants import PhysicalConstants # Check absolute zero if temp_val <= PhysicalConstants.ABSOLUTE_ZERO: raise MaterialTemperatureError( f"{temp_name} must be above absolute zero, got {temp_val}K" ) # Check range if specified if min_temp is not None and temp_val < min_temp: raise MaterialTemperatureError( f"{temp_name} {temp_val}K is below minimum allowed value ({min_temp}K)" ) if max_temp is not None and temp_val > max_temp: raise MaterialTemperatureError( f"{temp_name} {temp_val}K is above maximum allowed value ({max_temp}K)" ) def _calculate_properties(self) -> None: """Calculate interpolated atomic properties based on composition.""" logger.debug("Calculating interpolated properties for %s", self.name) if self.material_type == 'pure_metal': # For pure metals, use the single element's properties element = self.elements[0] self.atomic_number = float(element.atomic_number) self.atomic_mass = float(element.atomic_mass) logger.debug("Pure metal properties - atomic_number: %.1f, atomic_mass: %.3f", self.atomic_number, self.atomic_mass) elif self.material_type == 'alloy': # For alloys, calculate weighted averages self.atomic_number = interpolate_atomic_number(self.elements, self.composition) self.atomic_mass = interpolate_atomic_mass(self.elements, self.composition) logger.debug("Alloy properties - atomic_number: %.3f, atomic_mass: %.3f", self.atomic_number, self.atomic_mass) else: available_types = ['pure_metal', 'alloy',] # Extend with other types as needed raise ValueError(f"Unknown material type: {self.material_type}. " f"Supported types: {available_types}")
[docs] def solidification_interval(self) -> Tuple[sp.Float, sp.Float]: """ Get the solidification interval for alloys. Returns: Tuple of (solidus_temperature, liquidus_temperature) Raises: ValueError: If called on a pure metal """ if self.material_type != 'alloy': raise ValueError("Solidification interval is only applicable to alloys") return self.solidus_temperature, self.liquidus_temperature
[docs] def melting_point(self) -> sp.Float: """ Get the melting point. For pure metals, returns melting_temperature. For alloys, returns solidus_temperature. Returns: Melting point temperature """ if self.material_type == 'pure_metal': return self.melting_temperature else: return self.solidus_temperature
[docs] def boiling_point(self) -> sp.Float: """ Get the boiling point. For pure metals, returns boiling_temperature. For alloys, returns initial_boiling_temperature. Returns: Boiling point temperature """ if self.material_type == 'pure_metal': return self.boiling_temperature else: return self.initial_boiling_temperature
def __str__(self) -> str: """String representation of the material.""" element_names = [element.name for element in self.elements] if self.material_type == 'pure_metal': return f"Pure Metal: {self.name} ({element_names[0]})" else: composition_str = ", ".join([f"{elem}: {comp:.3f}" for elem, comp in zip(element_names, self.composition)]) return f"Alloy: {self.name} ({composition_str})" def __repr__(self) -> str: """Detailed representation of the material.""" return (f"Material(name='{self.name}', material_type='{self.material_type}', " f"elements={len(self.elements)}, composition={self.composition})")
[docs] def evaluate_properties_at_temperature(self, temperature: Union[float, int], properties: Optional[List[str]] = None, include_constants: bool = True) -> Dict[str, float]: """ Evaluate all or specified material properties at a given temperature. Args: temperature: Temperature value in Kelvin properties: List of specific property names to evaluate. If None, evaluates all properties. include_constants: Whether to include constant properties in the result Returns: Dictionary mapping property names to their evaluated values Examples: # Evaluate all properties values = material.evaluate_properties_at_temperature(500.0) # Evaluate specific properties values = material.evaluate_properties_at_temperature(500.0, ['density', 'heat_capacity']) # Exclude constants values = material.evaluate_properties_at_temperature(500.0, include_constants=False) """ logger.info("Evaluating properties at T=%.1f K for material: %s", temperature, self.name) # Validate temperature if not isinstance(temperature, (int, float)): raise ValueError(f"Temperature must be numeric, got {type(temperature).__name__}") if temperature <= 0: raise ValueError(f"Temperature must be positive, got {temperature}") # Get all property attributes all_properties = { 'density': self.density, 'dynamic_viscosity': self.dynamic_viscosity, 'energy_density': self.energy_density, 'heat_capacity': self.heat_capacity, 'heat_conductivity': self.heat_conductivity, 'kinematic_viscosity': self.kinematic_viscosity, 'latent_heat_of_fusion': self.latent_heat_of_fusion, 'latent_heat_of_vaporization': self.latent_heat_of_vaporization, 'specific_enthalpy': self.specific_enthalpy, 'surface_tension': self.surface_tension, 'thermal_diffusivity': self.thermal_diffusivity, 'thermal_expansion_coefficient': self.thermal_expansion_coefficient } # Filter to only properties that exist (not None) existing_properties = {name: prop for name, prop in all_properties.items() if prop is not None} # Filter to requested properties if specified if properties is not None: if not isinstance(properties, list): raise ValueError("Properties must be a list of strings") invalid_props = set(properties) - set(existing_properties.keys()) if invalid_props: available = list(existing_properties.keys()) raise ValueError(f"Invalid properties: {invalid_props}. Available: {available}") existing_properties = {name: prop for name, prop in existing_properties.items() if name in properties} logger.debug("Evaluating %d properties: %s", len(existing_properties), list(existing_properties.keys())) results = {} # Find the temperature symbol used in this material temp_symbol = None for prop_name, prop_value in existing_properties.items(): if hasattr(prop_value, 'free_symbols') and prop_value.free_symbols: temp_symbol = list(prop_value.free_symbols)[0] break if temp_symbol is None: logger.debug("No symbolic temperature found, assuming all properties are constants") else: logger.debug("Using temperature symbol: %s", temp_symbol) for prop_name, prop_value in existing_properties.items(): try: if hasattr(prop_value, 'free_symbols') and prop_value.free_symbols: # Symbolic property - substitute and evaluate if temp_symbol: evaluated = prop_value.subs(temp_symbol, temperature) # Handle any remaining symbolic expressions if hasattr(evaluated, 'evalf'): result = float(evaluated.evalf()) else: result = float(evaluated) else: result = float(prop_value) results[prop_name] = result elif isinstance(prop_value, (sp.Float, sp.Integer)): # SymPy numeric constant if include_constants: results[prop_name] = float(prop_value) elif isinstance(prop_value, (int, float)): # Python numeric constant if include_constants: results[prop_name] = float(prop_value) else: logger.warning("Unknown property type for '%s': %s", prop_name, type(prop_value)) except Exception as e: logger.error("Failed to evaluate property '%s': %s", prop_name, e) # Continue with other properties instead of failing completely results[prop_name] = None # Remove None values results = {k: v for k, v in results.items() if v is not None} logger.info("Successfully evaluated %d properties at T=%.1f K", len(results), temperature) return results