Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add support for non_parametric optimization #100

Merged
merged 1 commit into from
Nov 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ New:

Fixes:

- The method :py:meth:`Optimization.run()<ansys.simai.core.data.optimizations.OptimizationDirectory.run>` now raises an exception if no workspace is provided and none is configured.
- The method `Optimization.run()<ansys.simai.core.data.optimizations.OptimizationDirectory.run>` now raises an exception if no workspace is provided and none is configured.
- Fix RecursionError on authentication refresh

0.1.5 (2024-04-15)
Expand Down
25 changes: 13 additions & 12 deletions doc/source/api_reference/optimizations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,22 @@ Model
:members:
:inherited-members:

TrialRuns
=========
..
TrialRuns
=========

Trial runs are a single step of the optimization process.
Trial runs are a single step of the optimization process.

Directory
---------
Directory
---------

.. autoclass:: OptimizationTrialRunDirectory()
:members:
.. autoclass:: __OptimizationTrialRunDirectory()
:members:


Model
-----
Model
-----

.. autoclass:: OptimizationTrialRun()
:members:
:inherited-members:
.. autoclass:: __OptimizationTrialRun()
:members:
:inherited-members:
8 changes: 3 additions & 5 deletions src/ansys/simai/core/api/optimization.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,10 @@ class OptimizationClientMixin(ApiClientMixin):
def define_optimization(self, workspace_id: str, optimization_parameters: Dict):
return self._post(f"workspaces/{workspace_id}/optimizations", json=optimization_parameters)

def run_optimization_trial(
self, optimization_id: str, geometry_id: str, geometry_parameters: Dict
):
def run_optimization_trial(self, optimization_id: str, parameters: Dict):
return self._post(
f"optimizations/{optimization_id}/trial-runs/{geometry_id}",
json=geometry_parameters,
f"optimizations/{optimization_id}/trial-runs",
json=parameters,
)

def get_optimization(self, optimization_id: str):
Expand Down
4 changes: 2 additions & 2 deletions src/ansys/simai/core/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
from ansys.simai.core.data.models import ModelDirectory
from ansys.simai.core.data.optimizations import (
OptimizationDirectory,
OptimizationTrialRunDirectory,
_OptimizationTrialRunDirectory,
)
from ansys.simai.core.data.post_processings import PostProcessingDirectory
from ansys.simai.core.data.predictions import PredictionDirectory
Expand Down Expand Up @@ -87,7 +87,7 @@ def __init__(self, **kwargs):
self._compute_gc_formula_directory = ComputeGlobalCoefficientDirectory(client=self)
self._geometry_directory = GeometryDirectory(client=self)
self._optimization_directory = OptimizationDirectory(client=self)
self._optimization_trial_run_directory = OptimizationTrialRunDirectory(client=self)
self._optimization_trial_run_directory = _OptimizationTrialRunDirectory(client=self)
self._post_processing_directory = PostProcessingDirectory(client=self)
self._project_directory = ProjectDirectory(client=self)
self._model_directory = ModelDirectory(client=self)
Expand Down
199 changes: 149 additions & 50 deletions src/ansys/simai/core/data/optimizations.py
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since I could not wrap my head around the interaction of the different components I made this:

sequenceDiagram
    participant User
    participant OptDir as OptimizationDirectory
    participant TrialDir as OptimizationTrialRunDirectory

    User ->> OptDir: run_parametric
    activate User
    activate OptDir
    create participant Opti as Optimization
    OptDir -x Opti: creates


    loop Until optimization complete
        OptDir->>Opti: _run_iteration
        activate Opti
        Opti->>TrialDir: _run_iteration
        activate TrialDir
        create participant Trial as OptimizationTrialRun
        TrialDir-xTrial: Creates
        activate Trial
        TrialDir-->>Opti: returns OptimizationTrialRun
        deactivate TrialDir
        Opti-->>OptDir: returns OptimizationTrialRun
        deactivate Opti
        OptDir->>Trial: Get iteration results
        deactivate Trial
    end
    deactivate OptDir

    OptDir-->>User: result (List[Dict])
    deactivate User
Loading

Conclusions:

  • Some objects are not meant to be handled by end users (OptimizationTrialRun and OptimizationTrialRunDirectory so should be prefixed by "_")
  • The relationships between objects must be better defined in the code, even in the definition order, define the children first: OptimizationTrialRun then OptimizationTrialRunDirectory then Optimization then OptimizationDirectory
  • Maybe _run_iteration should only be defined on the Optimization ?
  • I don't understand what OptimizationTrialRunDirectory.get could be used for

Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

import logging
from inspect import signature
from typing import Callable, Dict, List, Optional, Tuple
from typing import Callable, Dict, List, Literal, Optional, Tuple

from tqdm import tqdm
from wakepy import keep
Expand All @@ -33,32 +33,46 @@
Identifiable,
NamedFile,
get_id_from_identifiable,
get_object_from_identifiable,
)
from ansys.simai.core.data.workspaces import Workspace
from ansys.simai.core.errors import InvalidArguments

logger = logging.getLogger(__name__)


class Optimization(ComputableDataModel):
"""Provides the local representation of an optimization definition object."""

def _try_geometry(
self, geometry: Identifiable[Geometry], geometry_parameters: Dict
) -> "OptimizationTrialRun":
return self._client._optimization_trial_run_directory._try_geometry(
self.id, geometry, geometry_parameters
)


class OptimizationTrialRun(ComputableDataModel):
class _OptimizationTrialRun(ComputableDataModel):
"""Provides the local representation of an optimization trial run object.

The optimization trial run is an iteration of the optimization process.
Each trial run tests a geometry and returns new parameters for the next geometry to try.
"""


# Undocumented for now, users don't really need to interact with it
class _OptimizationTrialRunDirectory(Directory[_OptimizationTrialRun]):
_data_model = _OptimizationTrialRun

def get(self, trial_run_id: str):
"""Get a specific trial run from the server."""
return self._model_from(self._client._api.get_optimization_trial_run(trial_run_id))

Check warning on line 58 in src/ansys/simai/core/data/optimizations.py

View check run for this annotation

Codecov / codecov/patch

src/ansys/simai/core/data/optimizations.py#L58

Added line #L58 was not covered by tests

def _run_iteration(
self, optimization: Identifiable["Optimization"], parameters: Dict
) -> _OptimizationTrialRun:
optimization_id = get_id_from_identifiable(optimization)
return self._model_from(
self._client._api.run_optimization_trial(optimization_id, parameters)
)


class Optimization(ComputableDataModel):
"""Provides the local representation of an optimization definition object."""

def _run_iteration(self, parameters: Dict) -> "_OptimizationTrialRun":
return self._client._optimization_trial_run_directory._run_iteration(self.id, parameters)


class OptimizationDirectory(Directory[Optimization]):
"""Provides a collection of methods related to optimizations.

Expand Down Expand Up @@ -86,7 +100,7 @@
"""
return self._model_from(self._client._api.get_optimization(optimization_id))

def run(
def run_parametric(
self,
geometry_generation_fn: Callable[..., NamedFile],
geometry_parameters: Dict[str, Tuple[float, float]],
Expand All @@ -98,7 +112,7 @@
show_progress: bool = False,
workspace: Optional[Identifiable[Workspace]] = None,
) -> List[Dict]:
"""Run an optimization process.
"""Run an optimization loop with client-side parametric geometry generation.

Args:
geometry_generation_fn: Function to call to generate a new geometry
Expand All @@ -107,6 +121,7 @@
geometry_parameters: Name of the geometry parameters and their bounds or possible values (choices).
boundary_conditions: Values of the boundary conditions to perform the optimization at.
The values should map to existing boundary conditions in your project/workspace.
n_iters: Number of iterations of the optimization loop.
minimize: List of global coefficients to minimize.
The global coefficients should map to existing coefficients in your project/workspace.
maximize: List of global coefficients to maximize.
Expand All @@ -119,7 +134,6 @@
- ``x`` is a float bound.
- The comparison operator is ``>=`` or ``<=``.

n_iters: Number of iterations of the optimization loop.
show_progress: Whether to print progress on stdout.
workspace: Workspace to run the optimization in. If a workspace is
not specified, the default is the configured workspace.
Expand Down Expand Up @@ -162,22 +176,18 @@
print(results)
"""
workspace_id = get_id_from_identifiable(workspace, True, self._client._current_workspace)
_check_geometry_generation_fn_signature(geometry_generation_fn, geometry_parameters)
if not minimize and not maximize:
raise InvalidArguments("No global coefficient to optimize.")
objective = {}
if minimize:
for global_coefficient in minimize:
objective[global_coefficient] = {"minimize": True}
if maximize:
for global_coefficient in maximize:
objective[global_coefficient] = {"minimize": False}
_validate_geometry_parameters(geometry_parameters)
_validate_geometry_generation_fn_signature(geometry_generation_fn, geometry_parameters)
objective = _build_objective(minimize, maximize)
optimization_parameters = {
"boundary_conditions": boundary_conditions,
"geometry_parameters": geometry_parameters,
"n_iters": n_iters,
"objective": objective,
"type": "parametric",
"outcome_constraints": outcome_constraints or [],
"geometry_generation": {
"geometry_parameters": geometry_parameters,
},
}
with tqdm(total=n_iters, disable=not show_progress) as progress_bar:
progress_bar.set_description("Creating optimization definition.")
Expand All @@ -204,7 +214,9 @@
)
logger.debug("Running trial.")
progress_bar.set_description("Running trial.")
trial_run = optimization._try_geometry(geometry, geometry_parameters)
trial_run = optimization._run_iteration(
{"geometry": geometry.id, "geometry_parameters": geometry_parameters}
)
trial_run.wait()
iteration_result = {
"parameters": geometry_parameters,
Expand All @@ -222,33 +234,120 @@
progress_bar.set_description("Optimization complete.")
return iterations_results

def run_non_parametric(
self,
geometry: Identifiable[Geometry],
bounding_boxes: List[List[float]],
symmetries: List[Literal["x", "y", "z", "X", "Y", "Z"]],
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the value of typing for both upper and lower case is not worth it compared to "Hugh ? Why am I seeing double ?"

boundary_conditions: Dict[str, float],
n_iters: int,
minimize: Optional[List[str]] = None,
maximize: Optional[List[str]] = None,
show_progress: bool = False,
):
"""Run an optimization loop with server-side geometry generation using automorphism.

Args:
geometry: The base geometry on which to perform the automorphism. The optimization will
run in the same workspace as the geometry.
bounding_boxes: list of the bounds of the different boxes that will define the locations
of the geometry to optimize.
The format is [
[box1_xmin, box1_xmax, box1_ymin, box1_ymax, box1_zmin, box1_zmax],
[box2_xmin, box2_xmax, box2_ymin, box2_ymax, box2_zmin, box2_zmax],
...
]
symmetries: list of symmetry axes, axes being x, y or z
boundary_conditions: Values of the boundary conditions to perform the optimization at.
The values should map to existing boundary conditions in your project/workspace.
n_iters: Number of iterations of the optimization loop.
minimize: List of global coefficients to minimize.
The global coefficients should map to existing coefficients in your project/workspace.
maximize: List of global coefficients to maximize.
The global coefficients should map to existing coefficients in your project/workspace.
show_progress: Whether to print progress on stdout.
workspace: Workspace to run the optimization in. If a workspace is
not specified, the default is the configured workspace.

Warning:
This feature is in beta. Results are not guaranteed.
"""
geometry = get_object_from_identifiable(geometry, self._client._geometry_directory)
objective = _build_objective(minimize, maximize)
optimization_parameters = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since optimization parameters is used both in run_parametric and run_non_parametric with small differences, it might deserve to have a function or class for its construction, according to parametric or non-parametric. Maye it would save some a few lines of code too 😄

"boundary_conditions": boundary_conditions,
"n_iters": n_iters,
"objective": objective,
"type": "non_parametric",
"geometry_generation": {
"geometry": geometry.id,
"box_bounds_list": bounding_boxes,
"symmetries": symmetries,
},
}
with tqdm(total=n_iters, disable=not show_progress) as progress_bar:
progress_bar.set_description("Creating optimization definition.")
optimization = self._model_from(
self._client._api.define_optimization(
geometry._fields["workspace_id"], optimization_parameters
)
)
optimization.wait()
logger.debug("Optimization defined. Starting optimization loop.")
iterations_results: List[Dict] = []
with keep.running(on_fail="warn"):
for _ in range(n_iters):
logger.debug("Running iteration")
progress_bar.set_description("Running iteration")
trial_run = optimization._run_iteration({})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This {} arg could really use a validation with a raise InvalidArguments

trial_run.wait()
iteration_result = {
"objective": trial_run.fields["outcome_values"],
}
progress_bar.set_postfix(**iteration_result)
if trial_run.fields.get("is_feasible", True):
iterations_results.append(iteration_result)
else:
logger.debug("Trial run results did not match constraints. Skipping.")

Check warning on line 311 in src/ansys/simai/core/data/optimizations.py

View check run for this annotation

Codecov / codecov/patch

src/ansys/simai/core/data/optimizations.py#L311

Added line #L311 was not covered by tests
tmpbeing marked this conversation as resolved.
Show resolved Hide resolved
logger.debug("Trial complete.")
progress_bar.update(1)
logger.debug("Optimization complete.")
progress_bar.set_description("Optimization complete.")
return iterations_results


def _validate_geometry_parameters(params: Dict):
if not isinstance(params, Dict):
raise InvalidArguments("geometry_parameters: must be a dict.")

Check warning on line 321 in src/ansys/simai/core/data/optimizations.py

View check run for this annotation

Codecov / codecov/patch

src/ansys/simai/core/data/optimizations.py#L321

Added line #L321 was not covered by tests
tmpbeing marked this conversation as resolved.
Show resolved Hide resolved
if not params:
raise InvalidArguments("geometry_parameters: must not be empty.")

Check warning on line 323 in src/ansys/simai/core/data/optimizations.py

View check run for this annotation

Codecov / codecov/patch

src/ansys/simai/core/data/optimizations.py#L323

Added line #L323 was not covered by tests
tmpbeing marked this conversation as resolved.
Show resolved Hide resolved
for key, value in params.items():
bounds = value.get("bounds")
choices = value.get("choices")
if not bounds and not choices:
raise InvalidArguments(f"geometry_parameters: no bounds or choices specified for {key}")

Check warning on line 328 in src/ansys/simai/core/data/optimizations.py

View check run for this annotation

Codecov / codecov/patch

src/ansys/simai/core/data/optimizations.py#L328

Added line #L328 was not covered by tests
tmpbeing marked this conversation as resolved.
Show resolved Hide resolved
if bounds and choices:
raise InvalidArguments(

Check warning on line 330 in src/ansys/simai/core/data/optimizations.py

View check run for this annotation

Codecov / codecov/patch

src/ansys/simai/core/data/optimizations.py#L330

Added line #L330 was not covered by tests
f"geometry_parameters: only one of bounds or choices must be specified for {key}"
tmpbeing marked this conversation as resolved.
Show resolved Hide resolved
)

def _check_geometry_generation_fn_signature(geometry_generation_fn, geometry_parameters):

def _validate_geometry_generation_fn_signature(geometry_generation_fn, geometry_parameters):
geometry_generation_fn_args = signature(geometry_generation_fn).parameters
if geometry_generation_fn_args.keys() != geometry_parameters.keys():
raise InvalidArguments(
f"geometry_generation_fn requires the following signature: {list(geometry_parameters.keys())}, but got: {list(geometry_generation_fn_args.keys())}"
)


# Undocumented for now, users don't really need to interact with it
class OptimizationTrialRunDirectory(Directory[OptimizationTrialRun]):
_data_model = OptimizationTrialRun

def get(self, trial_run_id: str):
"""Get a specific trial run from the server."""
return self._model_from(self._client._api.get_optimization_trial_run(trial_run_id))

def _try_geometry(
self,
optimization: Identifiable[Optimization],
geometry: Identifiable[Geometry],
geometry_parameters: Dict,
) -> OptimizationTrialRun:
geometry_id = get_id_from_identifiable(geometry)
optimization_id = get_id_from_identifiable(optimization)
return self._model_from(
self._client._api.run_optimization_trial(
optimization_id, geometry_id, geometry_parameters
)
)
def _build_objective(minimize: List[str], maximize: List[str]) -> Dict:
if not minimize and not maximize:
raise InvalidArguments("No global coefficient to optimize.")

Check warning on line 345 in src/ansys/simai/core/data/optimizations.py

View check run for this annotation

Codecov / codecov/patch

src/ansys/simai/core/data/optimizations.py#L345

Added line #L345 was not covered by tests
objective = {}
if minimize:
for global_coefficient in minimize:
objective[global_coefficient] = {"minimize": True}
if maximize:
for global_coefficient in maximize:
objective[global_coefficient] = {"minimize": False}

Check warning on line 352 in src/ansys/simai/core/data/optimizations.py

View check run for this annotation

Codecov / codecov/patch

src/ansys/simai/core/data/optimizations.py#L351-L352

Added lines #L351 - L352 were not covered by tests
return objective
Loading
Loading