Source code for engibench.core
"""Core API for Problem and other base classes."""
from __future__ import annotations
import dataclasses
from enum import auto
from enum import Enum
from typing import Any, Generic, TYPE_CHECKING, TypeVar
from datasets import Dataset
from datasets import load_dataset
import numpy as np
import numpy.typing as npt
from engibench import constraint
if TYPE_CHECKING:
from collections.abc import Sequence
from gymnasium import spaces
DesignType = TypeVar("DesignType")
@dataclasses.dataclass
class OptiStep:
"""Optimization step."""
obj_values: npt.NDArray
step: int
class ObjectiveDirection(Enum):
"""Direction of the objective function."""
MINIMIZE = auto()
MAXIMIZE = auto()
[docs]
class Problem(Generic[DesignType]):
r"""Main class for defining an engineering design problem.
This class assumes there is:
- an underlying simulator that is called to evaluate the performance of a design (see `simulate` method);
- a dataset containing representations of designs and their performances (see `design_space`, `dataset_id` attributes);
The main API methods that users should use are:
- :meth:`simulate` - to simulate a design and return the performance 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.
- :meth:`render` - to render a design in a human-readable format.
- :meth:`random_design` - to generate a valid random design.
There are some attributes that help understanding the problem:
- :attr:`objectives` - a dictionary with the names of the objectives and their types (minimize or maximize).
- :attr:`conditions` - the conditions for the design problem.
- :attr:`design_space` - the space of designs (outputs of algorithms).
- :attr:`dataset_id` - a string identifier for the problem -- useful to pull datasets.
- :attr:`dataset` - the dataset with designs and performances.
- :attr:`container_id` - a string identifier for the singularity container.
Having all these defined in the code allows to easily extract the columns we want from the dataset to train ML models.
Note:
This class is generic and should be subclassed to define the specific problem.
Note:
This class is parameterized with `DesignType` is the type of the design that is optimized (e.g. a Numpy array representing the design).
Note:
Some simulators also ask for simulator related configurations. These configurations are generally defined in the
problem implementation, do not appear in the `problem.conditions`, but sometimes appear in the dataset (for
advanced usage). You can override them by using the `config` argument in the `simulate` or `optimize` method.
"""
# Must be defined in subclasses
version: int
"""Version of the problem"""
objectives: tuple[tuple[str, ObjectiveDirection], ...]
"""Objective names and types (minimize or maximize)"""
conditions: tuple[tuple[str, Any], ...]
"""Conditions for the design problem"""
design_space: spaces.Space[DesignType]
"""Design space (algorithm output)"""
dataset_id: str
"""String identifier for the problem (useful to pull datasets)"""
design_constraints: tuple[constraint.Constraint, ...] = ()
"""Additional constraints for designs"""
_dataset: Dataset | None = None
"""Dataset with designs and performances"""
container_id: str | None
"""String identifier for the singularity container"""
Config: type | None = None
"""Dataclass declaring types, defaults (optional) and constraints"""
# This handles the RNG properly
np_random: np.random.Generator
def __init__(self, **kwargs: Any) -> None:
"""Initialize the problem.
Args:
**kwargs: Keyword arguments.
"""
self.reset(**kwargs)
@property
def dataset(self) -> Dataset:
"""Pulls the dataset if it is not already loaded."""
if self._dataset is None:
self._dataset = load_dataset(self.dataset_id)
return self._dataset
@property
def conditions_dict(self) -> dict[str, Any]:
"""Returns the conditions as a dictionary."""
return dict(self.conditions)
@property
def conditions_keys(self) -> list[str]:
"""Returns the condition names as a list."""
return [name for name, _ in self.conditions]
@property
def objectives_keys(self) -> list[str]:
"""Returns the objective names as a list."""
return [name for name, _ in self.objectives]
[docs]
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.
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:
np.array: The performance of the design -- each entry corresponds to an objective value.
"""
raise NotImplementedError
[docs]
def optimize(
self, starting_point: DesignType, config: dict[str, Any] | None = None
) -> tuple[DesignType, Sequence[OptiStep]]:
r"""Some simulators have built-in optimization. This function optimizes the design starting from `starting_point`.
This is optional and will probably be implemented only for some problems.
Args:
starting_point (DesignType): The starting point for the optimization.
config (dict): A dictionary with configuration (e.g., boundary conditions, filenames) for the optimization.
Returns:
Tuple[DesignType, list[OptiStep]]: The optimized design and the optimization history.
"""
raise NotImplementedError
[docs]
def reset(self, seed: int | None = None) -> None:
r"""Reset the simulator and numpy random to a given seed.
Args:
seed (int, optional): The seed to reset to. If None, a random seed is used.
"""
self.seed = seed
self.np_random = np.random.default_rng(seed)
[docs]
def render(self, design: DesignType, *, open_window: bool = False) -> Any:
r"""Render the design in a human-readable format.
Args:
design (DesignType): The design to render.
open_window (bool): Whether to open a window to display the design.
Returns:
Any: The rendered design.
"""
raise NotImplementedError
[docs]
def random_design(self) -> tuple[DesignType, int]:
r"""Generate a random design.
Returns:
DesignType: The random design.
idx: The index of the design in the dataset.
"""
raise NotImplementedError
[docs]
def check_constraints(self, design: DesignType, config: dict[str, Any]) -> constraint.Violations:
"""Check if config and design violate any constraints declared in `Config` and `design_space`.
Return a :class:`constraint.Violations` object containing all violations.
"""
if self.Config is not None:
checked_config = self.Config(**config)
violations = constraint.check_field_constraints(checked_config)
else:
violations = constraint.Violations([], 0)
@constraint.constraint
def design_constraint(design: DesignType) -> None:
assert self.design_space.contains(design), "design ∉ design_space"
violations.n_constraints += 1 + len(self.design_constraints)
for c in (design_constraint, *self.design_constraints):
design_violation = c.check_value(design)
if design_violation is not None:
violations.violations.append(design_violation)
return violations