"""Atomic task operations used by higher-level workflow containers."""
from __future__ import annotations
import json
import os
import tempfile
import time
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
from rich.progress import BarColumn, Progress, TaskProgressColumn, TextColumn, TimeElapsedColumn
from tidy3d.components.medium import AbstractCustomMedium
from tidy3d.components.mode.mode_solver import ModeSolver
from tidy3d.components.mode.simulation import ModeSimulation
from tidy3d.components.tcad.simulation.heat_charge import HeatChargeSimulation, TCADAnalysisTypes
from tidy3d.components.workflow import resolve_workflow
from tidy3d.config import config
from tidy3d.exceptions import DataError, WebError, format_chained_exception_message
from tidy3d.log import get_logging_console, log
from tidy3d.web.api.states import (
ALL_POST_VALIDATE_STATES,
COMPLETED_PERCENT,
DIVERGED_STATES,
END_STATES,
ERROR_STATES,
MAX_STEPS,
STATE_PROGRESS_PERCENTAGE,
status_to_stage,
)
from tidy3d.web.cache import resolve_local_cache
from tidy3d.web.core.constants import (
CM_DATA_HDF5_GZ,
MODE_DATA_HDF5_GZ,
MODE_FILE_HDF5_GZ,
MODELER_FILE_HDF5_GZ,
SIM_FILE_HDF5,
SIM_FILE_HDF5_GZ,
SIMULATION_DATA_HDF5_GZ,
)
from tidy3d.web.core.s3utils import upload_file
from tidy3d.web.core.task_core import BatchTask, Folder, SimulationTask, TaskFactory, WebTask
from tidy3d.web.core.task_info import ChargeType, TaskInfo
from tidy3d.web.core.types import TaskType
from .connect_util import REFRESH_TIME, get_grid_points_str, get_time_steps_str, wait_for_connection
from .run_options import (
log_deprecated_run_args,
resolve_pay_type,
resolve_run_start_options,
resolve_upload_options,
resolve_vgpu_start_options,
)
from .tidy3d_stub import Tidy3dStub, Tidy3dStubData, task_type_name_of
if TYPE_CHECKING:
from collections.abc import Callable, Iterable
from os import PathLike
from typing import Literal
from tidy3d.components.base import Tidy3dBaseModel
from tidy3d.components.types.workflow import WorkflowDataType, WorkflowOperationType
from tidy3d.web.cache import CacheEntry
from tidy3d.web.core.constants import TaskId
from tidy3d.web.core.task_info import BatchDetail
from tidy3d.web.core.types import PayType
RUN_REFRESH_TIME = 1.0
SIM_FILE_JSON = "simulation.json"
GUI_SUPPORTED_TASK_TYPES = ["FDTD", "MODE_SOLVER", "HEAT", "TERMINAL_CM"]
BETA_TASK_TYPES = ["HEAT", "EME", "HEAT_CHARGE", "VOLUME_MESH"]
SOLVER_NAME = {
"FDTD": "FDTD",
"MODE_SOLVER": "Mode",
"MODE": "Mode",
"EME": "EME",
"HEAT": "Heat",
"HEAT_CHARGE": "HeatCharge",
"VOLUME_MESH": "VolumeMesher",
}
@dataclass(frozen=True)
class FlexCreditEstimate:
"""Estimated task cost in FlexCredits."""
maximum: float
typical: float | None = None
task_type: str | None = None
is_final_billed_cost: bool | None = None
typical_cost_kind: str | None = None
_FINAL_BILLED_COST_TASK_TYPES = {"MODE", "MODE_SOLVER", "EME", "HEAT"}
_TYPICAL_COST_KIND_CHARGE_SOLVER_ITERATIONS = "charge_solver_iterations"
DEFAULT_DATA_FILENAME = {
TaskType.FDTD.name: "simulation_data.hdf5",
TaskType.MODE_SOLVER.name: "simulation_data.hdf5",
TaskType.MODE.name: "simulation_data.hdf5",
TaskType.EME.name: "simulation_data.hdf5",
TaskType.HEAT.name: "simulation_data.hdf5",
TaskType.HEAT_CHARGE.name: "simulation_data.hdf5",
TaskType.VOLUME_MESH.name: "simulation_data.hdf5",
TaskType.MODAL_CM.name: "cm_data.hdf5",
TaskType.TERMINAL_CM.name: "cm_data.hdf5",
"COMPONENT_MODELER": "cm_data.hdf5",
"TERMINAL_COMPONENT_MODELER": "cm_data.hdf5",
"RF": "cm_data.hdf5",
}
def default_data_filename(task_type: str | None) -> str:
"""Return the default results filename for the given task type."""
if isinstance(task_type, TaskType):
task_type = task_type.name
return DEFAULT_DATA_FILENAME.get(task_type or "", "simulation_data.hdf5")
def _estimate_is_final_billed_cost(estimate: FlexCreditEstimate) -> bool:
"""Return ``True`` when the estimate equals the final billed solver cost."""
if estimate.is_final_billed_cost is not None:
return estimate.is_final_billed_cost
task_type = (estimate.task_type or "").upper()
return task_type in _FINAL_BILLED_COST_TASK_TYPES
def _estimate_has_charge_solver_iteration_scaling(estimate: FlexCreditEstimate) -> bool:
"""Return whether ``estimate.typical`` comes from charge iteration scaling."""
return (
estimate.typical is not None
and not _estimate_is_final_billed_cost(estimate)
and estimate.typical_cost_kind == _TYPICAL_COST_KIND_CHARGE_SOLVER_ITERATIONS
)
def _operation_estimate_is_final_billed_cost(operation: Any) -> bool | None:
"""Return local operation knowledge about whether an estimate is final."""
if isinstance(operation, HeatChargeSimulation):
return TCADAnalysisTypes.CHARGE not in operation._get_simulation_types()
try:
task_type = task_type_name_of(operation)
except TypeError:
return None
return task_type.upper() in _FINAL_BILLED_COST_TASK_TYPES
def _batch_typical_flex_credit_cost(estimates: Iterable[FlexCreditEstimate]) -> float | None:
"""Return a batch typical cost when it can be reported as a complete batch total."""
estimates = list(estimates)
batch_typical_cost = 0.0
has_typical_estimate = False
for estimate in estimates:
if _estimate_is_final_billed_cost(estimate):
batch_typical_cost += estimate.maximum
elif estimate.typical is not None:
has_typical_estimate = True
batch_typical_cost += estimate.typical
elif estimate.maximum == 0:
continue
else:
return None
if not has_typical_estimate:
return None
return batch_typical_cost
def _resolve_output_path(path: PathLike | None, task_type: str | None) -> Path:
"""Resolve an explicit output path or a task-type-specific default filename."""
return Path(path) if path is not None else Path(default_data_filename(task_type))
def _is_web_container(value: object) -> bool:
"""Return whether a value is a public web container shape."""
return isinstance(value, list | tuple | Mapping)
def _raise_if_upload_container(simulation: object) -> None:
"""Raise a clear error for multi-workflow upload containers."""
if not _is_web_container(simulation):
return
raise ValueError(
"tidy3d.web.upload() accepts a single workflow object, but received a "
f"{type(simulation).__name__} container. For multiple workflows, use "
"tidy3d.web.Batch(simulations=...) and call batch.upload(); use "
"tidy3d.web.run(...) if you want the full upload/start/monitor/load flow "
"for a container."
)
def _raise_if_load_container(task_id: object) -> None:
"""Raise a clear error for task-id load containers."""
if not _is_web_container(task_id):
return
raise ValueError(
"tidy3d.web.load() accepts a single task id, or None to load an existing local "
f"file, but received a {type(task_id).__name__} container. Loading task-id "
"containers is not supported by this function because multi-task state is "
"managed by Batch. Use Batch.load(...) for batches, or call "
"tidy3d.web.load(...) once per task id."
)
def _task_type_from_task(task: WebTask, task_type: str | None = None) -> str | None:
"""Normalize the task type for default-path and artifact resolution."""
task_type = task_type or getattr(task, "task_type", None)
if isinstance(task_type, TaskType):
task_type = task_type.name
if isinstance(task, BatchTask):
return task_type or "RF"
return task_type
def _resolve_download_target(
task_id: TaskId,
path: PathLike | None,
task: WebTask | None = None,
task_type: str | None = None,
) -> tuple[Path, WebTask, str | None]:
"""Resolve the output path and task metadata needed to download results."""
if task is None:
task = TaskFactory.get(task_id, verbose=False)
if task is None:
raise ValueError("Task not found.")
task_type = _task_type_from_task(task, task_type)
return _resolve_output_path(path, task_type), task, task_type
def _remote_data_file(task: WebTask, task_type: str | None) -> str:
"""Return the remote results artifact name for a task."""
if isinstance(task, BatchTask):
return CM_DATA_HDF5_GZ
return MODE_DATA_HDF5_GZ if task_type == TaskType.MODE_SOLVER.name else SIMULATION_DATA_HDF5_GZ
def _raise_if_modeler_batch_diverged(task: WebTask) -> None:
"""Raise before downloading aggregate modeler data for diverged RF batches."""
if isinstance(task, BatchTask) and (task.status or "").lower() in DIVERGED_STATES:
raise WebError(
"The RF/modeler task diverged, so aggregate component-modeler data "
"was not produced. Child simulation data may still be available on "
"the child task IDs."
)
def _build_website_url(path: str) -> str:
base = str(config.web.website_endpoint or "")
if not path:
return base
return "/".join([base.rstrip("/"), path.lstrip("/")])
def _get_url(task_id: str) -> str:
"""Get the URL for a task on our server."""
return _build_website_url(f"workbench?taskId={task_id}")
def _get_folder_url(folder_id: str) -> str:
"""Get the URL for a task folder on our server."""
return _build_website_url(f"folders/{folder_id}")
def _get_url_rf(resource_id: str) -> str:
"""Get the RF GUI URL for a modeler/batch group."""
return _build_website_url(f"rf?taskId={resource_id}")
def _get_task_urls(
task_type: str,
resource_id: str,
folder_id: str | None = None,
group_id: str | None = None,
) -> tuple[str, str | None]:
"""Log task and folder links to the web UI."""
if task_type in ["RF", "TERMINAL_CM", "MODAL_CM"]:
url = _get_url_rf(group_id or resource_id)
else:
url = _get_url(resource_id)
if folder_id is not None:
folder_url = _get_folder_url(folder_id)
else:
folder_url = None
return url, folder_url
def _batch_detail_error(resource_id: str) -> WebError | None:
"""Processes a failed batch job to generate a detailed error."""
try:
batch = BatchTask.get(resource_id)
batch_detail = batch.detail()
status = batch_detail.status.lower()
except Exception as e:
log.error(f"Could not retrieve batch details for '{resource_id}': {e}")
raise WebError(
format_chained_exception_message(
f"Failed to retrieve status for batch '{resource_id}'", e
)
) from e
if status not in ERROR_STATES:
return None
if hasattr(batch_detail, "validateErrors") and batch_detail.validateErrors:
try:
error_details = []
for key, error_str in batch_detail.validateErrors.items():
error_details.append(f"- Subtask '{key}' failed: {error_str}")
details_string = "\n".join(error_details)
full_error_msg = (
"One or more subtasks failed validation. Please fix the component modeler "
"configuration.\n"
f"Details:\n{details_string}"
)
except Exception as e:
raise WebError(
format_chained_exception_message(
"One or more subtasks failed validation. Failed to parse validation errors.",
e,
)
) from e
raise WebError(full_error_msg)
raise WebError(
f"Batch '{resource_id}' failed with status '{status}'. Check server logs for details or "
"contact customer support."
)
def _copy_simulation_data_from_cache_entry(entry: CacheEntry, path: PathLike) -> bool:
"""Copy cached simulation data from a cache entry to a specified path."""
if entry is not None:
try:
entry.materialize(Path(path))
return True
except Exception:
return False
return False
def _load_simulation_via_tempfile(task_id: TaskId) -> WorkflowOperationType | None:
"""Load a simulation into a temp file for cache bookkeeping (Windows-safe)."""
handle, fname = tempfile.mkstemp(suffix=".hdf5")
os.close(handle)
try:
task = TaskFactory.get(task_id, verbose=False)
if isinstance(task, BatchTask):
raise NotImplementedError("Operation not implemented for modeler batches.")
path = Path(fname)
task.get_simulation_hdf5(path, verbose=False)
return Tidy3dStub.from_file(path)
finally:
try:
os.unlink(fname)
except FileNotFoundError:
pass
def _cache_simulation_for_load(
*,
task_id: TaskId,
workflow_type: str | None,
lazy: bool,
cache_simulation: WorkflowOperationType | None,
) -> tuple[WorkflowOperationType | None, bool]:
"""Return the simulation object to use for cache storage, and whether to store."""
if cache_simulation is not None:
return cache_simulation, True
if workflow_type == TaskType.VOLUME_MESH.name:
try:
return _load_simulation_via_tempfile(task_id), True
except Exception as e:
log.info(f"Failed to load VolumeMesher for storing results: {e}.")
return None, False
if lazy:
try:
return _load_simulation_via_tempfile(task_id), True
except Exception as e:
log.info(f"Failed to load simulation for storing results: {e}.")
return None, False
return None, True
def get_reduced_simulation(
simulation: WorkflowOperationType,
reduce_simulation: Literal["auto", True, False],
*,
warn_auto: bool = True,
) -> WorkflowOperationType:
"""
Adjust the given simulation object based on the reduce_simulation parameter. Currently only
implemented for the mode solver.
"""
if reduce_simulation == "auto":
if isinstance(simulation, ModeSimulation):
sim_mediums = simulation.scene.mediums
else:
sim_mediums = simulation.simulation.scene.mediums
contains_custom = any(isinstance(med, AbstractCustomMedium) for med in sim_mediums)
reduce_simulation = contains_custom
if reduce_simulation and warn_auto:
log.warning(
f"The {type(simulation)} object contains custom mediums. It will be "
"automatically restricted to the solver domain to reduce data for uploading. "
"To force uploading the original object use 'reduce_simulation=False'."
" Setting 'reduce_simulation=True' will force simulation reduction in all cases and"
" silence this warning."
)
if reduce_simulation:
return simulation.reduced_simulation_copy
return simulation
@wait_for_connection
def restore_simulation_if_cached(
simulation: WorkflowOperationType,
path: PathLike | None = None,
reduce_simulation: Literal["auto", True, False] = "auto",
verbose: bool = True,
) -> tuple[PathLike | None, TaskId | None]:
"""Attempt to restore simulation data from a local cache entry, if available."""
simulation_cache = resolve_local_cache()
retrieved_simulation_path = None
cached_task_id = None
if simulation_cache is not None:
sim_for_cache = simulation
if isinstance(simulation, ModeSolver | ModeSimulation):
sim_for_cache = get_reduced_simulation(simulation, reduce_simulation)
entry = simulation_cache.try_fetch(simulation=sim_for_cache, verbose=verbose)
if entry is not None:
if path is not None:
copied = _copy_simulation_data_from_cache_entry(entry, path)
if copied:
retrieved_simulation_path = path
else:
retrieved_simulation_path = entry.artifact_path
cached_task_id = entry.metadata.get("task_id")
cached_workflow_type = entry.metadata.get("workflow_type")
if cached_task_id is not None and cached_workflow_type is not None and verbose:
console = get_logging_console()
url, _ = _get_task_urls(cached_workflow_type, cached_task_id)
console.log(
"Loading simulation from local cache. "
f"View cached task using web UI at [link={url}]'{url}'[/link]."
)
return retrieved_simulation_path, cached_task_id
def load_simulation_if_cached(
simulation: WorkflowOperationType,
path: PathLike | None = None,
reduce_simulation: Literal["auto", True, False] = "auto",
verbose: bool = True,
) -> WorkflowDataType | None:
"""Load simulation results directly from the local cache, if available."""
restored_path, _ = restore_simulation_if_cached(
simulation=simulation,
path=path,
reduce_simulation=reduce_simulation,
verbose=verbose,
)
if restored_path is None:
return None
data = load(
task_id=None,
path=str(restored_path),
verbose=verbose,
)
if isinstance(simulation, ModeSolver):
simulation._patch_data(data=data)
return data
def _upload_sidecar_artifacts(
resource_id: TaskId,
sidecar_artifacts: Mapping[str, Tidy3dBaseModel],
verbose: bool,
) -> None:
"""Serialize and upload internal sidecar artifacts for an allocated task."""
for remote_filename, artifact in sidecar_artifacts.items():
suffix = "".join(Path(remote_filename).suffixes) or ".hdf5"
handle, fname = tempfile.mkstemp(suffix=suffix)
os.close(handle)
try:
artifact.to_file(fname)
upload_file(
resource_id,
fname,
remote_filename,
verbose=verbose,
)
finally:
os.unlink(fname)
def upload(
simulation: WorkflowOperationType,
task_name: str | None = None,
folder_name: str = "default",
callback_url: str | None = None,
verbose: bool = True,
progress_callback: Callable[[float], None] | None = None,
simulation_type: str | None = None,
parent_tasks: list[str] | None = None,
source_required: bool = True,
solver_version: str | None = None,
reduce_simulation: Literal["auto", True, False] = "auto",
verbose_estimate_cost: bool | None = None,
_workflow_step: bool = False,
) -> TaskId:
return _upload(
simulation=simulation,
task_name=task_name,
folder_name=folder_name,
callback_url=callback_url,
verbose=verbose,
progress_callback=progress_callback,
simulation_type=simulation_type,
parent_tasks=parent_tasks,
source_required=source_required,
solver_version=solver_version,
reduce_simulation=reduce_simulation,
verbose_estimate_cost=verbose_estimate_cost,
_workflow_step=_workflow_step,
)
@wait_for_connection
def _upload(
simulation: WorkflowOperationType,
task_name: str | None = None,
folder_name: str = "default",
callback_url: str | None = None,
verbose: bool = True,
progress_callback: Callable[[float], None] | None = None,
simulation_type: str | None = None,
parent_tasks: list[str] | None = None,
source_required: bool = True,
solver_version: str | None = None,
reduce_simulation: Literal["auto", True, False] = "auto",
verbose_estimate_cost: bool | None = None,
_workflow_step: bool = False,
_sidecar_artifacts: Mapping[str, Tidy3dBaseModel] | None = None,
) -> TaskId:
"""Private upload implementation with optional internal sidecar artifacts."""
_raise_if_upload_container(simulation)
if not _workflow_step:
workflow = resolve_workflow(simulation)
if len(workflow.steps) > 1:
raise DataError(
"'web.upload()' does not support multi-step simulations. "
"Use 'web.run()', 'web.Job(...).run()', or 'web.Job(...).step()' "
"to execute them."
)
console = get_logging_console() if verbose else None
log_deprecated_run_args(
solver_version=solver_version,
)
upload_options = resolve_upload_options(
solver_version=solver_version,
simulation_type=simulation_type,
)
if isinstance(simulation, ModeSolver | ModeSimulation):
simulation = get_reduced_simulation(simulation, reduce_simulation)
stub = Tidy3dStub(simulation=simulation)
stub.validate_pre_upload(source_required=source_required)
log.debug("Creating task.")
if task_name is None:
task_name = stub.get_default_task_name()
task_type = stub.get_type()
task = WebTask.create(
task_type,
task_name,
folder_name,
callback_url,
upload_options.simulation_type,
parent_tasks,
"Gz",
)
group_id = getattr(task, "groupId", None)
resource_id = task.task_id
if verbose:
console.log(
f"Created task '{task_name}' with resource_id '{resource_id}' and task_type '{task_type}'."
)
if task_type in BETA_TASK_TYPES:
solver_name = SOLVER_NAME[task_type]
console.log(
f"Tidy3D's {solver_name} solver is currently in the beta stage. "
f"Cost of {solver_name} simulations is subject to change in the future."
)
if task_type in GUI_SUPPORTED_TASK_TYPES:
url, folder_url = _get_task_urls(task_type, resource_id, task.folder_id, group_id)
console.log(f"View task using web UI at [link={url}]'{url}'[/link].")
console.log(f"Task folder: [link={folder_url}]'{task.folder_name}'[/link].")
remote_sim_file = SIM_FILE_HDF5_GZ
if task_type == "MODE_SOLVER":
remote_sim_file = MODE_FILE_HDF5_GZ
elif task_type in ["RF", "TERMINAL_CM", "MODAL_CM"]:
remote_sim_file = MODELER_FILE_HDF5_GZ
task.upload_simulation(
stub=stub,
verbose=verbose,
progress_callback=progress_callback,
remote_sim_file=remote_sim_file,
)
if _sidecar_artifacts is not None:
_upload_sidecar_artifacts(resource_id, _sidecar_artifacts, verbose=verbose)
verbose_estimate_cost = verbose if verbose_estimate_cost is None else verbose_estimate_cost
estimate_cost(
task_id=resource_id,
solver_version=upload_options.solver_version,
verbose=verbose_estimate_cost,
)
task.validate_post_upload(parent_tasks=parent_tasks)
return resource_id
[docs]
@wait_for_connection
def get_info(task_id: TaskId, verbose: bool = True) -> TaskInfo | BatchDetail:
task = TaskFactory.get(task_id, verbose=verbose)
if not task:
raise ValueError("Task not found.")
return task.detail()
@wait_for_connection
def start(
task_id: TaskId,
solver_version: str | None = None,
worker_group: str | None = None,
pay_type: PayType | str | None = None,
priority: int | None = None,
vgpu_allocation: int | None = None,
ignore_memory_limit: bool | None = None,
) -> None:
task = TaskFactory.get(task_id)
if not task:
raise ValueError("Task not found.")
log_deprecated_run_args(
solver_version=solver_version,
worker_group=worker_group,
pay_type=pay_type,
priority=priority,
vgpu_allocation=vgpu_allocation,
ignore_memory_limit=ignore_memory_limit,
)
dispatch_options = resolve_run_start_options(
solver_version=solver_version,
worker_group=worker_group,
)
if isinstance(task, BatchTask):
resolved_pay_type = resolve_pay_type(pay_type, apply_config_default=False)
resolved_priority = priority
resolved_vgpu_allocation = vgpu_allocation
resolved_ignore_memory_limit = ignore_memory_limit
else:
resolved_pay_type = resolve_pay_type(pay_type)
vgpu_options = resolve_vgpu_start_options(
priority=priority,
vgpu_allocation=vgpu_allocation,
ignore_memory_limit=ignore_memory_limit,
apply_config_defaults=True,
)
resolved_priority = vgpu_options.priority
resolved_vgpu_allocation = vgpu_options.vgpu_allocation
resolved_ignore_memory_limit = vgpu_options.ignore_memory_limit
task.submit(
solver_version=dispatch_options.solver_version,
worker_group=dispatch_options.worker_group,
pay_type=resolved_pay_type,
priority=resolved_priority,
vgpu_allocation=resolved_vgpu_allocation,
ignore_memory_limit=resolved_ignore_memory_limit,
additional_payload=dispatch_options.additional_payload,
)
[docs]
@wait_for_connection
def get_run_info(task_id: TaskId) -> tuple[float | None, float | None]:
task = TaskFactory.get(task_id)
if isinstance(task, BatchTask):
raise NotImplementedError("Operation not implemented for modeler batches.")
return task.get_running_info()
def _get_batch_detail_handle_error_status(batch: BatchTask) -> BatchDetail:
"""Get batch detail and raise error if status is in ERROR_STATES."""
detail = batch.detail()
status = detail.status.lower()
if status in ERROR_STATES:
_batch_detail_error(batch.task_id)
return detail
def get_status(task_id: TaskId) -> str:
"""Get the status of a task. Raises an error if status is ``error``."""
task = TaskFactory.get(task_id)
if isinstance(task, BatchTask):
return _get_batch_detail_handle_error_status(task).status
task_info = get_info(task_id)
status = task_info.status
if status == "visualize":
return "success"
if status in ERROR_STATES:
try:
task = SimulationTask(taskId=task_id)
with tempfile.NamedTemporaryFile(suffix=".json") as tmp_file:
task.get_error_json(to_file=tmp_file.name)
with open(tmp_file.name) as f:
error_content = json.load(f)
error_msg = error_content["msg"]
except Exception:
error_msg = "Error message could not be obtained, please contact customer support."
raise WebError(f"Error running task {task_id}! {error_msg}")
return status
def _batch_detail_progress(detail: BatchDetail) -> tuple[str, str, float]:
"""Compute display status, color status, and progress percentage from BatchDetail subtasks."""
batch_status = (detail.status or "draft").lower()
if not detail.tasks:
return batch_status, batch_status, STATE_PROGRESS_PERCENTAGE.get(batch_status, 0)
if batch_status in END_STATES:
pct = STATE_PROGRESS_PERCENTAGE.get(batch_status, COMPLETED_PERCENT)
return batch_status, batch_status, pct
n_tasks = len(detail.tasks)
stage_acc = 0.0
status_counts: dict[str, int] = {}
for task in detail.tasks:
task_status = (task.status or "draft").lower()
stage_name, idx = status_to_stage(task_status)
stage_acc += idx / MAX_STEPS
status_counts[stage_name] = status_counts.get(stage_name, 0) + 1
task_avg = stage_acc / n_tasks
pct = task_avg * 0.8 * COMPLETED_PERCENT
dominant_stage = max(status_counts, key=status_counts.get)
dominant_count = status_counts[dominant_stage]
if n_tasks > 1 and dominant_count < n_tasks:
display_status = f"{dominant_stage} ({dominant_count}/{n_tasks})"
else:
display_status = dominant_stage
return display_status, dominant_stage, pct
def _monitor_modeler_batch(
task_id: str,
verbose: bool = True,
max_detail_tasks: int = 20,
) -> None:
"""Monitor modeler batch progress with aggregate and per-task views."""
console = get_logging_console() if verbose else None
task = BatchTask.get(task_id=task_id)
detail = _get_batch_detail_handle_error_status(task)
name = detail.name or "modeler_batch"
group_id = detail.groupId
status = detail.status.lower()
if not verbose:
while status_to_stage(status)[0] not in END_STATES:
time.sleep(REFRESH_TIME)
detail = _get_batch_detail_handle_error_status(task)
status = detail.status.lower()
return
progress_columns = (
TextColumn("[progress.description]{task.description}"),
BarColumn(bar_width=25),
TaskProgressColumn(),
TextColumn("[progress.description]{task.fields[status]}"),
TimeElapsedColumn(),
)
header = f"Subtasks status - {name}"
if group_id:
header += f"\nGroup ID: '{group_id}'"
console.log(header)
with Progress(*progress_columns, console=console, transient=False) as progress:
display_status, _, _ = _batch_detail_progress(detail)
p_run = progress.add_task("Run Total", total=1.0, status=f" {display_status} ")
task_bars: dict[str, int] = {}
prev_display_status = display_status
console.log(f"Batch status = {status}")
end_monitor = False
while not end_monitor:
total = len(detail.tasks)
completed_runs = detail.runSuccess or 0
display_status, _, _ = _batch_detail_progress(detail)
if display_status != prev_display_status:
prev_display_status = display_status
console.log(f"Batch status = {display_status}")
progress.update(p_run, status=f" {display_status} ")
if total and total <= max_detail_tasks and detail.tasks:
name_to_task = {(task.taskName or task.taskId): task for task in detail.tasks or []}
for name, batch_task in name_to_task.items():
if name not in task_bars:
task_status = (batch_task.status or "draft").lower()
pbar = progress.add_task(
f" {name}",
total=1.0,
completed=STATE_PROGRESS_PERCENTAGE[task_status] / 100,
status=f" {task_status} ",
)
task_bars[name] = pbar
if detail.tasks:
acc = 0.0
n_members = 0
for batch_task in detail.tasks or []:
n_members += 1
task_status = (batch_task.status or "draft").lower()
_, idx = status_to_stage(task_status)
acc += max(0.0, min(1.0, idx / MAX_STEPS))
run_frac = ((acc / float(n_members)) if n_members else 0.0) * 0.8
else:
run_frac = (completed_runs / total) * 0.8 if total else 0.0
if status in END_STATES:
end_monitor = True
run_frac = 1.0
progress.update(p_run, completed=run_frac)
if task_bars and detail.tasks:
name_to_task = {(task.taskName or task.taskId): task for task in detail.tasks or []}
for task_name, pbar in task_bars.items():
batch_task = name_to_task.get(task_name)
if not batch_task:
continue
task_status = (batch_task.status or "draft").lower()
progress.update(
pbar,
completed=STATE_PROGRESS_PERCENTAGE[task_status] / 100,
description=f" {task_name}",
status=f" {task_status} ",
refresh=False,
)
progress.refresh()
time.sleep(REFRESH_TIME)
detail = _get_batch_detail_handle_error_status(task)
status = detail.status.lower()
console.log("Modeler has finished running successfully.")
real_cost(task.task_id, verbose=verbose)
@wait_for_connection
def monitor(task_id: TaskId, verbose: bool = True, worker_group: str | None = None) -> None:
"""Print the real time task progress until completion."""
del worker_group
task_kind = TaskFactory.get_kind(task_id)
if task_kind is BatchTask:
return _monitor_modeler_batch(task_id, verbose=verbose)
console = get_logging_console() if verbose else None
task_info = get_info(task_id)
task_name = task_info.taskName
task_type = task_info.taskType
def get_estimated_cost() -> float:
task_info_local = get_info(task_id)
block_info = task_info_local.taskBlockInfo
if block_info and block_info.chargeType == ChargeType.FREE:
est_flex_unit = 0
grid_points = block_info.maxGridPoints
time_steps = block_info.maxTimeSteps
grid_points_str = get_grid_points_str(grid_points)
time_steps_str = get_time_steps_str(time_steps)
console.log(
f"You are running this simulation for FREE. Your current plan allows"
f" up to {block_info.maxFreeCount} free non-concurrent simulations per"
f" day (under {grid_points_str} grid points and {time_steps_str}"
f" time steps)"
)
else:
est_flex_unit = task_info_local.estFlexUnit
return est_flex_unit
def monitor_preprocess() -> None:
status_local = get_status(task_id)
while status_local not in END_STATES and status_local != "running":
new_status = get_status(task_id)
if new_status != status_local:
status_local = new_status
if verbose and status_local != "running":
console.log(f"status = {status_local}")
time.sleep(REFRESH_TIME)
status = get_status(task_id)
if verbose:
console.log(f"status = {status}")
if status in END_STATES:
return None
if verbose:
console.log(
"To cancel the simulation, use 'web.abort(task_id)' or 'web.delete(task_id)' "
"or abort/delete the task in the web "
"UI. Terminating the Python script will not stop the job running on the cloud."
)
with console.status(f"[bold green]Waiting for '{task_name}'...", spinner="runner"):
monitor_preprocess()
else:
monitor_preprocess()
if verbose:
get_estimated_cost()
console.log("starting up solver")
while get_run_info(task_id)[0] is None and get_status(task_id) == "running":
time.sleep(REFRESH_TIME)
if verbose:
console.log("running solver")
if "FDTD" in task_type:
with Progress(console=console) as progress:
pbar_pd = progress.add_task("% done", total=100)
perc_done, _ = get_run_info(task_id)
while (
perc_done is not None and perc_done < 100 and get_status(task_id) == "running"
):
perc_done, field_decay = get_run_info(task_id)
progress.update(
pbar_pd,
completed=perc_done,
description=f"solver progress (field decay = {field_decay:.2e})",
)
time.sleep(RUN_REFRESH_TIME)
perc_done, field_decay = get_run_info(task_id)
if perc_done is not None and perc_done < 100 and field_decay > 0:
console.log(f"early shutoff detected at {perc_done:1.0f}%, exiting.")
progress.update(
pbar_pd,
completed=100,
refresh=True,
description=f"solver progress (field decay = {field_decay:.2e})",
)
elif task_type == "EME":
with Progress(console=console) as progress:
pbar_pd = progress.add_task("% done", total=100)
perc_done, _ = get_run_info(task_id)
while (
perc_done is not None and perc_done < 100 and get_status(task_id) == "running"
):
perc_done, _ = get_run_info(task_id)
progress.update(pbar_pd, completed=perc_done, description="solver progress")
time.sleep(RUN_REFRESH_TIME)
progress.update(pbar_pd, completed=100, refresh=True, description="solver progress")
else:
while get_status(task_id) == "running":
_ = get_run_info(task_id)
time.sleep(RUN_REFRESH_TIME)
else:
perc_done, _ = get_run_info(task_id)
while perc_done is not None and perc_done < 100 and get_status(task_id) == "running":
perc_done, _ = get_run_info(task_id)
time.sleep(RUN_REFRESH_TIME)
if verbose:
status = get_status(task_id)
if status != "running":
console.log(f"status = {status}")
with console.status(f"[bold green]Finishing '{task_name}'...", spinner="runner"):
while status not in END_STATES:
new_status = get_status(task_id)
if new_status != status:
status = new_status
console.log(f"status = {status}")
time.sleep(REFRESH_TIME)
if task_type in GUI_SUPPORTED_TASK_TYPES:
url = _get_url(task_id)
console.log(f"View simulation result at [blue underline][link={url}]'{url}'[/link].")
else:
while get_status(task_id) not in END_STATES:
time.sleep(REFRESH_TIME)
return None
@wait_for_connection
def download(
task_id: TaskId,
path: PathLike | None = None,
verbose: bool = True,
progress_callback: Callable[[float], None] | None = None,
) -> None:
"""Download results of task to file."""
path, task, task_type = _resolve_download_target(task_id=task_id, path=path)
_raise_if_modeler_batch_diverged(task)
remote_data_file = _remote_data_file(task, task_type)
task.get_data_hdf5(
to_file=path,
remote_data_file_gz=remote_data_file,
verbose=verbose,
progress_callback=progress_callback,
)
return None
@wait_for_connection
def load(
task_id: TaskId | None,
path: PathLike | None = None,
verbose: bool = True,
progress_callback: Callable[[float], None] | None = None,
replace_existing: bool = True,
lazy: bool = False,
cache_simulation: WorkflowOperationType | None = None,
store_in_cache: bool = True,
_allow_existing_path_with_task_id: bool = False,
) -> WorkflowDataType:
"""Download and load simulation results into a data object."""
_raise_if_load_container(task_id)
from_cache = task_id is None
task = None
task_type = None
path = Path(path) if path is not None else None
reuse_existing_path = path is not None and path.exists() and not replace_existing
if from_cache:
path = _resolve_output_path(path, None)
elif reuse_existing_path and _allow_existing_path_with_task_id:
pass
else:
path, task, task_type = _resolve_download_target(task_id=task_id, path=path)
reuse_existing_path = path.exists() and not replace_existing
if from_cache:
if not path.exists():
raise FileNotFoundError("Cached file not found.")
elif not path.exists() or replace_existing:
if task is None or not hasattr(task, "get_data_hdf5"):
download(
task_id=task_id,
path=path,
verbose=verbose,
progress_callback=progress_callback,
)
else:
remote_data_file = _remote_data_file(task, task_type)
_raise_if_modeler_batch_diverged(task)
task.get_data_hdf5(
to_file=path,
remote_data_file_gz=remote_data_file,
verbose=verbose,
progress_callback=progress_callback,
)
if verbose and not from_cache:
console = get_logging_console()
console.log(f"Loading results from {path}")
stub_data = Tidy3dStubData.postprocess(path, lazy=lazy)
simulation_cache = resolve_local_cache()
should_store_in_cache = store_in_cache and simulation_cache is not None and not from_cache
if should_store_in_cache and (not reuse_existing_path or cache_simulation is not None):
if reuse_existing_path:
workflow_type = task_type_name_of(cache_simulation)
else:
info = get_info(task_id, verbose=False)
workflow_type = getattr(info, "taskType", None)
if workflow_type != TaskType.MODE_SOLVER.name:
simulation, should_store = _cache_simulation_for_load(
task_id=task_id,
workflow_type=workflow_type,
lazy=lazy,
cache_simulation=cache_simulation,
)
if should_store:
simulation_cache.store_result(
stub_data=stub_data,
task_id=task_id,
path=path,
workflow_type=workflow_type,
simulation=simulation,
)
return stub_data
[docs]
@wait_for_connection
def abort(task_id: TaskId) -> TaskInfo | None:
"""Abort server-side data associated with task."""
console = get_logging_console()
task = TaskFactory.get(task_id, verbose=False)
if not task:
return None
url = task.get_url()
task.abort()
console.log(
f"Task is aborting. View task using web UI at [link={url}]'{url}'[/link] to check the result."
)
return TaskInfo(
**{"taskId": task_id, "taskType": getattr(task, "task_type", None), **task.model_dump()}
)
[docs]
@wait_for_connection
def download_json(task_id: TaskId, path: PathLike = SIM_FILE_JSON, verbose: bool = True) -> None:
"""Download the ``.json`` simulation file associated with a task."""
task = TaskFactory.get(task_id, verbose=False)
if isinstance(task, BatchTask):
raise NotImplementedError("Operation not implemented for modeler batches.")
task.get_simulation_json(path, verbose=verbose)
[docs]
@wait_for_connection
def delete_old(days_old: int, folder_name: str = "default") -> int:
"""Remove folder contents older than ``days_old``."""
folder = Folder.get(folder_name, create=True)
return folder.delete_old(days_old)
[docs]
@wait_for_connection
def load_simulation(
task_id: TaskId, path: PathLike = SIM_FILE_JSON, verbose: bool = True
) -> WorkflowOperationType:
"""Download a task's simulation file and load the associated workflow object."""
task = TaskFactory.get(task_id, verbose=False)
if isinstance(task, BatchTask):
raise NotImplementedError("Operation not implemented for modeler batches.")
path = Path(path)
if path.suffix == ".json":
task.get_simulation_json(path, verbose=verbose)
elif path.suffix == ".hdf5":
task.get_simulation_hdf5(path, verbose=verbose)
else:
raise ValueError("Path suffix must be '.json' or '.hdf5'")
return Tidy3dStub.from_file(path)
[docs]
@wait_for_connection
def download_log(
task_id: TaskId,
path: PathLike = "tidy3d.log",
verbose: bool = True,
progress_callback: Callable[[float], None] | None = None,
) -> None:
"""Download the log file associated with a task."""
task = TaskFactory.get(task_id, verbose=False)
if isinstance(task, BatchTask):
raise NotImplementedError("Operation not implemented for modeler batches.")
task.get_log(path, verbose=verbose, progress_callback=progress_callback)
@wait_for_connection
def download_simulation(
task_id: TaskId,
path: PathLike = SIM_FILE_HDF5,
verbose: bool = True,
progress_callback: Callable[[float], None] | None = None,
) -> None:
"""Download the ``.hdf5`` simulation file associated with a task."""
task = TaskFactory.get(task_id, verbose=False)
if isinstance(task, BatchTask):
raise NotImplementedError("Operation not implemented for modeler batches.")
task_type = _task_type_from_task(task)
if task_type is None:
info = get_info(task_id, verbose=False)
task_type = getattr(info, "taskType", None)
remote_sim_file = (
MODE_FILE_HDF5_GZ if task_type == TaskType.MODE_SOLVER.name else SIM_FILE_HDF5_GZ
)
task.get_simulation_hdf5(
path,
verbose=verbose,
progress_callback=progress_callback,
remote_sim_file=remote_sim_file,
)
[docs]
@wait_for_connection
def get_tasks(
num_tasks: int | None = None, order: Literal["new", "old"] = "new", folder: str = "default"
) -> list[dict]:
"""Get metadata of tasks in the requested folder."""
folder = Folder.get(folder, create=True)
tasks = folder.list_tasks()
if not tasks:
return []
if order == "new":
tasks = sorted(tasks, key=lambda t: t.created_at, reverse=True)
elif order == "old":
tasks = sorted(tasks, key=lambda t: t.created_at)
if num_tasks is not None:
tasks = tasks[:num_tasks]
return [task.model_dump() for task in tasks]
[docs]
@wait_for_connection
def delete(task_id: TaskId, versions: bool = False) -> TaskInfo:
"""Delete server-side data associated with task."""
if not task_id:
raise ValueError("Task id not found.")
task = TaskFactory.get(task_id, verbose=False)
task.delete(versions)
return TaskInfo(**{"taskId": task.task_id, **task.model_dump()})
def _log_flex_credit_estimate(console: Any, estimate: FlexCreditEstimate) -> None:
"""Log a user-facing FlexCredit estimate."""
task_type = (estimate.task_type or "").upper()
if estimate.typical is not None:
if _estimate_has_charge_solver_iteration_scaling(estimate):
console.log(
f"Estimated typical FlexCredit cost: {estimate.typical:1.3f}. "
"For charge simulations, the billed cost depends on the number of solver "
"iterations required for convergence."
)
console.log(
f"Maximum FlexCredit cost: {estimate.maximum:1.3f}. This assumes the charge "
"solver reaches its configured iteration limits for all applied biases. Use "
"'web.real_cost(task_id)' to get the billed FlexCredit cost after a simulation "
"run."
)
else:
console.log(f"Estimated typical FlexCredit cost: {estimate.typical:1.3f}.")
console.log(
f"Maximum FlexCredit cost: {estimate.maximum:1.3f}. Use "
"'web.real_cost(task_id)' to get the billed FlexCredit cost after a simulation "
"run."
)
return
if task_type in {"FDTD", "RF_FDTD"}:
console.log(
f"Estimated FlexCredit cost: {estimate.maximum:1.3f}. This assumes the FDTD "
"solver runs for the full simulation time; if early shutoff is reached, the "
"billed cost can be lower. Use 'web.real_cost(task_id)' to get the billed "
"FlexCredit cost after a simulation run."
)
elif _estimate_is_final_billed_cost(estimate):
console.log(
f"Estimated FlexCredit cost: {estimate.maximum:1.3f}. For this solver type, "
"the estimate is the final billed cost."
)
else:
console.log(
f"Estimated FlexCredit cost: {estimate.maximum:1.3f}. Use "
"'web.real_cost(task_id)' to get the billed FlexCredit cost after a simulation "
"run."
)
@wait_for_connection
def estimate_cost_info(
task_id: TaskId,
verbose: bool = True,
solver_version: str | None = None,
*,
is_final_billed_cost: bool | None = None,
) -> FlexCreditEstimate:
"""Compute the FlexCredit charge estimate details for a given task."""
if not isinstance(task_id, str):
raise ValueError(
f"Task ID: {task_id} is not a string. You can get it using 'web.upload(<simulation>)'."
)
console = get_logging_console() if verbose else None
task = TaskFactory.get(task_id, verbose=False)
detail = task.detail()
if isinstance(task, BatchTask):
check_task_type = "FDTD" if detail.taskType == "MODAL_CM" else "RF_FDTD"
task.check(solver_version=solver_version, check_task_type=check_task_type)
detail = task.detail()
status = detail.status.lower()
while status not in ALL_POST_VALIDATE_STATES:
time.sleep(REFRESH_TIME)
detail = task.detail()
status = detail.status.lower()
if status in ERROR_STATES:
_batch_detail_error(resource_id=task_id)
est_flex_unit = detail.estFlexUnit
estimate = FlexCreditEstimate(
maximum=est_flex_unit,
task_type=detail.taskType,
is_final_billed_cost=is_final_billed_cost,
)
if verbose:
_log_flex_credit_estimate(console, estimate)
return estimate
task.estimate_cost(solver_version=solver_version)
task_info = get_info(task_id)
status = task_info.metadataStatus
while status not in ALL_POST_VALIDATE_STATES:
time.sleep(REFRESH_TIME)
task_info = get_info(task_id)
status = task_info.metadataStatus
if status in ERROR_STATES:
try:
task = SimulationTask(taskId=task_id)
with tempfile.NamedTemporaryFile(suffix=".json") as tmp_file:
task.get_error_json(to_file=tmp_file.name, validation=True)
with open(tmp_file.name) as f:
error_content = json.load(f)
error_msg = error_content["validation_error"]
except Exception:
error_msg = "Error message could not be obtained, please contact customer support."
raise WebError(f"Error estimating cost for task {task_id}! {error_msg}")
typical = task_info.estFlexUnitTypical
estimate_is_final_billed_cost = is_final_billed_cost
if (task_info.taskType or "").upper() == TaskType.HEAT_CHARGE.name:
if estimate_is_final_billed_cost is None and typical is not None and typical <= 0:
estimate_is_final_billed_cost = True
if estimate_is_final_billed_cost:
typical = None
elif typical is not None and typical <= 0:
typical = None
typical_cost_kind = None
if typical is not None and (task_info.taskType or "").upper() == TaskType.HEAT_CHARGE.name:
typical_cost_kind = _TYPICAL_COST_KIND_CHARGE_SOLVER_ITERATIONS
estimate = FlexCreditEstimate(
maximum=task_info.estFlexUnit,
typical=typical,
task_type=task_info.taskType,
is_final_billed_cost=estimate_is_final_billed_cost,
typical_cost_kind=typical_cost_kind,
)
if verbose:
_log_flex_credit_estimate(console, estimate)
fc_mode = task_info.estFlexCreditMode
fc_post = task_info.estFlexCreditPostProcess
if fc_mode:
console.log(f" {fc_mode:1.3f} FlexCredit of the total cost from mode solves.")
if fc_post:
console.log(f" {fc_post:1.3f} FlexCredit of the total cost from post-processing.")
return estimate
[docs]
def estimate_cost(
task_id: TaskId,
verbose: bool = True,
solver_version: str | None = None,
) -> float:
"""Compute the maximum FlexCredit charge for a given task."""
return estimate_cost_info(
task_id=task_id,
verbose=verbose,
solver_version=solver_version,
).maximum
[docs]
@wait_for_connection
def real_cost(task_id: TaskId, verbose: bool = True) -> float | None:
"""Get the billed cost for given task after it has been run."""
if not isinstance(task_id, str):
raise ValueError(
f"Task ID: {task_id} is not a string. You can get it using 'web.upload(<simulation>)'."
)
console = get_logging_console() if verbose else None
task_info = get_info(task_id)
flex_unit = task_info.realFlexUnit
ori_flex_unit = getattr(task_info, "oriRealFlexUnit", flex_unit)
if not flex_unit:
log.warning(
f"Billed FlexCredit for task '{task_id}' is not available. If the task has been "
"successfully run, it should be available shortly."
)
elif verbose:
console.log(f"Billed flex credit cost: {flex_unit:1.3f}.")
if flex_unit != ori_flex_unit and "FDTD" in task_info.taskType:
console.log(
"Note: the task cost pro-rated due to early shutoff was below the minimum "
"threshold, due to fast shutoff. Decreasing the simulation 'run_time' should "
"decrease the estimated, and correspondingly the billed cost of such tasks."
)
return flex_unit