Source code for materforge.core.evaluator

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

"""Fast numeric evaluation of a :class:`~materforge.core.materials.Material`.

:class:`Material.evaluate` substitutes the dependency symbolically on every call,
which is fine for a one-off scalar but slow when sweeping many values. A
:class:`MaterialEvaluator` compiles each property to a NumPy callable once
(via :func:`sympy.lambdify`) and reuses it, so evaluating over an array of
dependency values is a single vectorised call per property.

Build one with :meth:`Material.compile`::

    ev = material.compile()
    ev(500.0)                       # {'density': 2634.5, ...}
    ev(np.linspace(300, 900, 200))  # {'density': array([...]), ...}
"""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Union

import numpy as np
import sympy as sp

if TYPE_CHECKING:
    from materforge.core.materials import Material

logger = logging.getLogger(__name__)

# A scalar in, or anything array-like (list, tuple, ndarray).
Number = Union[int, float]
ArrayLike = Union[Number, "np.ndarray", list, tuple]


[docs] class MaterialEvaluator: """Compiled, reusable numeric evaluator for a material's properties. Each property is lambdified once against the material's dependency symbol and cached. Calling the evaluator with a scalar returns a ``dict`` of floats; calling it with an array returns a ``dict`` of NumPy arrays shaped like the input. Constant properties are broadcast to match. The evaluator is a snapshot taken at construction time: if the material's properties change afterwards, build a fresh evaluator with :meth:`Material.compile`. Args: material: A fully processed Material instance. symbol: Dependency symbol to evaluate against. If omitted, it is inferred from the free symbols of the material's properties (the common case, where every property is a function of a single dependency). Pass it explicitly to disambiguate or to pin the variable for an all-constant material. Raises: TypeError: symbol is given but is not a sympy Symbol. ValueError: The properties depend on more than one symbol (multivariate materials are not yet supported), or on a symbol other than the one supplied. """ def __init__(self, material: "Material", symbol: Optional[sp.Symbol] = None) -> None: self._material_name = material.name self._symbol = self._resolve_symbol(material.properties, symbol) self._functions: Dict[str, Callable] = {} self._build(material.properties) # --- Construction helpers --- @staticmethod def _free_symbols(expr: Any) -> Set[sp.Symbol]: """Free symbols of a property expression, or empty for a plain number.""" if isinstance(expr, sp.Basic): return expr.free_symbols return set() def _resolve_symbol(self, properties: Dict[str, Any], symbol: Optional[sp.Symbol]) -> Optional[sp.Symbol]: all_symbols: Set[sp.Symbol] = set() for expr in properties.values(): all_symbols |= self._free_symbols(expr) if symbol is not None: if not isinstance(symbol, sp.Symbol): raise TypeError( f"symbol must be a sympy Symbol, got {type(symbol).__name__}") extra = all_symbols - {symbol} if extra: raise ValueError( f"Properties depend on {sorted(map(str, extra))}, which is not " f"the supplied symbol '{symbol}'") return symbol if len(all_symbols) > 1: raise ValueError( f"Material '{self._material_name}' depends on multiple symbols " f"{sorted(map(str, all_symbols))}; pass symbol= to pick one. " f"Multivariate evaluation is not yet supported.") return next(iter(all_symbols)) if all_symbols else None def _build(self, properties: Dict[str, Any]) -> None: for name, expr in properties.items(): if expr is None: logger.warning("Skipping property '%s' with no expression", name) continue free = self._free_symbols(expr) if not free: try: self._functions[name] = _Constant(float(expr)) continue except (TypeError, ValueError): logger.warning( "Skipping constant property '%s' that is not a real number", name) continue if self._symbol is None: # Cannot happen given _resolve_symbol, but guards against misuse. logger.warning("Skipping property '%s'; no dependency symbol", name) continue try: self._functions[name] = sp.lambdify(self._symbol, expr, "numpy") except (TypeError, ValueError, NameError) as exc: logger.warning("Could not compile property '%s': %s", name, exc) # --- Public API --- @property def symbol(self) -> Optional[sp.Symbol]: """The dependency symbol this evaluator substitutes (None if all-constant).""" return self._symbol
[docs] def property_names(self) -> Set[str]: """Names of the properties this evaluator can compute.""" return set(self._functions.keys())
[docs] def function(self, name: str) -> Callable: """Returns the cached numeric callable for a single property. Useful when you only need one property, or want to hand the raw callable to another numerical routine. Raises: KeyError: No compiled property with that name. """ if name not in self._functions: raise KeyError( f"No compiled property '{name}'. Available: {sorted(self._functions)}") return self._functions[name]
[docs] def evaluate(self, value: ArrayLike) -> Dict[str, object]: """Alias for calling the evaluator; see :meth:`__call__`.""" return self(value)
def __call__(self, value: ArrayLike) -> Dict[str, object]: """Evaluates every property at a scalar or array of dependency values. Args: value: A scalar, or an array-like of dependency values. Returns: Mapping of property name to result: a float for a scalar input, or a NumPy array shaped like the input for an array. Constant properties are broadcast to match. Raises: ValueError: value is None or cannot be read as a real number/array. """ if value is None: raise ValueError("value must not be None") if _is_scalar(value): return self._evaluate_scalar(float(value)) # type: ignore[arg-type] try: array = np.asarray(value, dtype=float) except (TypeError, ValueError) as exc: raise ValueError(f"value must be a real number or array-like: {exc}") from exc return self._evaluate_array(array) def _evaluate_scalar(self, value: float) -> Dict[str, object]: results: Dict[str, object] = {} for name, func in self._functions.items(): results[name] = float(np.asarray(func(value)).reshape(-1)[0]) return results def _evaluate_array(self, array: "np.ndarray") -> Dict[str, object]: results: Dict[str, object] = {} for name, func in self._functions.items(): raw = np.asarray(func(array), dtype=float) if raw.shape != array.shape: raw = np.broadcast_to(raw, array.shape).astype(float, copy=True) results[name] = raw return results def __len__(self) -> int: return len(self._functions) def __repr__(self) -> str: symbol = self._symbol if self._symbol is not None else "const" return (f"MaterialEvaluator('{self._material_name}', symbol={symbol}, " f"properties={len(self._functions)})")
class _Constant: """Callable wrapper for a constant property, so every property is a function.""" __slots__ = ("value",) def __init__(self, value: float) -> None: self.value = value def __call__(self, _: ArrayLike) -> float: return self.value def _is_scalar(value: object) -> bool: """True for a single number, including a 0-d NumPy array.""" if isinstance(value, bool): return False if isinstance(value, (int, float, np.number)): return True return isinstance(value, np.ndarray) and value.ndim == 0 __all__: List[str] = ["MaterialEvaluator"]