Source code for src.tools.engibench

"""
EngiBench tools for engineering design optimization.

EngiBench (https://engibench.ethz.ch) is a library for benchmarking
engineering design problems. It provides access to various optimization
problems like beam design, airfoils, truss structures, etc.

These tools allow LLM agents to interact with EngiBench simulators.
"""

from __future__ import annotations

import contextvars
import datetime
import logging
import random
from collections.abc import Mapping
from pathlib import Path
from typing import TYPE_CHECKING, Any

import matplotlib
import numpy as np
from langchain_core.tools import tool

from src.tools.problems import PROBLEM_CLASSES

if TYPE_CHECKING:
    from engibench.core import Problem


matplotlib.use("Agg")  # Use non-interactive backend

logger = logging.getLogger(__name__)

# Constants
EXPECTED_ARRAY_DIMENSIONS = 2  # For 2D beam design arrays

# Build problem registry from problems.py - single source of truth
PROBLEM_REGISTRY: Mapping[str, type] = PROBLEM_CLASSES

# Context variable to store session ID for isolating state in batch evaluations
# This prevents data corruption when multiple examples run in parallel or sequentially
_session_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
    "session_id", default=None
)

# Counter for generating unique filenames in parallel executions
_filename_counter: contextvars.ContextVar[int] = contextvars.ContextVar(
    "filename_counter", default=0
)

# State management dictionary with session isolation.
# Stores only designs (last_design, initial_design) per session and problem type.
# Problem instances are NOT cached - fresh instances are created per tool call.
# When session_id is None, uses "default" session (for interactive use).
_problem_states: dict[str, dict[str, dict[str, Any]]] = {}


# Unified helper functions for problem management
[docs] def get_problem_class(problem_type: str) -> type: """Get the problem class for a given problem type.""" problem_key = problem_type.lower() if problem_key not in PROBLEM_REGISTRY: msg = f"Unknown problem type: {problem_type}. Available types: {', '.join(PROBLEM_REGISTRY.keys())}" raise ValueError(msg) return PROBLEM_REGISTRY[problem_key]
[docs] def set_session_id(session_id: str) -> None: """Set the session ID for state isolation in batch evaluations. Args: session_id: Unique identifier for this evaluation session/example Example: >>> set_session_id("example_42") # In evaluation framework >>> optimize_design(...) # State is isolated to this session """ _session_id_var.set(session_id)
[docs] def get_current_session_id() -> str: """Get the current session ID, or 'default' if not set. Returns: Session ID string (defaults to 'default' for interactive use) """ session_id = _session_id_var.get() return session_id if session_id is not None else "default"
[docs] def get_problem_state(problem_type: str) -> dict[str, Any]: """Get the state dictionary for a given problem type in the current session. This function is session-aware and prevents state leakage between evaluation examples. Each session gets its own isolated state dictionary for storing designs only. Args: problem_type: Type of problem (e.g., 'beams2d') Returns: State dictionary with keys: last_design, initial_design """ problem_key = problem_type.lower() session_id = get_current_session_id() # Ensure session exists in state dictionary if session_id not in _problem_states: _problem_states[session_id] = {} # Ensure problem type exists in session if problem_key not in _problem_states[session_id]: _problem_states[session_id][problem_key] = { "last_design": None, "initial_design": None, } return _problem_states[session_id][problem_key]
[docs] def get_unified_last_design(problem_type: str) -> np.ndarray | None: """Get the last design for a given problem type.""" state = get_problem_state(problem_type) return state["last_design"]
[docs] def set_unified_last_design(problem_type: str, design: np.ndarray) -> None: """Store the last design for a given problem type.""" state = get_problem_state(problem_type) state["last_design"] = design
[docs] def get_initial_design(problem_type: str) -> np.ndarray | None: """Get the initial design (before optimization) for a given problem type.""" state = get_problem_state(problem_type) return state.get("initial_design")
[docs] def set_initial_design(problem_type: str, design: np.ndarray) -> None: """Store the initial design (before optimization) for a given problem type.""" state = get_problem_state(problem_type) state["initial_design"] = design
[docs] def clear_session_state(session_id: str | None = None) -> None: """Clear state for a specific session to prevent memory leaks. Args: session_id: Session ID to clear, or None to clear current session Example: >>> clear_session_state("example_42") # Clear specific session >>> clear_session_state() # Clear current session """ target_session = session_id if session_id is not None else get_current_session_id() _problem_states.pop(target_session, None)
@tool def create_problem( problem_type: str = "beams2d", problem_config: dict[str, Any] | None = None, seed: int = 0, ) -> dict[str, Any]: """ Create an optimization problem using EngiBench. This unified tool works with any problem type available in EngiBench. Currently supported: 'beams2d', 'photonics2d'. Args: problem_type: Type of problem ('beams2d', 'photonics2d') problem_config: Problem-specific configuration parameters (optional) For beams2d: {"volfrac": 0.35, "rmin": 2.0, "forcedist": 0.2} For photonics2d: {"lambda1": 1.2, "lambda2": 1.3, "blur_radius": 2.0} seed: Random seed for reproducibility (default: 0) Returns: dict with problem info: - problem_id: str identifier - problem_type: str - design_space: description of design space - objectives: list of objectives to optimize - conditions: problem conditions/constraints (reflects problem_config if provided) - dataset_id: HuggingFace dataset identifier - success: bool - message: str Example: >>> info = create_problem(problem_type="photonics2d", problem_config={"lambda1": 1.19, "lambda2": 1.30, "blur_radius": 2.0}, seed=42) >>> print(info['conditions']) >>> # Photonics2D.Conditions(lambda1=1.19, lambda2=1.30, blur_radius=2.0) """ try: # Convert None to empty dict for consistency config = problem_config if problem_config is not None else {} problem_class = get_problem_class(problem_type) # Try to create problem with config, fall back to seed-only if not supported try: problem = problem_class(seed=seed, config=config) except TypeError as exc: # Fall back to seed-only initialization if config keyword is not supported msg = str(exc) if "unexpected keyword argument" in msg and "config" in msg: problem = problem_class(seed=seed) else: raise return { "problem_id": problem_type.lower(), "problem_type": problem_type.lower(), "design_space": str(problem.design_space), "objectives": [ (name, str(direction)) for name, direction in problem.objectives ], "conditions": problem.conditions, "conditions_keys": problem.conditions_keys, "dataset_id": problem.dataset_id, "success": True, "message": f"Created {problem_type} problem with seed {seed}" + (f" and config {config}" if config else ""), } except ValueError as e: return {"success": False, "error": str(e)} except ImportError: return { "success": False, "error": "engibench not installed. Install with: pip install engibench", } except Exception as e: return {"success": False, "error": f"Failed to create problem: {e!s}"} @tool def simulate_design( # noqa: PLR0913 problem_type: str = "beams2d", design_description: str = "random design", problem_config: dict[str, Any] | None = None, seed: int = 0, # Flat config parameters (LangChain doesn't pass unknown params to **kwargs) volume_fraction: float | None = None, volfrac: float | None = None, force_distance: float | None = None, forcedist: float | None = None, rmin: float | None = None, lambda1: float | None = None, lambda2: float | None = None, blur_radius: float | None = None, weight: float | None = None, ) -> dict[str, Any]: """ Simulate a design and return its performance metrics. Currently supported: 'beams2d', 'photonics2d'. Args: problem_type: Type of problem ('beams2d', 'photonics2d') design_description: Which design to simulate ("random design", "optimized design", "last design") seed: Random seed for reproducibility Configuration parameters (pass directly OR in problem_config dict): - volume_fraction / volfrac: Material volume constraint (0.0 to 1.0) - force_distance / forcedist: Load position for beams2d (0.0 to 1.0) - filter_radius / rmin: Density filter radius - lambda1, lambda2: Wavelengths for photonics2d - blur_radius: Blur radius for photonics2d problem_config: Alternative: pass all config as a dict Returns: dict with: success, compliance (beams2d), total_overlap (photonics2d), etc. Example: >>> simulate_design(problem_type="beams2d", design_description="optimized design", volume_fraction=0.4) """ try: # Build dict from flat parameters flat_params = { "volume_fraction": volume_fraction, "volfrac": volfrac, "force_distance": force_distance, "forcedist": forcedist, "rmin": rmin, "lambda1": lambda1, "lambda2": lambda2, "blur_radius": blur_radius, "weight": weight, } config = _build_problem_config_from_flat_params(problem_config, **flat_params) # Create a fresh problem instance for this simulation problem_class = get_problem_class(problem_type) try: problem = problem_class(seed=seed, config=config) except TypeError as exc: # Fall back to seed-only initialization if config keyword is not supported msg = str(exc) if "unexpected keyword argument" in msg and "config" in msg: problem = problem_class(seed=seed) else: raise # Get the design to simulate based on description design, _ = _get_design_to_render(problem_type, design_description, problem) # Store design if it's newly generated if not _should_use_last_design(design_description): set_unified_last_design(problem_type, design) # Run simulation objectives = problem.simulate(design=design, config=config or None) # Format results based on problem type - use problem.objectives to dynamically extract problem_key = problem_type.lower() # Build result dictionary dynamically from problem.objectives result = { "success": True, "problem_type": problem_key, "design_valid": True, } # Extract objective names from problem.objectives objective_names = [name for name, _ in problem.objectives] # Map objective values to their names for i, obj_name in enumerate(objective_names): if i < len(objectives): result[obj_name] = float(objectives[i]) # Add material usage metric (common to all problems) result["material_usage"] = float(design.mean()) # Build a human-readable message obj_strings = [ f"{name}={result[name]:.6f}" for name in objective_names if name in result ] message = f"Simulated {design_description}: " + ", ".join(obj_strings) result["message"] = message except ValueError as e: return {"success": False, "error": str(e)} except ImportError: return { "success": False, "error": "engibench not installed. Install with: pip install engibench", } except Exception as e: return {"success": False, "error": f"Simulation failed: {e!s}"} else: return result def _build_problem_config_from_flat_params( problem_config: dict[str, Any] | None, **kwargs: Any, ) -> dict[str, Any]: """Build problem_config from flat parameters, supporting common LLM parameter aliases. LLMs often pass parameters with different names than expected. This function normalizes them to the correct config keys. Args: problem_type: Type of problem (beams2d, photonics2d) problem_config: Existing problem_config dict (may contain aliased keys) **kwargs: Flat parameters that may use aliases Returns: Normalized problem_config dict """ # Parameter aliases: LLM name -> config key (shared across all problems) all_aliases = { # Beams2D aliases "volume_fraction": "volfrac", "vol_frac": "volfrac", "volume": "volfrac", "force_distance": "forcedist", "force_distribution": "forcedist", # backwards compat alias "force_dist": "forcedist", "load_position": "forcedist", "filter_radius": "rmin", # Photonics2D aliases "wavelength1": "lambda1", "wavelength_1": "lambda1", "wavelength2": "lambda2", "wavelength_2": "lambda2", "blur": "blur_radius", } # Valid config keys that don't need aliasing valid_keys = { "volfrac", "rmin", "forcedist", "lambda1", "lambda2", "blur_radius", "weight", } # Keys to skip (not config parameters) skip_keys = { "problem_id", "grid_size", "problem_type", "seed", "save_result", "design_description", "save_path", } def normalize_params(params: dict[str, Any]) -> dict[str, Any]: """Normalize a dict of parameters, applying aliases.""" config: dict[str, Any] = {} for key, value in params.items(): if value is None or key in skip_keys: continue # Check if this is an alias if key in all_aliases: config[all_aliases[key]] = value # Check if it's already a valid config key elif key in valid_keys: config[key] = value return config # If problem_config was provided, normalize it (may contain aliased keys) if problem_config: return normalize_params(problem_config) # Otherwise normalize kwargs return normalize_params(kwargs) @tool def optimize_design( # noqa: PLR0913 problem_type: str = "beams2d", problem_config: dict[str, Any] | None = None, seed: int = 0, # Flat config parameters (LangChain doesn't pass unknown params to **kwargs) volume_fraction: float | None = None, volfrac: float | None = None, force_distance: float | None = None, forcedist: float | None = None, rmin: float | None = None, lambda1: float | None = None, lambda2: float | None = None, blur_radius: float | None = None, weight: float | None = None, ) -> dict[str, Any]: """ ✅ USE THIS TOOL for: "optimize", "optimization", "topology optimization", "minimize compliance", "design a beam". Optimize a design using physics-based gradient optimization. This is the PRIMARY tool for all optimization and design tasks. Currently supported: 'beams2d', 'photonics2d'. The optimized design is always saved to outputs/ directory to reduce context consumption. Use np.load(design_path) to load the design array. Args: problem_type: Type of problem ('beams2d', 'photonics2d') seed: Random seed for optimization (default: 0) Configuration parameters (pass directly OR in problem_config dict): - volume_fraction / volfrac: Material volume constraint (0.0 to 1.0) - force_distance / forcedist: Load position for beams2d (0.0 to 1.0) - filter_radius / rmin: Density filter radius - lambda1, lambda2: Wavelengths for photonics2d - blur_radius: Blur radius for photonics2d problem_config: Alternative: pass all config as a dict (e.g., {"volfrac": 0.4, "rmin": 2.0}) Returns: dict with: success, problem_type, design_shape, design_path, optimization_info, message Examples: # Flat parameters (recommended for LLMs): >>> optimize_design(problem_type="beams2d", volume_fraction=0.4, force_distance=0.2, seed=42) # Dict format (also supported): >>> optimize_design(problem_type="beams2d", problem_config={"volfrac": 0.4, "forcedist": 0.2}, seed=42) """ try: # Build dict from flat parameters flat_params = { "volume_fraction": volume_fraction, "volfrac": volfrac, "force_distance": force_distance, "forcedist": forcedist, "rmin": rmin, "lambda1": lambda1, "lambda2": lambda2, "blur_radius": blur_radius, "weight": weight, } logger.info( f"optimize_design called with problem_config={problem_config}, flat_params={flat_params}" ) config = _build_problem_config_from_flat_params(problem_config, **flat_params) logger.info(f"Built config: {config}") config_was_empty = config == {} # Create a fresh problem instance for this optimization. # Problem instances are lightweight and creating fresh instances ensures # that config parameters are properly applied without state interference. problem_class = get_problem_class(problem_type) # Only pass config to problem classes that support it (e.g., Beams2D) # ThermoElastic2D and others may not accept config parameter try: problem = problem_class(seed=seed, config=config) except TypeError as exc: # Fall back to seed-only initialization if config keyword is not supported. # Re-raise other TypeErrors so that genuine bugs are not masked. msg = str(exc) if "unexpected keyword argument" in msg and "config" in msg: problem = problem_class(seed=seed) else: raise # Get starting design using random design from official API design, _ = problem.random_design() # Store initial design separately for later visualization set_initial_design(problem_type, design) # Also set as last_design (will be overwritten after optimization) set_unified_last_design(problem_type, design) # Run optimization with the starting design optimized_design, optimization_info = problem.optimize( starting_point=design, config=config or None ) # Store optimized design as the new last_design set_unified_last_design(problem_type, optimized_design) # Save design to file (always save to reduce context consumption) output_dir = Path("outputs") output_dir.mkdir(exist_ok=True) base_path = output_dir / f"{problem_type}_design_optimized.npy" versioned_path = _build_versioned_path(base_path, problem_type, "_optimized") np.save(str(versioned_path), optimized_design) # Format results - note: we don't call simulate() here # The LLM should call simulate_design tool separately if needed # Pass the design path instead of the full array to reduce context size result = _format_optimization_result( problem_type, optimized_design, optimization_info, design_path=str(versioned_path), ) # Add warning if no configuration parameters were provided if config_was_empty: warning_msg = " ⚠️ WARNING: No config parameters provided! Used defaults which may not match requirements!" result["message"] = result.get("message", "") + warning_msg result["config_warning"] = True except ValueError as e: return {"success": False, "error": str(e)} except ImportError: return { "success": False, "error": "engibench not installed. Install with: pip install engibench", } except Exception as e: return {"success": False, "error": f"Optimization failed: {e!s}"} else: return result def _extract_figure_and_axis(render_result): """Helper to extract figure and axis from different render return types.""" if isinstance(render_result, tuple): # Beams2D returns a tuple return render_result # ThermoElastic2D returns a single figure fig = render_result return fig, fig.gca() def _get_design_suffix(design_description: str, design_type: str) -> str: """Helper to get filename suffix based on design description.""" desc_lower = design_description.lower() suffix_map = { "initial": "_initial", "before": "_initial", "final": "_final", "after": "_final", "optimized": "_optimized", "optimal": "_optimized", "random": "_random", } return next( (suf for keyword, suf in suffix_map.items() if keyword in desc_lower), "_" + design_type.replace(" ", "_").replace("(", "").replace(")", ""), ) def _build_versioned_path(base_path: Path, problem_type: str, suffix: str) -> Path: """Helper to build a versioned file path with problem type prefix. Always uses {problem_type}_design as base to ensure consistency between optimize_design and render_design outputs. """ extension = base_path.suffix # Always use consistent base: {problem_type}_design # This ensures optimize_design and render_design create matching filenames stem = f"{problem_type}_design" # Add timestamp with microseconds and counter for uniqueness in parallel execution now = datetime.datetime.now() timestamp = now.strftime("%Y%m%d_%H%M%S") microseconds = now.microsecond // 1000 # Convert to milliseconds (0-999) # Get and increment counter for this context (thread-safe via contextvars) counter = _filename_counter.get() _filename_counter.set(counter + 1) return ( base_path.parent / f"{stem}{suffix}_{timestamp}_{microseconds:03d}_{counter}{extension}" ) def _should_use_last_design(design_description: str) -> bool: """Helper to determine if we should use the last design.""" desc_lower = design_description.lower() keywords = ["last", "previous", "current", "optimized", "final"] return any(keyword in desc_lower for keyword in keywords) def _get_design_to_render( problem_type: str, design_description: str, problem: Any ) -> tuple[np.ndarray, str]: """Get the design to render based on the description. Returns: Tuple of (design array, design type string) """ desc_lower = design_description.lower() # Check if user wants the initial design (before optimization) if "initial" in desc_lower or "before" in desc_lower or "starting" in desc_lower: initial_design = get_initial_design(problem_type) if initial_design is not None: return initial_design, "initial design" # No initial design stored, create a random one design, _ = problem.random_design() return design, "random design" # Check if user wants optimized/final design if _should_use_last_design(design_description): last_design = get_unified_last_design(problem_type) if last_design is not None: return last_design, design_description # No last design stored, create a random one design, _ = problem.random_design() return design, "random design" # Default to random design design, _ = problem.random_design() return design, "random design" def _serialize_optimization_step(step: Any) -> dict[str, Any]: """Convert a single optimization step to JSON-serializable format. Excludes 'design' arrays to save context. """ step_dict = {} if hasattr(step, "__dict__"): # Convert OptiStep object to dict for key, value in step.__dict__.items(): if key == "design": continue step_dict[key] = value.tolist() if isinstance(value, np.ndarray) else value elif isinstance(step, dict): # Already a dict, just convert arrays for key, value in step.items(): if key == "design": continue step_dict[key] = value.tolist() if isinstance(value, np.ndarray) else value else: step_dict = step return step_dict def _format_optimization_result( problem_type: str, optimized_design: np.ndarray, optimization_info: dict, design_path: str | None = None, ) -> dict[str, Any]: """Format optimization results. Note: This function does not include objective values. Use the simulate_design tool separately to get objective values for the optimized design. To reduce context consumption for smaller LLM models, the full design array is saved to a file and only the path is returned. The design can be loaded with: np.load(design_path) Args: problem_type: Type of the problem being formatted optimized_design: The optimized design array optimization_info: Optimization history/metadata design_path: Path where the design array was saved (if any) Returns: Dictionary with formatted results """ problem_key = problem_type.lower() # Convert optimization_info to JSON-serializable format # For context efficiency, only include objective values and step numbers (not full design arrays) if isinstance(optimization_info, list): serializable_opt_info = [ _serialize_optimization_step(step) for step in optimization_info ] else: serializable_opt_info = optimization_info result: dict[str, Any] = { "success": True, "problem_type": problem_key, "design_shape": list(optimized_design.shape), "optimization_info": serializable_opt_info, # Now JSON-serializable "message": f"Optimized {problem_type} design. Use simulate_design tool to get objective values.", } # Include design path for file-based access (reduces context size) if design_path: result["design_path"] = design_path result["message"] += f" Design saved to {design_path}." return result @tool def render_design( # noqa: PLR0913 problem_type: str = "beams2d", design_description: str = "random design", problem_config: dict[str, Any] | None = None, save_path: str = "design.png", seed: int | None = None, # Flat config parameters (LangChain doesn't pass unknown params to **kwargs) volume_fraction: float | None = None, volfrac: float | None = None, force_distance: float | None = None, forcedist: float | None = None, rmin: float | None = None, lambda1: float | None = None, lambda2: float | None = None, blur_radius: float | None = None, weight: float | None = None, ) -> dict[str, Any]: """ Render a design as a visual heatmap and save it as an image file. Currently supported: 'beams2d', 'photonics2d'. Args: problem_type: Type of problem ('beams2d', 'photonics2d') design_description: Which design to render ("random design", "optimized design", "initial design") save_path: Base filename for the image (saved in outputs/ directory) seed: Random seed for reproducibility (None = use random seed) Configuration parameters (pass directly OR in problem_config dict): - volume_fraction / volfrac: Material volume constraint (0.0 to 1.0) - force_distance / forcedist: Load position for beams2d (0.0 to 1.0) - filter_radius / rmin: Density filter radius - lambda1, lambda2: Wavelengths for photonics2d - blur_radius: Blur radius for photonics2d problem_config: Alternative: pass all config as a dict Returns: dict with: success, save_path, npy_path, design_shape, message Example: >>> render_design(problem_type="beams2d", design_description="optimized design", volume_fraction=0.4) """ try: # Build dict from flat parameters flat_params = { "volume_fraction": volume_fraction, "volfrac": volfrac, "force_distance": force_distance, "forcedist": forcedist, "rmin": rmin, "lambda1": lambda1, "lambda2": lambda2, "blur_radius": blur_radius, "weight": weight, } config = _build_problem_config_from_flat_params(problem_config, **flat_params) # Create outputs directory output_dir = Path("outputs") output_dir.mkdir(exist_ok=True) # Ensure save_path is in outputs directory save_path_obj = Path(save_path) if save_path_obj.parent.name != "outputs": full_save_path = output_dir / save_path_obj.name else: full_save_path = save_path_obj # Ensure the path has a valid image extension so the UI can render it # and matplotlib infers the correct format. The LLM sometimes passes a # save_path with no extension or with a non-image one (e.g. reusing the # .npy path from optimize_design). image_extensions = {".png", ".jpg", ".jpeg", ".svg", ".pdf"} if full_save_path.suffix.lower() not in image_extensions: full_save_path = full_save_path.with_suffix(".png") # Create a fresh problem instance for rendering problem_class = get_problem_class(problem_type) # Use random seed if none provided, otherwise use the provided seed if seed is None: seed = random.randint(0, 999999) # Initialize problem with config (if provided) to ensure correct rendering parameters try: problem: Problem = problem_class(seed=seed, config=config) except TypeError as exc: # Fall back to seed-only initialization if config keyword is not supported msg = str(exc) if "unexpected keyword argument" in msg and "config" in msg: problem = problem_class(seed=seed) else: raise # Get the design to render based on description design, design_type = _get_design_to_render( problem_type, design_description, problem ) # Build versioned file path suffix = _get_design_suffix(design_description, design_type) full_save_path = _build_versioned_path(full_save_path, problem_type, suffix) # Render the design (config already set during problem initialization) # Note: Different EngiBench problems return different types: # Beams2D returns a tuple, ThermoElastic2D returns a single figure render_result = problem.render(design, open_window=False) # type: ignore[attr-defined] fig, _ = _extract_figure_and_axis(render_result) # Save figure import matplotlib.pyplot as plt # noqa: PLC0415 fig.savefig(str(full_save_path), dpi=150, bbox_inches="tight") plt.close(fig) # Save design array npy_path = full_save_path.with_suffix(".npy") np.save(str(npy_path), design) return { "success": True, "problem_type": problem_type.lower(), "save_path": str(full_save_path), "npy_path": str(npy_path), "design_shape": design.shape, "design_type": design_type, "seed": seed, "message": f"Successfully rendered {design_type} and saved to {full_save_path}. Design array saved to {npy_path}.", } except ValueError as e: return {"success": False, "error": str(e)} except ImportError: return { "success": False, "error": "engibench not installed. Install with: pip install engibench", } except Exception as e: return {"success": False, "error": f"Rendering failed: {e!s}"} @tool def get_problem_details(problem_type: str = "beams2d") -> dict[str, Any]: """ Get detailed information directly from the problem object's attributes. This tool creates a problem instance and extracts information from its design_space, objectives, and conditions attributes - the authoritative source of problem specifications. Args: problem_type: Type of problem ("beams2d", "photonics2d") Default: "beams2d" Returns: dict with problem details: - success: bool - problem_type: str - design_space: str (from problem.design_space) - objectives: list of tuples (name, direction) from problem.objectives - conditions: dict (from problem.conditions) - conditions_keys: list (from problem.conditions_keys) - dataset_id: str (from problem.dataset_id) Example: >>> details = get_problem_details("beams2d") >>> print(details['design_space']) >>> # Box(0.0, 1.0, (50, 100), float32) >>> print(details['objectives']) >>> # [('compliance', 'MINIMIZE')] """ try: problem_key = problem_type.lower() # Check if problem type is supported if problem_key not in PROBLEM_REGISTRY: return { "success": False, "error": f"Problem type '{problem_type}' not supported. Available: {list(PROBLEM_REGISTRY.keys())}", } # Create problem instance using the registry # Note: This is only for metadata access (design space, objectives, conditions) problem_class = PROBLEM_REGISTRY[problem_key] problem = problem_class() # Metadata-only instance return { "success": True, "problem_type": problem_key, "design_space": str(problem.design_space), "design_space_shape": problem.design_space.shape, "design_space_bounds": { "low": float(problem.design_space.low.min()), "high": float(problem.design_space.high.max()), }, "objectives": [ (name, str(direction)) for name, direction in problem.objectives ], "conditions": problem.conditions, "conditions_keys": problem.conditions_keys, "dataset_id": problem.dataset_id, "description": f"Information from the EngiBench {problem_key} problem object. " f"The design_space defines where designs can exist (shape {problem.design_space.shape}). " f"The objectives specify what to optimize. " f"The conditions are the parameters that can be varied for different problem instances.", } except ImportError: return { "success": False, "error": "engibench not installed. Install with: pip install engibench", } except Exception as e: return {"success": False, "error": f"Failed to get problem details: {e!s}"} @tool def get_dataset_info(problem_type: str = "beams2d") -> dict[str, Any]: """ Get information about the EngiBench dataset for a problem. The dataset contains pre-computed optimal designs from the EngiBench benchmark. Each dataset includes training, validation, and test splits with optimal designs and their corresponding parameters. Args: problem_type: Type of problem ("beams2d", "photonics2d") Default: "beams2d" Returns: dict with dataset information: - success: bool (whether the operation succeeded) - dataset_id: str (HuggingFace dataset identifier) - splits: dict with split names and their sizes - features: list of feature names in the dataset - total_samples: int (total number of samples across all splits) - description: str (description of dataset contents) Example: >>> info = get_dataset_info("beams2d") >>> print(f"Dataset has {info['total_samples']} total samples") >>> print(f"Features: {info['features']}") """ try: problem_key = problem_type.lower() # Check if problem type is supported if problem_key not in PROBLEM_REGISTRY: return { "success": False, "error": f"Problem type '{problem_type}' not supported. Available: {list(PROBLEM_REGISTRY.keys())}", } # Create problem instance using the registry # Note: This is only for dataset access (metadata) problem_class = PROBLEM_REGISTRY[problem_key] problem = problem_class() # Metadata-only instance dataset = problem.dataset # Get information about each split splits = {} total_samples = 0 features: list[str] = [] for split_name in dataset: split = dataset[split_name] splits[split_name] = { "num_rows": len(split), "num_columns": len(split.column_names), } total_samples += len(split) if not features and split.column_names: features = split.column_names # Generic description for all problems - specific details available via features description = ( f"HuggingFace dataset containing pre-computed optimal {problem_key} designs. " f"Includes design arrays, optimization parameters, objective values, and history." ) return { "success": True, "problem_type": problem_key, "dataset_id": problem.dataset_id, "splits": splits, "split_names": list(dataset.keys()), "features": features, "total_samples": total_samples, "description": description, } except ImportError: return { "success": False, "error": "engibench not installed. Install with: pip install engibench", } except Exception as e: return {"success": False, "error": f"Failed to get dataset info: {e!s}"}