diff --git a/docs/introduction/api.md b/docs/introduction/api.md index ad364cd7..09ebac28 100644 --- a/docs/introduction/api.md +++ b/docs/introduction/api.md @@ -19,6 +19,7 @@ A dataset is generally composed of several columns: ```{eval-rst} .. automethod:: engibench.core.Problem.check_constraints .. automethod:: engibench.core.Problem.simulate +.. automethod:: engibench.core.Problem.simulate_verbose .. automethod:: engibench.core.Problem.optimize Where an OptiStep is defined as: diff --git a/engibench/core.py b/engibench/core.py index b2365960..1db95e1f 100644 --- a/engibench/core.py +++ b/engibench/core.py @@ -42,6 +42,17 @@ class ObjectiveDirection(Enum): MAXIMIZE = auto() +@dataclasses.dataclass +class SimulationResult: + """Base type for the return value of `Problem.simulate_with_details()`. + + Derived problem classes may return instances of derived `SimulationResult` classes, to return more fields. + """ + + objective_values: npt.NDArray + """Performance of the simulated design -- each entry corresponds to an objective value.""" + + class Problem(Generic[DesignType]): r"""Main class for defining an engineering design problem. @@ -54,6 +65,7 @@ class Problem(Generic[DesignType]): - :meth:`check_constraints` - to check if a design and conditions violate any constraints. - :meth:`simulate` - to simulate a design and return the performance given some conditions. + - :meth:`simulate_verbose` - to simulate a design and return the performance and problem specific additional data given some conditions. - :meth:`optimize` - to optimize a design starting from a given point, e.g., using adjoint solver included inside the simulator. - :meth:`reset` - to reset the simulator and numpy random to a given seed. Should be called before each call to `simulate` or `optimize`. - :meth:`render` - to render a design in a human-readable format. @@ -130,9 +142,32 @@ def conditions_keys(self) -> list[str]: """Returns the condition names as a list.""" return [f.name for f in dataclasses.fields(self.conditions)] + def simulate_verbose(self, design: DesignType, config: dict[str, Any] | None = None) -> SimulationResult: + r"""Launch a simulation on the given design and return the performance. + + Similar to :py:meth:`Problem.simulate()` but usually returns more values, which can lead to + an increase of computational intensity when compared to :py:meth:`Problem.simulate()`. + + Implementations returning more than just the objective values should return an instance of a + class which is derived from :class:`SimulationResult` and document the additional fields. + + Args: + design (DesignType): The design to simulate. + config (dict): A dictionary with configuration (e.g., boundary conditions, filenames) for the optimization. + **kwargs: Additional keyword arguments. + + Returns: + An instance of `SimulationResult` or a derived class. + """ + raise NotImplementedError + def simulate(self, design: DesignType, config: dict[str, Any] | None = None) -> npt.NDArray: r"""Launch a simulation on the given design and return the performance. + The default implementation just calls :py:meth:`Problem.simulate_verbose()` and only returns the objective values. + If the problem allows to determine the objective values in a simpler, less computationally intensive way than + :py:meth:`Problem.simulate_verbose()`, the default implementation should be overloaded by a custom implementation. + Args: design (DesignType): The design to simulate. config (dict): A dictionary with configuration (e.g., boundary conditions, filenames) for the optimization. @@ -141,7 +176,7 @@ def simulate(self, design: DesignType, config: dict[str, Any] | None = None) -> Returns: np.array: The performance of the design -- each entry corresponds to an objective value. """ - raise NotImplementedError + return self.simulate_verbose(design, config).objective_values def optimize( self, starting_point: DesignType, config: dict[str, Any] | None = None diff --git a/engibench/problems/airfoil/v0.py b/engibench/problems/airfoil/v0.py index 303270bd..29ae58d0 100644 --- a/engibench/problems/airfoil/v0.py +++ b/engibench/problems/airfoil/v0.py @@ -27,6 +27,7 @@ from engibench.core import ObjectiveDirection from engibench.core import OptiStep from engibench.core import Problem +from engibench.core import SimulationResult from engibench.problems.airfoil.pyopt_history import History from engibench.problems.airfoil.templates import cli_interface from engibench.problems.airfoil.utils import calc_area @@ -315,7 +316,22 @@ def simulate(self, design: DesignType, config: dict[str, Any] | None = None, mpi mpicores (int): The number of MPI cores to use in the simulation. Returns: - dict: The performance of the design - each entry of the dict corresponds to a named objective value. + Objective values as the performance of the design. + """ + return self.simulate_verbose(design, config, mpicores=mpicores).objective_values + + def simulate_verbose( + self, design: DesignType, config: dict[str, Any] | None = None, mpicores: int = 4 + ) -> SimulationResult: + """Simulates the performance of an airfoil design. + + Args: + design (dict): The design to simulate. + config (dict): A dictionary with configuration (e.g., boundary conditions, filenames) for the simulation. + mpicores (int): The number of MPI cores to use in the simulation. + + Returns: + `SimulationResult` instance containing objective values as the performance of the design. """ if isinstance(design["angle_of_attack"], np.ndarray): design["angle_of_attack"] = design["angle_of_attack"][0] @@ -353,7 +369,7 @@ def simulate(self, design: DesignType, config: dict[str, Any] | None = None, mpi outputs = np.load(self.__local_study_dir + "/output/outputs.npy") lift = float(outputs[3]) drag = float(outputs[4]) - return np.array([drag, lift]) + return SimulationResult(np.array([drag, lift])) def optimize( self, starting_point: DesignType, config: dict[str, Any] | None = None, mpicores: int = 4 diff --git a/engibench/problems/beams2d/v0.py b/engibench/problems/beams2d/v0.py index c25c7dcd..7a444803 100644 --- a/engibench/problems/beams2d/v0.py +++ b/engibench/problems/beams2d/v0.py @@ -24,6 +24,7 @@ from engibench.core import ObjectiveDirection from engibench.core import OptiStep from engibench.core import Problem +from engibench.core import SimulationResult from engibench.problems.beams2d.backend import calc_sensitivity from engibench.problems.beams2d.backend import design_to_image from engibench.problems.beams2d.backend import image_to_design @@ -146,7 +147,22 @@ def simulate( config (dict): A dictionary with configuration (e.g., boundary conditions) for the simulation. Returns: - npt.NDArray: The performance of the design in terms of compliance. + The performance of the design in terms of compliance. + """ + return self.simulate_verbose(design, config, ce=ce).objective_values + + def simulate_verbose( + self, design: npt.NDArray, config: dict[str, Any] | None = None, *, ce: npt.NDArray | None = None + ) -> SimulationResult: + """Simulates the performance of a beam design. + + Args: + design (np.ndarray): The design to simulate. + ce: (np.ndarray, optional): If applicable, the pre-calculated sensitivity of the current design. + config (dict): A dictionary with configuration (e.g., boundary conditions) for the simulation. + + Returns: + SimulationResult: The performance of the design in terms of compliance. """ # This condition is needed to convert user-provided designs (images) to flat arrays. Normally does not apply, i.e., during optimization. if len(design.shape) > 1: @@ -166,7 +182,7 @@ def simulate( c = ( (self.__st.Emin + design**simulate_config.penal * (self.__st.Emax - self.__st.Emin)) * ce ).sum() # compliance (objective) - return np.array([c]) + return SimulationResult(np.array([c])) def optimize( self, starting_point: npt.NDArray | None = None, config: dict[str, Any] | None = None diff --git a/engibench/problems/heatconduction2d/v0.py b/engibench/problems/heatconduction2d/v0.py index b7948c47..3538e0af 100644 --- a/engibench/problems/heatconduction2d/v0.py +++ b/engibench/problems/heatconduction2d/v0.py @@ -22,6 +22,7 @@ from engibench.core import ObjectiveDirection from engibench.core import OptiStep from engibench.core import Problem +from engibench.core import SimulationResult from engibench.problems.heatconduction2d.shared import load_float from engibench.problems.heatconduction2d.shared import run_container_script from engibench.utils.cli import np_array_to_bytes @@ -95,7 +96,7 @@ def __init__(self, seed: int = 0, **kwargs: Any) -> None: self.conditions = self.Conditions(self.config.volume, self.config.length) self.design_space = spaces.Box(low=0.0, high=1.0, shape=(resolution, resolution), dtype=np.float64) - def simulate(self, design: npt.NDArray | None = None, config: dict[str, Any] | None = None) -> npt.NDArray: + def simulate_verbose(self, design: npt.NDArray | None = None, config: dict[str, Any] | None = None) -> SimulationResult: """Simulate the design. Args: @@ -103,7 +104,7 @@ def simulate(self, design: npt.NDArray | None = None, config: dict[str, Any] | N config (dict): A dictionary with configuration (e.g., volume (float): Volume constraint,length (float): Length constraint,resolution (int): Resolution of the design space) for the simulation. Returns: - float: The thermal compliance of the design. + A `SimulationResult` instance containing the thermal compliance of the design. """ config = config or {} volume = config.get("volume", self.config.volume) @@ -122,7 +123,7 @@ def simulate(self, design: npt.NDArray | None = None, config: dict[str, Any] | N ) ) - return np.array([perf]) + return SimulationResult(np.array([perf])) def optimize( self, starting_point: npt.NDArray | None = None, config: dict[str, Any] | None = None diff --git a/engibench/problems/heatconduction3d/v0.py b/engibench/problems/heatconduction3d/v0.py index ac8f7379..b8543486 100644 --- a/engibench/problems/heatconduction3d/v0.py +++ b/engibench/problems/heatconduction3d/v0.py @@ -22,6 +22,7 @@ from engibench.core import ObjectiveDirection from engibench.core import OptiStep from engibench.core import Problem +from engibench.core import SimulationResult from engibench.problems.heatconduction2d.shared import load_float from engibench.problems.heatconduction2d.shared import run_container_script from engibench.utils import cli @@ -91,15 +92,15 @@ def __init__(self, seed: int = 0, **kwargs) -> None: self.conditions = self.Conditions(self.config.volume, self.config.area) self.design_space = spaces.Box(low=0.0, high=1.0, shape=(resolution, resolution, resolution), dtype=np.float64) - def simulate(self, design: npt.NDArray | None = None, config: dict[str, Any] | None = None) -> npt.NDArray: - """Simulate the design. + def simulate_verbose(self, design: npt.NDArray | None = None, config: dict[str, Any] | None = None) -> SimulationResult: + r"""Launch a simulation on the given design and return the performance. Args: design (Optional[np.ndarray]): The design to simulate. config (dict): A dictionary with configuration (e.g., volume (float): Volume constraint,area (float): Area constraint,resolution (int): Resolution of the design space) for the simulation. Returns: - float: The thermal compliance of the design. + A `SimulationResult` instance containing the thermal compliance of the design. """ config = config or {} volume = config.get("volume", self.config.volume) @@ -118,7 +119,7 @@ def simulate(self, design: npt.NDArray | None = None, config: dict[str, Any] | N ) ) - return np.array([float(perf)]) + return SimulationResult(np.array([float(perf)])) def optimize( self, starting_point: npt.NDArray | None = None, config: dict[str, Any] | None = None diff --git a/engibench/problems/photonics2d/v0.py b/engibench/problems/photonics2d/v0.py index 0b0815db..e22f1c67 100644 --- a/engibench/problems/photonics2d/v0.py +++ b/engibench/problems/photonics2d/v0.py @@ -37,6 +37,7 @@ from engibench.core import ObjectiveDirection from engibench.core import OptiStep from engibench.core import Problem +from engibench.core import SimulationResult from engibench.problems.photonics2d.backend import epsr_parameterization # --- EngiBench Problem-Specific Backend --- @@ -232,7 +233,7 @@ def _run_fdfd( return epsr, ez1, ez2, source1, source2, probe1, probe2 - def simulate(self, design: npt.NDArray, config: dict[str, Any] | None = None, **kwargs) -> npt.NDArray: # noqa: ARG002 + def simulate_verbose(self, design: npt.NDArray, config: dict[str, Any] | None = None, **kwargs) -> SimulationResult: # noqa: ARG002 """Simulates the performance of a design, returning the raw objective value. Stores simulation fields (`Ez1`, `Ez2`, `epsr`) internally in `_last_Ez1`, @@ -244,7 +245,7 @@ def simulate(self, design: npt.NDArray, config: dict[str, Any] | None = None, ** **kwargs: Additional keyword arguments (ignored). Returns: - npt.NDArray: 1-element array: [total_overlap - penalty], where higher is better. + SimulationResult: Containing an 1-element array: [total_overlap - penalty], where higher is better. """ conditions = self._setup_simulation(config) @@ -267,7 +268,7 @@ def simulate(self, design: npt.NDArray, config: dict[str, Any] | None = None, ** penalty_weight = conditions.get("penalty_weight", self._penalty_weight_default) penalty = penalty_weight * np.linalg.norm(design) - return np.array([total_overlap - penalty], dtype=np.float64) + return SimulationResult(np.array([total_overlap - penalty], dtype=np.float64)) def optimize( # noqa: PLR0915 self, diff --git a/engibench/problems/power_electronics/v0.py b/engibench/problems/power_electronics/v0.py index 300ca648..df66fa93 100644 --- a/engibench/problems/power_electronics/v0.py +++ b/engibench/problems/power_electronics/v0.py @@ -16,6 +16,7 @@ from engibench.core import ObjectiveDirection from engibench.core import Problem +from engibench.core import SimulationResult from engibench.problems.power_electronics.utils.config import Config from engibench.problems.power_electronics.utils.netlist_handler import parse_topology from engibench.problems.power_electronics.utils.netlist_handler import rewrite_netlist @@ -88,7 +89,7 @@ def __init__( ) self.ngspice_path = ngspice_path - def simulate(self, design: npt.NDArray, config: dict[str, Any] | None = None) -> npt.NDArray: # noqa: ARG002 + def simulate_verbose(self, design: npt.NDArray, config: dict[str, Any] | None = None) -> SimulationResult: # noqa: ARG002 """Simulates the performance of a Power Electronics design. Args: @@ -103,7 +104,7 @@ def simulate(self, design: npt.NDArray, config: dict[str, Any] | None = None) -> config: ignored Returns: - simulation_results: a numpy array containing the simulation results [DcGain, VoltageRipple, Efficiency]. + An instance of `SimulationResult` containing `objective_values=[DcGain, VoltageRipple]`. """ self.config, rewrite_netlist_str, edge_map, _ = parse_topology(self.config) self.config = process_sweep_data(config=self.config, sweep_data=design.tolist()) @@ -112,7 +113,7 @@ def simulate(self, design: npt.NDArray, config: dict[str, Any] | None = None) -> ngspice = NgSpice(ngspice_windows_path=self.ngspice_path) ngspice.run(self.config.rewrite_netlist_path, self.config.log_file_path) DcGain, VoltageRipple = process_log_file(self.config.log_file_path) - return np.array([DcGain, VoltageRipple]) + return SimulationResult(np.array([DcGain, VoltageRipple])) def optimize(self, starting_point: npt.NDArray, config: dict[str, Any] | None = None) -> NoReturn: """Optimize the design variable. Not applicable for this problem.""" diff --git a/engibench/problems/thermoelastic2d/v0.py b/engibench/problems/thermoelastic2d/v0.py index 61c701d8..e79438b3 100644 --- a/engibench/problems/thermoelastic2d/v0.py +++ b/engibench/problems/thermoelastic2d/v0.py @@ -19,6 +19,7 @@ from engibench.core import ObjectiveDirection from engibench.core import OptiStep from engibench.core import Problem +from engibench.core import SimulationResult from engibench.problems.thermoelastic2d.model import fea_model from engibench.problems.thermoelastic2d.model.fea_model import FeaModel from engibench.problems.thermoelastic2d.utils import get_res_bounds @@ -110,15 +111,15 @@ def reset(self, seed: int | None = None) -> None: """ super().reset(seed) - def simulate(self, design: npt.NDArray, config: dict[str, Any] | None = None) -> npt.NDArray: - """Simulates the performance of a design topology. + def simulate_verbose(self, design: npt.NDArray, config: dict[str, Any] | None = None) -> SimulationResult: + r"""Launch a simulation on the given design topology and return the performance. Args: design (np.ndarray): The design to simulate. config (dict): A dictionary with configuration (e.g., boundary conditions, filenames) for the simulation. Returns: - dict: The performance of the design - each entry of the dict corresponds to a named objective value. + A `SimulationResult` instance. """ boundary_dict = dataclasses.asdict(self.conditions) for key, value in (config or {}).items(): @@ -129,7 +130,9 @@ def simulate(self, design: npt.NDArray, config: dict[str, Any] | None = None) -> boundary_dict[key] = value results = FeaModel(plot=False, eval_only=True).run(boundary_dict, x_init=design) - return np.array([results["structural_compliance"], results["thermal_compliance"], results["volume_fraction_error"]]) + return SimulationResult( + np.array([results["structural_compliance"], results["thermal_compliance"], results["volume_fraction_error"]]) + ) def optimize( self, starting_point: npt.NDArray, config: dict[str, Any] | None = None diff --git a/engibench/problems/thermoelastic3d/v0.py b/engibench/problems/thermoelastic3d/v0.py index cf2aafa7..1b2dadb0 100644 --- a/engibench/problems/thermoelastic3d/v0.py +++ b/engibench/problems/thermoelastic3d/v0.py @@ -17,6 +17,7 @@ from engibench.core import ObjectiveDirection from engibench.core import OptiStep from engibench.core import Problem +from engibench.core import SimulationResult from engibench.problems.thermoelastic3d.model import fem_model from engibench.problems.thermoelastic3d.model.fem_model import FeaModel3D @@ -132,15 +133,15 @@ def reset(self, seed: int | None = None) -> None: """ super().reset(seed) - def simulate(self, design: npt.NDArray, config: dict[str, Any] | None = None) -> npt.NDArray: - """Simulates the performance of a design topology. + def simulate_verbose(self, design: npt.NDArray, config: dict[str, Any] | None = None) -> SimulationResult: + r"""Launch a simulation on the given design topology and return the performance. Args: design (np.ndarray): The design to simulate. config (dict): A dictionary with configuration (e.g., boundary conditions, filenames) for the simulation. Returns: - dict: The performance of the design - each entry of the dict corresponds to a named objective value. + A `SimulationResult` instance. """ boundary_dict = dataclasses.asdict(self.conditions) for key, value in (config or {}).items(): @@ -151,7 +152,9 @@ def simulate(self, design: npt.NDArray, config: dict[str, Any] | None = None) -> boundary_dict[key] = value results = FeaModel3D(plot=False, eval_only=True).run(boundary_dict, x_init=design) - return np.array([results["structural_compliance"], results["thermal_compliance"], results["volume_fraction"]]) + return SimulationResult( + np.array([results["structural_compliance"], results["thermal_compliance"], results["volume_fraction"]]) + ) def optimize( self, starting_point: npt.NDArray, config: dict[str, Any] | None = None diff --git a/tests/test_problem_implementations.py b/tests/test_problem_implementations.py index 575b634a..17567006 100644 --- a/tests/test_problem_implementations.py +++ b/tests/test_problem_implementations.py @@ -52,7 +52,9 @@ def test_problem_impl(problem_class: type[Problem]) -> None: for name, member in inspect.getmembers(type(problem)) if inspect.isfunction(member) and member.__qualname__.startswith(type(problem).__name__ + ".") } - assert "simulate" in class_methods, f"Problem {problem_class.__name__}: The simulate method should be implemented." + assert "simulate_verbose" in class_methods, ( + f"Problem {problem_class.__name__}: The simulate_verbose method should be implemented." + ) assert "render" in class_methods, f"Problem {problem_class.__name__}: The render method should be implemented." assert "random_design" in class_methods, ( f"Problem {problem_class.__name__}: The random_design method should be implemented." @@ -137,7 +139,7 @@ def test_python_problem_impl(problem_class: type[Problem]) -> None: try: optimal_design, history = problem.optimize(starting_point=design, config=max_iter_config) except NotImplementedError: - print("Problem class {problem_class.__name__} does not implement optimize - Skipping optimize") + print(f"Problem class {problem_class.__name__} does not implement optimize - Skipping optimize") return if isinstance(problem.design_space, spaces.Box): assert np.all(optimal_design >= problem.design_space.low), (