"""Web API for mode solver"""
from __future__ import annotations
import os
import tempfile
import time
from datetime import datetime
from typing import TYPE_CHECKING
from botocore.exceptions import ClientError
from joblib import Parallel, delayed
from pydantic import Field
from rich.progress import Progress
from tidy3d.components.data.monitor_data import ModeSolverData
from tidy3d.components.eme.simulation import EMESimulation
from tidy3d.components.medium import AbstractCustomMedium
from tidy3d.components.simulation import Simulation
from tidy3d.config import config
from tidy3d.exceptions import SetupError, WebError, format_chained_exception_message
from tidy3d.log import get_logging_console, log
from tidy3d.plugins.mode.mode_solver import MODE_MONITOR_NAME, ModeSolver
from tidy3d.version import __version__
from tidy3d.web.api.run_options import log_deprecated_run_args, resolve_pay_type
from tidy3d.web.core.core_config import get_logger_console
from tidy3d.web.core.http_util import http
from tidy3d.web.core.s3utils import download_file, download_gz_file, upload_file
from tidy3d.web.core.task_core import Folder
from tidy3d.web.core.types import ResourceLifecycle, Submittable
if TYPE_CHECKING:
import pathlib
from collections.abc import Callable
from os import PathLike
from typing import Literal
import requests
from rich.progress import TaskID
from tidy3d.web.core.types import PayType
SIMULATION_JSON = "simulation.json"
SIM_FILE_HDF5_GZ = "simulation.hdf5.gz"
MODESOLVER_API = "tidy3d/modesolver/py"
MODESOLVER_JSON = "mode_solver.json"
MODESOLVER_HDF5 = "mode_solver.hdf5"
MODESOLVER_GZ = "mode_solver.hdf5.gz"
MODESOLVER_LOG = "output/result.log"
MODESOLVER_RESULT = "output/result.hdf5"
MODESOLVER_RESULT_GZ = "output/mode_solver_data.hdf5.gz"
DEFAULT_NUM_WORKERS = 10
DEFAULT_MAX_RETRIES = 3
DEFAULT_RETRY_DELAY = 10 # in seconds
[docs]
def run(
mode_solver: ModeSolver,
task_name: str = "Untitled",
mode_solver_name: str = "mode_solver",
folder_name: str = "Mode Solver",
results_file: PathLike = "mode_solver.hdf5",
verbose: bool = True,
progress_callback_upload: Callable[[float], None] | None = None,
progress_callback_download: Callable[[float], None] | None = None,
reduce_simulation: Literal["auto", True, False] = "auto",
pay_type: PayType | str | None = None,
) -> ModeSolverData:
"""Submits a :class:`.ModeSolver` to server, starts running, monitors progress, downloads,
and loads results as a :class:`.ModeSolverData` object.
Parameters
----------
mode_solver : :class:`.ModeSolver`
Mode solver to upload to server.
task_name : str = "Untitled"
Name of task.
mode_solver_name: str = "mode_solver"
The name of the mode solver to create the in task.
folder_name : str = "Mode Solver"
Name of folder to store task on web UI.
results_file : PathLike = "mode_solver.hdf5"
Path to download results file (.hdf5).
verbose : bool = True
If ``True``, will print status, otherwise, will run silently.
progress_callback_upload : Callable[[float], None] = None
Optional callback function called when uploading file with ``bytes_in_chunk`` as argument.
progress_callback_download : Callable[[float], None] = None
Optional callback function called when downloading file with ``bytes_in_chunk`` as argument.
reduce_simulation : Literal["auto", True, False] = "auto"
Restrict simulation to mode solver region. If "auto", then simulation is automatically
restricted if it contains custom mediums.
pay_type : Optional[Union[PayType, str]] = None
Deprecated direct argument for internal use only. Internal workflows should set
``td.config.run.pay_type`` instead; external users should leave unset.
Returns
-------
:class:`.ModeSolverData`
Mode solver data with the calculated results.
"""
log_deprecated_run_args(pay_type=pay_type)
log_level = "DEBUG" if verbose else "INFO"
if verbose:
console = get_logging_console()
if reduce_simulation == "auto":
sim_mediums = mode_solver.simulation.scene.mediums
contains_custom = any(isinstance(med, AbstractCustomMedium) for med in sim_mediums)
reduce_simulation = contains_custom
if reduce_simulation:
log.warning(
"The associated 'Simulation' object contains custom mediums. It will be "
"automatically restricted to the mode solver plane to reduce data for uploading. "
"To force uploading the original 'Simulation' object use 'reduce_simulation=False'."
" Setting 'reduce_simulation=True' will force simulation reduction in all cases and"
" silence this warning."
)
if reduce_simulation:
mode_solver = mode_solver.reduced_simulation_copy
task = ModeSolverTask.create(mode_solver, task_name, mode_solver_name, folder_name)
if verbose:
console.log(
f"Mode solver created with task_id='{task.task_id}', solver_id='{task.solver_id}'."
)
task.upload(verbose=verbose, progress_callback=progress_callback_upload)
task.submit(pay_type=pay_type)
# Wait for task to finish
prev_status = "draft"
status = task.status
while status not in ("success", "error", "diverged", "deleted"):
if status != prev_status:
log.log(log_level, f"Mode solver status: {status}")
if verbose:
console.log(f"Mode solver status: {status}")
prev_status = status
time.sleep(0.5)
status = task.get_info().status
if status == "error":
raise WebError("Error running mode solver.")
log.log(log_level, f"Mode solver status: {status}")
if verbose:
console.log(f"Mode solver status: {status}")
if status != "success":
# Our cache discards None, so the user is able to re-run
return None
return task.get_result(
to_file=results_file, verbose=verbose, progress_callback=progress_callback_download
)
[docs]
def run_batch(
mode_solvers: list[ModeSolver],
task_name: str = "BatchModeSolver",
folder_name: str = "BatchModeSolvers",
results_files: list[str] | None = None,
verbose: bool = True,
max_workers: int = DEFAULT_NUM_WORKERS,
max_retries: int = DEFAULT_MAX_RETRIES,
retry_delay: float = DEFAULT_RETRY_DELAY,
progress_callback_upload: Callable[[float], None] | None = None,
progress_callback_download: Callable[[float], None] | None = None,
) -> list[ModeSolverData]:
"""
Submits a batch of ModeSolver to the server concurrently, manages progress, and retrieves results.
Parameters
----------
mode_solvers : list[ModeSolver]
List of mode solvers to be submitted to the server.
task_name : str
Base name for tasks. Each task in the batch will have a unique index appended to this base name.
folder_name : str
Name of the folder where tasks are stored on the server's web UI.
results_files : list[str], optional
List of file paths where the results for each ModeSolver should be downloaded. If None, a default path based on the folder name and index is used.
verbose : bool
If True, displays a progress bar. If False, runs silently.
max_workers : int
Maximum number of concurrent workers to use for processing the batch of simulations.
max_retries : int
Maximum number of retries for each simulation in case of failure before giving up.
retry_delay : int
Delay in seconds between retries when a simulation fails.
progress_callback_upload : Callable[[float], None], optional
Optional callback function called when uploading file with ``bytes_in_chunk`` as argument.
progress_callback_download : Callable[[float], None], optional
Optional callback function called when downloading file with ``bytes_in_chunk`` as argument.
Returns
-------
list[ModeSolverData]
A list of ModeSolverData objects containing the results from each simulation in the batch. ``None`` is placed in the list for simulations that fail after all retries.
"""
console = get_logging_console()
if not all(isinstance(x, ModeSolver) for x in mode_solvers):
console.log(
"Validation Error: All items in `mode_solvers` must be instances of `ModeSolver`."
)
return []
num_mode_solvers = len(mode_solvers)
if results_files is None:
results_files = [f"mode_solver_batch_results_{i}.hdf5" for i in range(num_mode_solvers)]
def handle_mode_solver(index: int, progress: Progress, pbar: TaskID | None) -> Parallel | None:
retries = 0
while retries <= max_retries:
try:
result = run(
mode_solver=mode_solvers[index],
task_name=f"{task_name}_{index}",
mode_solver_name=f"mode_solver_batch_{index}",
folder_name=folder_name,
results_file=results_files[index],
verbose=False,
progress_callback_upload=progress_callback_upload,
progress_callback_download=progress_callback_download,
)
if verbose:
progress.update(pbar, advance=1)
return result
except Exception as e:
console.log(f"Error in mode solver {index}: {e!s}")
if retries < max_retries:
time.sleep(retry_delay)
retries += 1
else:
console.log(f"The {index}-th mode solver failed after {max_retries} tries.")
if verbose:
progress.update(pbar, advance=1)
return None
return None
if verbose:
console.log(f"[cyan]Running a batch of [deep_pink4]{num_mode_solvers} mode solvers.\n")
# Create the common folder before running the parallel computation
_ = Folder.create(folder_name=folder_name)
with Progress(console=console) as progress:
pbar = progress.add_task("Status:", total=num_mode_solvers)
results = Parallel(n_jobs=max_workers, backend="threading")(
delayed(handle_mode_solver)(i, progress, pbar) for i in range(num_mode_solvers)
)
# Make sure the progress bar is complete
progress.update(pbar, completed=num_mode_solvers, refresh=True)
console.log("[green]A batch of `ModeSolver` tasks completed successfully!")
else:
results = Parallel(n_jobs=max_workers, backend="threading")(
delayed(handle_mode_solver)(i, None, None) for i in range(num_mode_solvers)
)
return results
[docs]
class ModeSolverTask(ResourceLifecycle, Submittable, extra="allow"):
"""Interface for managing the running of a :class:`.ModeSolver` task on server."""
task_id: str | None = Field(
None,
title="task_id",
description="Task ID number, set when the task is created, leave as None.",
alias="refId",
)
solver_id: str | None = Field(
None,
title="solver",
description="Solver ID number, set when the task is created, leave as None.",
alias="id",
)
real_flex_unit: float | None = Field(
None, title="real FlexCredits", description="Billed FlexCredits.", alias="charge"
)
created_at: datetime | None = Field(
title="created_at", description="Time at which this task was created.", alias="createdAt"
)
status: str | None = Field(
None,
title="status",
description="Mode solver task status.",
)
file_type: str | None = Field(
None,
title="file_type",
description="File type used to upload the mode solver.",
alias="fileType",
)
mode_solver: ModeSolver | None = Field(
None,
title="mode_solver",
description="Mode solver being run by this task.",
)
[docs]
@classmethod
def create(
cls,
mode_solver: ModeSolver,
task_name: str = "Untitled",
mode_solver_name: str = "mode_solver",
folder_name: str = "Mode Solver",
) -> ModeSolverTask:
"""Create a new mode solver task on the server.
Parameters
----------
mode_solver: :class".ModeSolver"
The object that will be uploaded to server in the submitting phase.
task_name: str = "Untitled"
The name of the task.
mode_solver_name: str = "mode_solver"
The name of the mode solver to create the in task.
folder_name: str = "Mode Solver"
The name of the folder to store the task.
Returns
-------
:class:`ModeSolverTask`
:class:`ModeSolverTask` object containing information about the created task.
"""
folder = Folder.get(folder_name, create=True)
mode_solver.validate_pre_upload()
if isinstance(mode_solver.simulation, Simulation):
mode_solver.simulation.validate_pre_upload(source_required=False)
elif isinstance(mode_solver.simulation, EMESimulation):
# TODO: replace this with native web api support
raise SetupError(
"'EMESimulation' is not yet supported in the "
"remote mode solver web api. Please instead call 'ModeSolver.to_fdtd_mode_solver' "
"before using the web api; this replaces the 'EMESimulation' with a 'Simulation' "
"that can be used in the remote mode solver. "
"Alternatively, you can add a 'ModeSolverMonitor' to the 'EMESimulation' "
"and use the EME solver web api."
)
# mode_solver.simulation.validate_pre_upload()
else:
raise SetupError("Simulation type not supported in the remote mode solver web api.")
response_body = {
"projectId": folder.folder_id,
"taskName": task_name,
"protocolVersion": __version__,
"modeSolverName": mode_solver_name,
"fileType": "Gz",
"source": "Python",
}
resp = http.post(MODESOLVER_API, response_body)
# TODO: actually fix the root cause later.
# sometimes POST for mode returns None and crashes the log.info call.
# For now this catches it and raises a better error that we can debug.
if resp is None:
raise WebError(
"'ModeSolver' POST request returned 'None'. If you received this error, please "
"raise an issue on the Tidy3D front end GitHub repository referencing the following"
f"response body '{response_body}'."
)
log.info(
"Mode solver created with task_id='%s', solver_id='%s'.", resp["refId"], resp["id"]
)
return ModeSolverTask(**resp, mode_solver=mode_solver)
[docs]
@classmethod
def get(
cls,
task_id: str,
solver_id: str,
to_file: PathLike = "mode_solver.hdf5",
sim_file: PathLike = "simulation.hdf5",
verbose: bool = True,
progress_callback: Callable[[float], None] | None = None,
) -> ModeSolverTask:
"""Get mode solver task from the server by id.
Parameters
----------
task_id: str
Unique identifier of the task on server.
solver_id: str
Unique identifier of the mode solver in the task.
to_file: PathLike = "mode_solver.hdf5"
File to store the mode solver downloaded from the task.
sim_file: PathLike = "simulation.hdf5"
File to store the simulation downloaded from the task.
verbose: bool = True
Whether to display progress bars.
progress_callback : Callable[[float], None] = None
Optional callback function called while downloading the data.
Returns
-------
:class:`ModeSolverTask`
:class:`ModeSolverTask` object containing information about the task.
"""
resp = http.get(f"{MODESOLVER_API}/{task_id}/{solver_id}")
task = ModeSolverTask(**resp)
mode_solver = task.get_modesolver(to_file, sim_file, verbose, progress_callback)
return task.model_copy(update={"mode_solver": mode_solver})
[docs]
def get_info(self) -> ModeSolverTask:
"""Get the current state of this task on the server.
Returns
-------
:class:`ModeSolverTask`
:class:`ModeSolverTask` object containing information about the task, without the mode
solver.
"""
resp = http.get(f"{MODESOLVER_API}/{self.task_id}/{self.solver_id}")
return ModeSolverTask(**resp, mode_solver=self.mode_solver)
[docs]
def upload(
self, verbose: bool = True, progress_callback: Callable[[float], None] | None = None
) -> None:
"""Upload this task's 'mode_solver' to the server.
Parameters
----------
verbose: bool = True
Whether to display progress bars.
progress_callback : Callable[[float], None] = None
Optional callback function called while uploading the data.
"""
mode_solver = self.mode_solver.copy()
sim = mode_solver.simulation
# Upload simulation.hdf5.gz for GUI display
file, file_name = tempfile.mkstemp(".hdf5.gz")
os.close(file)
try:
sim.to_hdf5_gz(file_name)
upload_file(
self.task_id,
file_name,
SIM_FILE_HDF5_GZ,
verbose=verbose,
progress_callback=progress_callback,
)
finally:
os.unlink(file_name)
# Upload a single HDF5 file with the full data
file, file_name = tempfile.mkstemp(".hdf5.gz")
os.close(file)
try:
mode_solver.to_hdf5_gz(file_name)
upload_file(
self.solver_id,
file_name,
MODESOLVER_GZ,
verbose=verbose,
progress_callback=progress_callback,
)
finally:
os.unlink(file_name)
[docs]
def submit(
self,
pay_type: PayType | str | None = None,
) -> None:
"""Start the execution of this task.
The mode solver must be uploaded to the server with the :meth:`ModeSolverTask.upload` method
before this step.
"""
http.post(
f"{MODESOLVER_API}/{self.task_id}/{self.solver_id}/run",
{
"enableCaching": config.web.enable_caching,
"payType": resolve_pay_type(pay_type).value,
},
)
[docs]
def delete(self) -> None:
"""Delete the mode solver and its corresponding task from the server."""
# Delete mode solver
http.delete(f"{MODESOLVER_API}/{self.task_id}/{self.solver_id}")
# Delete parent task
http.delete(f"tidy3d/tasks/{self.task_id}")
[docs]
def abort(self) -> requests.Response:
"""Abort the mode solver and its corresponding task from the server."""
return http.put(
"tidy3d/tasks/abort", json={"taskType": "MODE_SOLVER", "taskId": self.solver_id}
)
[docs]
def get_modesolver(
self,
to_file: PathLike = "mode_solver.hdf5",
sim_file: PathLike = "simulation.hdf5",
verbose: bool = True,
progress_callback: Callable[[float], None] | None = None,
) -> ModeSolver:
"""Get mode solver associated with this task from the server.
Parameters
----------
to_file: PathLike = "mode_solver.hdf5"
File to store the mode solver downloaded from the task.
sim_file: PathLike = "simulation.hdf5"
File to store the simulation downloaded from the task, if any.
verbose: bool = True
Whether to display progress bars.
progress_callback : Callable[[float], None] = None
Optional callback function called while downloading the data.
Returns
-------
:class:`.ModeSolver`
:class:`.ModeSolver` object associated with this task.
"""
if self.file_type == "Gz":
file, file_path = tempfile.mkstemp(".hdf5.gz")
os.close(file)
try:
download_file(
self.solver_id,
MODESOLVER_GZ,
to_file=file_path,
verbose=verbose,
progress_callback=progress_callback,
)
mode_solver = ModeSolver.from_hdf5_gz(file_path)
finally:
os.unlink(file_path)
elif self.file_type == "Hdf5":
file, file_path = tempfile.mkstemp(".hdf5")
os.close(file)
try:
download_file(
self.solver_id,
MODESOLVER_HDF5,
to_file=file_path,
verbose=verbose,
progress_callback=progress_callback,
)
mode_solver = ModeSolver.from_hdf5(file_path)
finally:
os.unlink(file_path)
else:
file, file_path = tempfile.mkstemp(".json")
os.close(file)
try:
download_file(
self.solver_id,
MODESOLVER_JSON,
to_file=file_path,
verbose=verbose,
progress_callback=progress_callback,
)
mode_solver_dict = ModeSolver.dict_from_json(file_path)
finally:
os.unlink(file_path)
download_file(
self.task_id,
SIMULATION_JSON,
to_file=sim_file,
verbose=verbose,
progress_callback=progress_callback,
)
mode_solver_dict["simulation"] = Simulation.from_json(sim_file)
mode_solver = ModeSolver.model_validate(mode_solver_dict)
# Store requested mode solver file
mode_solver.to_file(to_file)
return mode_solver
[docs]
def get_result(
self,
to_file: PathLike = "mode_solver_data.hdf5",
verbose: bool = True,
progress_callback: Callable[[float], None] | None = None,
) -> ModeSolverData:
"""Get mode solver results for this task from the server.
Parameters
----------
to_file: PathLike = "mode_solver_data.hdf5"
File to store the mode solver downloaded from the task.
verbose: bool = True
Whether to display progress bars.
progress_callback : Callable[[float], None] = None
Optional callback function called while downloading the data.
Returns
-------
:class:`.ModeSolverData`
Mode solver data with the calculated results.
"""
file = None
try:
file = download_gz_file(
resource_id=self.solver_id,
remote_filename=MODESOLVER_RESULT_GZ,
to_file=to_file,
verbose=verbose,
progress_callback=progress_callback,
)
except ClientError:
if verbose:
console = get_logger_console()
console.log(f"Unable to download '{MODESOLVER_RESULT_GZ}'.")
if not file:
try:
file = download_file(
resource_id=self.solver_id,
remote_filename=MODESOLVER_RESULT,
to_file=to_file,
verbose=verbose,
progress_callback=progress_callback,
)
except Exception as e:
raise WebError(
format_chained_exception_message(
"Failed to download the simulation data file from the server. Please "
"confirm that the task was successfully run.",
e,
)
) from e
data = ModeSolverData.from_hdf5(to_file)
data = data.copy(
update={"monitor": self.mode_solver.to_mode_solver_monitor(name=MODE_MONITOR_NAME)}
)
self.mode_solver._cached_properties["data_raw"] = data
# Perform symmetry expansion
self.mode_solver._cached_properties.pop("data", None)
return self.mode_solver.data
[docs]
def get_log(
self,
to_file: PathLike = "mode_solver.log",
verbose: bool = True,
progress_callback: Callable[[float], None] | None = None,
) -> pathlib.Path:
"""Get execution log for this task from the server.
Parameters
----------
to_file: PathLike = "mode_solver.log"
File to store the mode solver downloaded from the task.
verbose: bool = True
Whether to display progress bars.
progress_callback : Callable[[float], None] = None
Optional callback function called while downloading the data.
Returns
-------
path: pathlib.Path
Path to saved file.
"""
return download_file(
self.solver_id,
MODESOLVER_LOG,
to_file=to_file,
verbose=verbose,
progress_callback=progress_callback,
)