Source code for materforge.algorithms.piecewise_builder

import logging
from typing import Dict, List, Union
import numpy as np
import sympy as sp

from materforge.algorithms.interpolation import ensure_ascending_order
from materforge.algorithms.regression_processor import RegressionProcessor
from materforge.data.constants import ProcessingConstants
from materforge.parsing.config.yaml_keys import CONSTANT_KEY, EXTRAPOLATE_KEY, BOUNDS_KEY, PRE_KEY
from materforge.parsing.utils.utilities import ensure_sympy_compatible

logger = logging.getLogger(__name__)


[docs] class PiecewiseBuilder: """Centralized piecewise function creation with different strategies."""
[docs] @staticmethod def build_from_data(temp_array: np.ndarray, prop_array: np.ndarray, T: sp.Symbol, config: Dict, prop_name: str) -> sp.Piecewise: """ Main entry point for data-based piecewise creation. Args: temp_array: Temperature data points prop_array: Property values corresponding to temperatures T: Temperature symbol for the piecewise function config: Configuration dictionary containing bounds and regression settings prop_name: Name of the property (for logging and error messages) Returns: sp.Piecewise: Symbolic piecewise function """ logger.info("Building piecewise function for property: %s", prop_name) logger.debug("Input data - temperature points: %d, property points: %d", len(temp_array) if temp_array is not None else 0, len(prop_array) if prop_array is not None else 0) # Validate input arrays if temp_array is None or prop_array is None: logger.error("Null arrays provided for property '%s'", prop_name) raise ValueError(f"Temperature and property arrays cannot be None for '{prop_name}'") if len(temp_array) != len(prop_array): logger.error("Array length mismatch for '%s': temp=%d, prop=%d", prop_name, len(temp_array), len(prop_array)) raise ValueError(f"Temperature and property arrays must have same length for '{prop_name}'") if len(temp_array) == 0: logger.error("Empty arrays provided for property '%s'", prop_name) raise ValueError(f"Empty data arrays provided for '{prop_name}'") try: # Ensure ascending order (handles both ascending and descending input) original_order = "ascending" if temp_array[0] < temp_array[-1] else "descending" temp_array, prop_array = ensure_ascending_order(temp_array, prop_array) logger.debug("Data order for '%s': %s (reordered if needed)", prop_name, original_order) # Extract configuration lower_bound_type, upper_bound_type = config[BOUNDS_KEY] logger.debug("Boundary types for '%s': lower=%s, upper=%s", prop_name, lower_bound_type, upper_bound_type) T_standard = sp.Symbol('T') # Check for regression configuration has_regression, simplify_type, degree, segments = RegressionProcessor.process_regression_params( config, prop_name, len(temp_array)) if has_regression: logger.info("Regression enabled for '%s': type=%s, degree=%d, segments=%d", prop_name, simplify_type, degree, segments) else: logger.debug("No regression configured for property '%s'", prop_name) # Create piecewise function based on regression settings if has_regression and simplify_type == PRE_KEY: logger.debug("Building piecewise with pre-regression for '%s'", prop_name) pw_result = PiecewiseBuilder._build_with_regression( temp_array, prop_array, T_standard, lower_bound_type, upper_bound_type, degree, segments) else: logger.debug("Building piecewise without regression for '%s'", prop_name) pw_result = PiecewiseBuilder._build_without_regression( temp_array, prop_array, T_standard, lower_bound_type, upper_bound_type) # Handle symbol substitution if needed if isinstance(T, sp.Symbol) and str(T) != 'T': logger.debug("Substituting symbol T -> %s for property '%s'", T, prop_name) pw_result = pw_result.subs(T_standard, T) logger.info("Successfully built piecewise function for property: %s", prop_name) return pw_result except Exception as e: logger.error("Failed to build piecewise from data for '%s': %s", prop_name, e, exc_info=True) raise ValueError(f"Failed building piecewise from data for '{prop_name}': {str(e)}") from e
[docs] @staticmethod def build_from_formulas(temp_points: np.ndarray, equations: List[Union[str, sp.Expr]], T: sp.Symbol, lower_bound_type: str = CONSTANT_KEY, upper_bound_type: str = CONSTANT_KEY) -> sp.Piecewise: """ Create piecewise from symbolic equations. Args: temp_points: Temperature breakpoints equations: List of symbolic expressions (strings or SymPy expressions) T: Temperature symbol lower_bound_type: Boundary behavior below first breakpoint upper_bound_type: Boundary behavior above last breakpoint Returns: sp.Piecewise: Symbolic piecewise function """ logger.info("Building piecewise function from %d formulas and %d breakpoints", len(equations), len(temp_points)) logger.debug("Temperature breakpoints: %s", temp_points.tolist() if len(temp_points) <= 10 else f"[{temp_points[0]}, ..., {temp_points[-1]}]") logger.debug("Boundary types: lower=%s, upper=%s", lower_bound_type, upper_bound_type) if len(equations) != len(temp_points) - 1: logger.error("Equation count mismatch: %d equations for %d breakpoints", len(equations), len(temp_points)) raise ValueError(f"Number of equations ({len(equations)}) must be one less than temperature/break points " f"({len(temp_points)})") if len(temp_points) < 2: logger.error("Insufficient breakpoints: %d (minimum 2 required)", len(temp_points)) raise ValueError("At least 2 temperature points required for piecewise equations") try: temp_points = np.asarray(temp_points, dtype=float) # Parse equations into SymPy expressions using the provided symbol T parsed_equations = [] for i, eqn_str in enumerate(equations): try: logger.debug("Parsing equation %d: %s", i + 1, eqn_str) expr = sp.sympify(eqn_str) # Validate that only T symbol is used free_symbols = expr.free_symbols invalid_symbols = [str(sym) for sym in free_symbols if str(sym) != str(T)] if invalid_symbols: logger.error("Invalid symbols in equation %d '%s': %s (only '%s' allowed)", i + 1, eqn_str, invalid_symbols, T) raise ValueError( f"Invalid symbols {invalid_symbols} in equation '{eqn_str}'. Only '{T}' is allowed.") parsed_equations.append(expr) logger.debug("Successfully parsed equation %d", i + 1) except Exception as e: raise ValueError(f"Failed to parse equation {i + 1}: '{eqn_str}': {e}") # Special case: single expression with extrapolation at both ends if (len(parsed_equations) == 1 and lower_bound_type == EXTRAPOLATE_KEY and upper_bound_type == EXTRAPOLATE_KEY): logger.warning( "Using a single expression with extrapolation at both ends. " "Consider simplifying your YAML definition to use a direct equation." ) # Return as Piecewise for consistency result = sp.Piecewise((parsed_equations[0], T >= -sp.oo)) # Always true, but explicit logger.debug("Created single-expression piecewise with universal extrapolation") return result # Build piecewise conditions for multiple equations or different boundary types conditions = [] logger.debug("Building piecewise conditions for %d segments", len(parsed_equations)) # Handle lower bound if lower_bound_type == CONSTANT_KEY: const_value = parsed_equations[0].subs(T, temp_points[0]) conditions.append((const_value, T < temp_points[0])) logger.debug("Added lower constant boundary: value=%.3f at T<%.1f", float(const_value), temp_points[0]) # Handle intervals (including special cases for first and last) for i, expr in enumerate(parsed_equations): if i == 0 and lower_bound_type == EXTRAPOLATE_KEY: # First segment with extrapolation conditions.append((expr, T < temp_points[i + 1])) logger.debug("Added first segment with extrapolation: T<%.1f", temp_points[i + 1]) elif i == len(parsed_equations) - 1 and upper_bound_type == EXTRAPOLATE_KEY: # Last segment with extrapolation conditions.append((expr, T >= temp_points[i])) logger.debug("Added last segment with extrapolation: T>=%.1f", temp_points[i]) else: # Regular interval conditions.append((expr, sp.And(T >= temp_points[i], T < temp_points[i + 1]))) logger.debug("Added regular interval: %.1f<=T<%.1f", temp_points[i], temp_points[i + 1]) # Handle upper bound if upper_bound_type == CONSTANT_KEY: const_value = parsed_equations[-1].subs(T, temp_points[-1]) conditions.append((const_value, T >= temp_points[-1])) logger.debug("Added upper constant boundary: value=%.3f at T>=%.1f", float(const_value), temp_points[-1]) result = sp.Piecewise(*conditions) logger.info("Successfully built piecewise function from formulas with %d conditions", len(conditions)) return result except Exception as e: logger.error("Failed to build piecewise from formulas: %s", e, exc_info=True) raise ValueError(f"Failed building piecewise from formulas: {str(e)}") from e
@staticmethod def _build_without_regression(temp_array: np.ndarray, prop_array: np.ndarray, T: sp.Symbol, lower: str, upper: str) -> sp.Piecewise: """ Create basic linear interpolation piecewise function. Args: temp_array: Temperature data points (must be sorted) prop_array: Property values T: Temperature symbol lower: Lower boundary behavior ('constant' or 'extrapolate') upper: Upper boundary behavior ('constant' or 'extrapolate') Returns: sp.Piecewise: Linear interpolation piecewise function """ logger.debug("Building linear interpolation piecewise: %d data points, bounds=(%s,%s)", len(temp_array), lower, upper) # Convert arrays to Python floats to ensure SymPy compatibility temp_array = [ensure_sympy_compatible(x) for x in temp_array] prop_array = [ensure_sympy_compatible(x) for x in prop_array] conditions = [] # Handle lower bound (T < temp_array[0]) if lower == CONSTANT_KEY: lower_expr = prop_array[0] logger.debug("Lower bound: constant value %.3f", lower_expr) else: # extrapolate if len(temp_array) > 1: slope = (prop_array[1] - prop_array[0]) / (temp_array[1] - temp_array[0]) lower_expr = prop_array[0] + slope * (T - temp_array[0]) logger.debug("Lower bound: extrapolation with slope %.6f", slope) else: lower_expr = prop_array[0] logger.debug("Lower bound: single point, using constant %.3f", lower_expr) conditions.append((lower_expr, T < temp_array[0])) # Handle main interpolation segments logger.debug("Creating %d interpolation segments", len(temp_array) - 1) for i in range(len(temp_array) - 1): slope = (prop_array[i + 1] - prop_array[i]) / (temp_array[i + 1] - temp_array[i]) expr = prop_array[i] + slope * (T - temp_array[i]) condition = sp.And(T >= temp_array[i], T < temp_array[i + 1]) conditions.append((expr, condition)) # logger.debug("Segment %d: T∈[%.1f,%.1f), slope=%.6f", # i + 1, temp_array[i], temp_array[i + 1], slope) #TODO: Uncomment for detailed output # Handle upper bound (T >= temp_array[-1]) if upper == CONSTANT_KEY: upper_expr = prop_array[-1] logger.debug("Upper bound: constant value %.3f", upper_expr) else: # extrapolate if len(temp_array) > 1: slope = (prop_array[-1] - prop_array[-2]) / (temp_array[-1] - temp_array[-2]) upper_expr = prop_array[-1] + slope * (T - temp_array[-1]) logger.debug("Upper bound: extrapolation with slope %.6f", slope) else: upper_expr = prop_array[-1] logger.debug("Upper bound: single point, using constant %.3f", upper_expr) conditions.append((upper_expr, T >= temp_array[-1])) logger.debug("Created piecewise function with %d total conditions", len(conditions)) return sp.Piecewise(*conditions) @staticmethod def _build_with_regression(temp_array: np.ndarray, prop_array: np.ndarray, T: sp.Symbol, lower: str, upper: str, degree: int, segments: int) -> sp.Piecewise: """ Create piecewise with regression. This delegates to RegressionProcessor but provides a unified interface. Args: temp_array: Temperature data points prop_array: Property values T: Temperature symbol lower: Lower boundary behavior upper: Upper boundary behavior degree: Polynomial degree for regression segments: Number of segments for regression Returns: sp.Piecewise: Regression-based piecewise function """ logger.info("Building piecewise with regression: degree=%d, segments=%d", degree, segments) logger.debug("Regression parameters: lower=%s, upper=%s, data_points=%d", lower, upper, len(temp_array)) try: result = RegressionProcessor.process_regression(temp_array, prop_array, T, lower, upper, degree, segments, seed=ProcessingConstants.DEFAULT_REGRESSION_SEED) logger.info("Successfully created regression-based piecewise function") return result except Exception as e: logger.error("Regression processing failed: %s", e, exc_info=True) raise