"""Defines design space specification for tidy3d."""
from __future__ import annotations
import inspect
from typing import Any, Callable, Dict, List, Tuple, Union
import pydantic.v1 as pd
from ...components.base import TYPE_TAG_STR, Tidy3dBaseModel, cached_property
from ...components.data.sim_data import SimulationData
from ...components.simulation import Simulation
from ...log import Console, get_logging_console, log
from ...web.api.container import Batch, BatchData, Job
from .method import (
MethodBayOpt,
MethodGenAlg,
MethodOptimize,
MethodParticleSwarm,
MethodType,
)
from .parameter import ParameterAny, ParameterInt, ParameterType
from .result import Result
[docs]
class DesignSpace(Tidy3dBaseModel):
"""Manages all exploration of a parameter space within specified parameters using a supplied search method.
The ``DesignSpace`` forms the basis of the ``Design`` plugin, and receives a ``Method`` and ``Parameter`` list that
define the scope of the design space and how it should be searched. ``DesignSpace.run()`` can then be called with
a function(s) to generate different solutions from parameters suggested by the ``Method``. The ``Method`` can either
sample the design space systematically or randomly, or can optimize for a given problem through an iterative search
and evaluate approach.
Notes
-----
Schematic outline of how to use the ``Design`` plugin to explore a design space.
.. image:: ../../../../_static/img/design.png
:width: 80%
:align: center
The `Design <https://www.flexcompute.com/tidy3d/examples/notebooks/Design/>`_ notebook contains an overview of the
``Design`` plugin and is the best place to learn how to get started.
Detailed examples using the ``Design`` plugin can be found in the following notebooks:
* `All-Dielectric Structural Colors <https://www.flexcompute.com/tidy3d/examples/notebooks/AllDielectricStructuralColor/>`_
* `Bayesian Optimization of Y-Junction <https://www.flexcompute.com/tidy3d/examples/notebooks/BayesianOptimizationYJunction/>`_
* `Genetic Algorithm Reflector <https://www.flexcompute.com/tidy3d/examples/notebooks/GeneticAlgorithmReflector/>`_
* `Particle Swarm Optimizer PBS <https://www.flexcompute.com/tidy3d/examples/notebooks/ParticleSwarmOptimizedPBS/>`_
* `Particle Swarm Optimizer Bullseye Cavity <https://www.flexcompute.com/tidy3d/examples/notebooks/BullseyeCavityPSO/>`_
Example
-------
>>> import tidy3d.plugins.design as tdd
>>> param = tdd.ParameterFloat(name="x", span=(0,1))
>>> method = tdd.MethodMonteCarlo(num_points=10)
>>> design_space = tdd.DesignSpace(parameters=[param], method=method)
>>> fn = lambda x: x**2
>>> result = design_space.run(fn)
>>> df = result.to_dataframe()
>>> im = df.plot()
"""
parameters: Tuple[ParameterType, ...] = pd.Field(
(),
title="Parameters",
description="Set of parameters defining the dimensions and allowed values for the design space.",
)
method: MethodType = pd.Field(
...,
title="Search Type",
description="Specifications for the procedure used to explore the parameter space.",
discriminator=TYPE_TAG_STR, # Stops pydantic trying to validate every method whilst checking MethodType
)
task_name: str = pd.Field(
"",
title="Task Name",
description="Task name assigned to tasks along with a simulation counter in the form of {task_name}_{sim_index}_{counter} where ``sim_index`` is "
"the index of the ``Simulation`` from the pre function output. "
"If the pre function outputs a dictionary the key will be included in the task name as {task_name}_{dict_key}_{counter}. "
"Only used when pre-post functions are supplied.",
)
name: str = pd.Field(None, title="Name", description="Optional name for the design space.")
path_dir: str = pd.Field(
".",
title="Path Directory",
description="Directory where simulation data files will be locally saved to. Only used when pre and post functions are supplied.",
)
folder_name: str = pd.Field(
"default",
title="Folder Name",
description="Folder path where the simulation will be uploaded in the Tidy3D Workspace. Will use 'default' if no path is set.",
)
@cached_property
def dims(self) -> Tuple[str]:
"""dimensions defined by the design parameter names."""
return tuple(param.name for param in self.parameters)
def _package_run_results(
self,
fn_args: list[dict[str, Any]],
fn_values: List[Any],
fn_source: str,
task_names: Tuple[str] = None,
task_paths: list = None,
aux_values: List[Any] = None,
opt_output: Any = None,
) -> Result:
"""How to package results from ``method.run`` and ``method.run_batch``"""
fn_args_coords = tuple(
[[arg_dict[key] for arg_dict in fn_args] for key in fn_args[0].keys()]
)
fn_args_coords_T = list(map(list, zip(*fn_args_coords)))
return Result(
dims=tuple(fn_args[0].keys()),
values=fn_values,
coords=fn_args_coords_T,
fn_source=fn_source,
task_names=task_names,
task_paths=task_paths,
aux_values=aux_values,
optimizer=opt_output,
)
[docs]
@staticmethod
def get_fn_source(function: Callable) -> str:
"""Get the function source as a string, return ``None`` if not available."""
try:
return inspect.getsource(function)
except (TypeError, OSError):
return None
[docs]
def run(self, fn: Callable, fn_post: Callable = None, verbose: bool = True) -> Result:
"""Explore a parameter space with a supplied method using the user supplied function.
Supplied functions are used to evaluate the design space and are called within the method.
For optimization methods these functions act as the fitness function. A single function can be
supplied which will contain the preprocessing, computation, and analysis of the desired problem.
If running a Tidy3D simulation, it is recommended to split this function into a pre function, that creates a Simulation object(s),
and a post function which analyses the SimulationData produced by the pre function Simulations. This allows the DesignSpace to
manage the batching of Simulations, which varies between Method used, and saving time writing their own batching code.
It also efficiently submits simulations to the cloud servers allowing for the fastest exploration of a design space.
The ``fn`` function must take a dictionary input - this can be stored a dictionary ``def example_fn(**params)``
or left as keyword arguments ``def example_fn(arg1, arg2)`` where the keywords correspond to the ``name`` of the parameters in the design space.
If used as a pre function, the output of ``fn`` must be a float, ``Simulation``, ``Batch``, list, or dict. Supplied ``Batch`` objects are
run without modification and are run in series. A list or dict of ``Simulation`` objects is flattened into a single ``Batch`` to enable
parallel computation on the cloud. The original structure is then restored for output; all ``Simulation`` objects are replaced by ``SimulationData`` objects.
Example pre return formats and associated post inputs can be seen in the table below.
.. list-table:: Pre return formats and post input formats
:widths: 50 50
:header-rows: 1
* - fn_pre return
- fn_post call
* - 1.0
- fn_post(1.0)
* - [1,2,3]
- fn_post(1,2,3)
* - {'a': 2, 'b': 'hi'}
- fn_post(a=2, b='hi')
* - Simulation
- fn_post(SimulationData)
* - Batch
- fn_post(BatchData)
* - [Simulation, Simulation]
- fn_post(SimulationData, SimulationData)
* - [Simulation, 1.0]
- fn_post(SimulationData, 1.0)
* - [Simulation, Batch]
- fn_post(SimulationData, BatchData)
* - {'a': Simulation, 'b': Batch, 'c': 2.0}
- fn_post(a=SimulationData, b=BatchData, c=2.0)
The output of ``fn_post`` (or ``fn`` if only one function is supplied) must be a float
or a container where the first element is a ``float`` and second element is a ``list`` / ``dict`` e,g. [float {"aux_1": str}].
The float is used by the optimizers as the return of the fitness function.
The second element is for auxiliary data from the analysis that the user may want to keep.
Sampling methods (``MethodGrid`` or ``MethodMonteCarlo``) can have any return type.
Parameters
----------
fn : Callable
Function accepting arguments that correspond to the ``name`` fields
of the ``DesignSpace.parameters``.
Must return in the expected format for the ``method`` used in ``DesignSpace``,
or return an object that fn_post can accept as an input.
fn_post : Callable = None
Optional function performing postprocessing on the output of ``fn``.
It is recommended to supply fn_post when working with Simulation objects.
Must return in the expected format for the ``method`` used in ``DesignSpace``.
verbose : bool = True
Toggle the output of statements stored in the logging console.
Returns
-------
:class:`Result`
Object containing the results of the design space exploration.
Can be converted to ``pandas.DataFrame`` with ``.to_dataframe()``.
"""
# Get the console
# Method.run checks for console is None instead of being passed console and verbose
console = get_logging_console() if verbose else None
# Run based on how many functions the user provides
if fn_post is None:
fn_args, fn_values, aux_values, opt_output = self.run_single(fn, console)
sim_names = None
sim_paths = None
else:
fn_args, fn_values, aux_values, opt_output, sim_names, sim_paths = self.run_pre_post(
fn_pre=fn, fn_post=fn_post, console=console
)
if len(sim_names) == 0:
sim_names = None
fn_source = self.get_fn_source(fn)
# Package the result
return self._package_run_results(
fn_args=fn_args,
fn_values=fn_values,
fn_source=fn_source,
aux_values=aux_values,
task_names=sim_names,
task_paths=sim_paths,
opt_output=opt_output,
)
[docs]
def run_single(self, fn: Callable, console: Console) -> Tuple(list[dict], list, list[Any]):
"""Run a single function of parameter inputs."""
evaluate_fn = self._get_evaluate_fn_single(fn=fn)
return self.method._run(run_fn=evaluate_fn, parameters=self.parameters, console=console)
[docs]
def run_pre_post(self, fn_pre: Callable, fn_post: Callable, console: Console) -> Tuple(
list[dict], list[dict], list[Any]
):
"""Run a function with Tidy3D implicitly called in between."""
handler = self._get_evaluate_fn_pre_post(
fn_pre=fn_pre, fn_post=fn_post, fn_mid=self._fn_mid, console=console
)
fn_args, fn_values, aux_values, opt_output = self.method._run(
run_fn=handler.fn_combined, parameters=self.parameters, console=console
)
return fn_args, fn_values, aux_values, opt_output, handler.sim_names, handler.sim_paths
""" Helpers """
def _get_evaluate_fn_single(self, fn: Callable) -> list[Any]:
"""Get function that sequentially evaluates single `fn` for a list of arguments."""
def evaluate(args_list: list) -> list[Any]:
"""Evaluate a list of arguments passed to ``fn``."""
return [fn(**args) for args in args_list]
return evaluate
def _get_evaluate_fn_pre_post(
self, fn_pre: Callable, fn_post: Callable, fn_mid: Callable, console: Console
):
"""Get function that tries to use batch processing on a set of arguments."""
class Pre_Post_Handler:
def __init__(self, console):
self.sim_counter = 0
self.sim_names = []
self.sim_paths = []
self.console = console
def fn_combined(self, args_list: list[dict[str, Any]]) -> list[Any]:
"""Compute fn_pre and fn_post functions and capture other outputs."""
sim_dict = {str(idx): fn_pre(**arg_list) for idx, arg_list in enumerate(args_list)}
if not all(
isinstance(val, (int, float, Simulation, Batch, list, dict))
for val in sim_dict.values()
):
raise ValueError(
"Unrecognized output of fn_pre. Please change the return of fn_pre."
)
data, task_names, task_paths, sim_counter = fn_mid(
sim_dict, self.sim_counter, self.console
)
self.sim_names.extend(task_names)
self.sim_paths.extend(task_paths)
self.sim_counter = sim_counter
post_out = [fn_post(val) for val in data.values()]
return post_out
handler = Pre_Post_Handler(console)
return handler
def _fn_mid(
self, pre_out: dict[int, Any], sim_counter: int, console: Console
) -> Union[dict[int, Any], BatchData]:
"""A function of the output of ``fn_pre`` that gives the input to ``fn_post``."""
# Keep copy of original to use if no tidy3d simulation required
original_pre_out = pre_out.copy()
# Convert list input to dict of dict before passing through checks
was_list = False
if all(isinstance(sim, list) for sim in pre_out.values()):
pre_out = {
str(idx): {str(sub_idx): sub_list for sub_idx, sub_list in enumerate(sim_list)}
for idx, sim_list in enumerate(pre_out.values())
}
was_list = True
def _find_and_map(
search_dict: dict,
search_type: Any,
output_dict: dict,
naming_dict: dict,
previous_key: str = "",
):
"""Recursively search for search_type objects within a dictionary."""
current_key = previous_key
for key, value in search_dict.items():
if not len(previous_key):
latest_key = str(key)
else:
latest_key = f"{current_key}_{key}"
if isinstance(value, dict):
_find_and_map(value, search_type, output_dict, naming_dict, latest_key)
elif isinstance(value, search_type):
output_dict[latest_key] = value
naming_dict[latest_key] = key
simulations = {}
batches = {}
naming_keys = {}
_find_and_map(pre_out, Simulation, simulations, naming_keys)
_find_and_map(pre_out, Batch, batches, naming_keys)
# Exit fn_mid here if no td computation is required
if not len(simulations) and not len(batches):
return original_pre_out, list(), list(), sim_counter
# Create task names for simulations
named_sims = {}
translate_sims = {}
for sim_key, sim in simulations.items():
# Checks stop standard indexing keys being included in the name
suffix = f"{naming_keys[sim_key]}_{sim_counter}"
# Handle if the user does not want a task name
if len(self.task_name) > 0:
sim_name = f"{self.task_name}_{suffix}"
else:
sim_name = suffix
named_sims[sim_name] = sim
translate_sims[sim_name] = sim_key
sim_counter += 1
# Log the simulations and batches for the user
if console is not None:
# Writen like this to include batches on the same line if present
run_statement = f"{len(named_sims)} Simulations"
if len(batches) > 0:
run_statement = run_statement + f" and {len(batches)} user Batches"
console.log(f"Running {run_statement}")
# Running simulations and batches
sims_out = Batch(
simulations=named_sims,
folder_name=self.folder_name,
simulation_type="tidy3d_design",
verbose=False, # Using a custom output instead of Batch.monitor updates
).run(path_dir=self.path_dir)
batch_results = {}
for batch_key, batch in batches.items():
batch_out = batch.run(path_dir=self.path_dir)
batch_results[batch_key] = batch_out
def _return_to_dict(return_dict: dict, key: str, return_obj: Any) -> None:
"""Recursively insert items into a dict by keys split with underscore. Only works for dict or dict of dict inputs."""
split_key = key.split("_", 1)
if len(split_key) > 1:
_return_to_dict(return_dict[split_key[0]], split_key[1], return_obj)
else:
return_dict[split_key[0]] = return_obj
for (sim_name, sim), task_name, task_path in zip(
sims_out.items(), sims_out.task_ids.keys(), sims_out.task_paths.values()
):
translated_name = translate_sims[sim_name]
sim.attrs["task_name"] = task_name
sim.attrs["task_path"] = task_path
_return_to_dict(pre_out, translated_name, sim)
for batch_name, batch in batch_results.items():
_return_to_dict(pre_out, batch_name, batch)
def _remove_or_replace(search_dict: dict, attr_name: str) -> dict:
"""Recursively search through a dict replacing Sims and Batches or ignoring other items thus removing them"""
new_dict = {}
for key, value in search_dict.items():
if isinstance(value, dict):
new_sub_dict = _remove_or_replace(value, attr_name)
new_dict[key] = new_sub_dict
else:
if isinstance(value, SimulationData):
new_dict[key] = value.attrs[attr_name]
elif isinstance(value, BatchData):
if attr_name == "task_name":
new_dict[key] = list(value.task_ids.keys())
else:
new_dict[key] = list(value.task_paths.values())
return new_dict
# Build out a dict of task_name or task_path in the same shape as the original data
task_names = _remove_or_replace(pre_out.copy(), "task_name")
task_paths = _remove_or_replace(pre_out.copy(), "task_path")
# Reduce down to a list to be extended later
task_names = list(task_names.values())
task_paths = list(task_paths.values())
# Restore output to a list if a list was supplied
if was_list:
pre_out = {dict_idx: list(sub_dict.values()) for dict_idx, sub_dict in pre_out.items()}
task_names = [list(sub_dict.values()) for sub_dict in task_names]
task_paths = [list(sub_dict.values()) for sub_dict in task_paths]
return pre_out, task_names, task_paths, sim_counter
[docs]
def run_batch(
self,
fn_pre: Callable[Any, Union[Simulation, List[Simulation], Dict[str, Simulation]]],
fn_post: Callable[
Union[SimulationData, List[SimulationData], Dict[str, SimulationData]], Any
],
path_dir: str = ".",
**batch_kwargs,
) -> Result:
"""
This function has been superceded by `run`, please use `run` for batched simulations.
"""
log.warning(
"In version 2.8.0, the 'run_batch' method is replaced by 'run'."
"'fn_pre' has become 'fn', whilst 'fn_post' remains the same."
"While the original syntax will still be supported, future functionality will be added to the new method moving forward."
)
if len(batch_kwargs) > 0:
log.warning(
"'batch_kwargs' supplied here will no longer be used within the simulation."
)
new_self = self.updated_copy(path_dir=path_dir)
result = new_self.run(fn=fn_pre, fn_post=fn_post)
return result
[docs]
def estimate_cost(self, fn_pre: Callable) -> float:
"""Compute the maximum FlexCredit charge for the ``DesignSpace.run`` computation.
Require a pre function that should return a ``Simulation`` object, a ``Batch`` object, or collection of either.
The pre function is called to estimate the cost - complicated pre functions may cause long runtimes. The cost per
iteration is multiplied by the theoretical maximum number of iterations to give the maximum cost.
Parameters
----------
fn_pre : Callable
Function accepting arguments that correspond to the ``name`` fields
of the ``DesignSpace.parameters``. Should return a ``Simulation`` or ``Batch`` object, or a
``list`` / ``dict`` of these objects.
Returns
-------
float
Estimated maximum cost for the ``DesignSpace.run``.
"""
# Get output fn_pre for paramters at the lowest span / default
arg_dict = {}
for param in self.parameters:
arg_dict[param.name] = param.sample_first()
# Compute fn_pre
pre_out = fn_pre(**arg_dict)
def _estimate_sim_cost(sim):
job = Job(simulation=sim, task_name="estimate_cost")
estimate = job.estimate_cost()
job.delete() # Deleted as only a test with initial parameters
return estimate
if isinstance(pre_out, Simulation):
per_run_estimate = _estimate_sim_cost(pre_out)
elif isinstance(pre_out, Batch):
per_run_estimate = pre_out.estimate_cost()
pre_out.delete() # Deleted as only a test with initial parameters
elif isinstance(pre_out, (list, dict)):
# Iterate through container to get simulations and batches and sum cost
# Accept list or dict inputs
if isinstance(pre_out, dict):
pre_out = list(pre_out.values())
sims = []
batches = []
for value in pre_out:
if isinstance(value, Simulation):
sims.append(value)
elif isinstance(value, Batch):
batches.append(value)
calculated_estimates = []
for sim in sims:
calculated_estimates.append(_estimate_sim_cost(sim))
for batch in batches:
calculated_estimates.append(batch.estimate_cost())
batch.delete() # Deleted as only a test with initial parameters
if None in calculated_estimates:
per_run_estimate = None
else:
per_run_estimate = sum(calculated_estimates)
else:
raise ValueError("Unrecognized output from pre-function, unable to estimate cost.")
# Calculate maximum number of runs for different methods
run_count = self.method._get_run_count(self.parameters)
# For if tidy3d server cannot determine the estimate
if per_run_estimate is None:
return None
else:
return round(per_run_estimate * run_count, 3)
[docs]
def summarize(self, fn_pre: Callable = None, verbose: bool = True) -> dict[str, Any]:
"""Summarize the setup of the DesignSpace
Prints a summary of the DesignSpace including the method and associated args, the parameters,
and the maximum number of runs expected. If ``fn_pre`` is provided an estimated cost will
also be included. Additional notes are printed where relevant. All data is returned as a dict.
Parameters
----------
fn_pre : Callable = None
Function accepting arguments that correspond to the ``name`` fields
of the ``DesignSpace.parameters``. Allows for estimated cost to be included
in the summary.
verbose: bool = True
Toggle if the summary should be output to log. If False, the dict is returned silently.
Returns
-------
summary_dict: dict
Dictionary containing the summary information.
"""
# Get output console
console = get_logging_console()
# Assemble message
# If check stops it printing standard attributes
arg_values = [
f"{field}: {getattr(self.method, field)}\n"
for field in self.method.__fields__
if field not in MethodOptimize.__fields__
]
param_values = []
for param in self.parameters:
if isinstance(param, ParameterAny):
param_values.append(f"{param.name}: {param.type} {param.allowed_values}\n")
else:
param_values.append(f"{param.name}: {param.type} {param.span}\n")
run_count = self.method._get_run_count(self.parameters)
# Compile data into a dict for return
summary_dict = {
"method": self.method.type,
"method_args": "".join(arg_values),
"param_count": len(self.parameters),
"param_names": ", ".join([param.name for param in self.parameters]),
"param_vals": "".join(param_values),
"max_run_count": run_count,
}
if verbose:
console.log(
"\nSummary of DesignSpace\n\n"
f"Method: {summary_dict['method']}\n"
f"Method Args\n{summary_dict['method_args']}\n"
f"No. of Parameters: {summary_dict['param_count']}\n"
f"Parameters: {summary_dict['param_names']}\n"
f"{summary_dict['param_vals']}\n"
f"Maximum Run Count: {summary_dict['max_run_count']}\n"
)
if fn_pre is not None:
cost_estimate = self.estimate_cost(fn_pre)
summary_dict["cost_estimate"] = cost_estimate
console.log(f"Estimated Maximum Cost: {cost_estimate} FlexCredits")
# NOTE: Could then add more details regarding the output of fn_pre - confirm batching?
# Include additional notes/warnings
notes = []
if isinstance(self.method, MethodGenAlg):
notes.append(
"The maximum run count for MethodGenAlg is difficult to predict. "
"Repeated solutions are not executed, reducing the total number of simulations. "
"High crossover and mutation probabilities may result in an increased number of simulations, potentially exceeding the predicted maximum run count."
)
if isinstance(self.method, (MethodBayOpt, MethodGenAlg, MethodParticleSwarm)):
if any(isinstance(param, ParameterInt) for param in self.parameters):
if any(isinstance(param, ParameterAny) for param in self.parameters):
notes.append(
"Discrete 'ParameterAny' values are automatically converted to 'int' values to be optimized.\n"
)
notes.append(
"Discrete 'int' values are automatically rounded if optimizers generate 'float' predictions.\n"
)
if len(notes) > 0 and verbose:
console.log(
"Notes:",
)
for note in notes:
console.log(note)
return summary_dict