Source code for materforge.core.materials

# 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

from __future__ import annotations

import logging
from dataclasses import dataclass, field
from typing import Any, Dict

import sympy as sp

logger = logging.getLogger(__name__)


[docs] @dataclass class Material: """Generic material container with fully dynamic property tracking. All material properties are assigned dynamically via setattr and tracked automatically. Attributes: name: Human-readable material identifier. properties: Dictionary with all properties. """ name: str properties: Dict[str, sp.Basic] = field(default_factory=dict) # --- Dynamic property tracking --- def __setattr__(self, name: str, value) -> None: if name in {"name", "properties"}: super().__setattr__(name, value) else: self.properties[name] = value def __getattr__(self, name: str) -> Any: # Only fires when normal lookup in properties fails if name in self.properties: return self.properties[name] raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
[docs] def property_names(self) -> set: """Returns all dynamically assigned property names.""" return set(self.properties.keys())
# --- Property evaluation ---
[docs] def evaluate(self, symbol: sp.Symbol, value) -> Material: """Evaluates all properties by substituting symbol=value. Args: symbol: SymPy symbol to substitute (e.g. sp.Symbol('T')). value: Value to substitute. Returns: New Material instance with name '{name}@{symbol}={value}' and all properties substituted at the given value. Properties that fail evaluation are silently excluded. Raises: ValueError: If symbol is not sp.Symbol, or value is non-numeric. """ if not isinstance(symbol, sp.Symbol): raise ValueError(f"symbol must be sp.Symbol, got {type(symbol).__name__}") if value is None: raise ValueError("value must not be None") try: value = float(value) except (TypeError, ValueError) as e: raise ValueError(f"value must be convertible to float, got {type(value).__name__}") from e logger.info("Evaluating '%s' at %s=%.2f", self.name, symbol, value) evaluated_properties: Dict[str, sp.Basic] = {} for prop_name, expr in self.properties.items(): if expr is None: logger.warning("Dropped None expression for '%s'", prop_name) continue try: substituted = expr.subs(symbol, value).evalf(chop=True) except (TypeError, ValueError, AttributeError) as e: logger.error("Failed to evaluate '%s': %s", prop_name, e, exc_info=True) continue if substituted.free_symbols: logger.error("Property '%s' still has free symbols %s after substituting %s; expression requires %s", prop_name, substituted.free_symbols, symbol, expr.free_symbols, exc_info=True) continue evaluated_properties[prop_name] = substituted return Material( name=f"{self.name}@{symbol}={value}", properties=evaluated_properties, )
def __str__(self) -> str: return f"Material: {self.name} ({len(self.properties)} properties)" def __repr__(self) -> str: return f"Material(name='{self.name}', properties={sorted(self.property_names())})"